Skip to content
WPCom100Public

About

Business and project management web app for Luminary Technology & Productions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

LTP Business Suite

Business management web app for Luminary Technology & Productions.

Stack

  • Frontend: Vanilla JS + React 18 via CDN (no build step)
  • Backend: Python FastAPI + SQLAlchemy (async)
  • Database: PostgreSQL (Railway managed)
  • Deployment: Railway

Deploy to Railway

1. Push to GitHub

git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/YOUR_USER/ltp-app.git
git push -u origin main

2. Create Railway project

  1. Go to railway.com and create a new project
  2. Click "Deploy from GitHub repo" and select your repo
  3. Click "+ New" → "Database" → "PostgreSQL"
  4. Railway auto-provisions the database and sets DATABASE_URL

3. Connect the database

In your FastAPI service's Variables tab, add a reference:

  • DATABASE_URL → click "Add Reference" → select the PostgreSQL service's DATABASE_URL

Railway will inject the connection string automatically.

4. Deploy

Railway will:

  1. Detect requirements.txt and install Python dependencies
  2. Run build.sh to copy frontend files to frontend/
  3. Start the server with uvicorn backend.main:app

The app will be live at your Railway domain.

Local Development

# Install dependencies
pip install -r requirements.txt

# Build frontend
bash build.sh

# Run server (uses SQLite locally)
uvicorn backend.main:app --reload --port 8000

Open http://localhost:8000

Migrations

Schema changes go through Alembic. There is no in-process database reset switch — recovery from corruption is via Railway's point-in-time restore, not an env var.

Make a schema change:

# 1. Edit backend/models.py (add column, change type, add index, etc.)
# 2. Autogenerate the migration against your local sqlite
python -m alembic revision --autogenerate -m "describe the change"
# 3. Open alembic/versions/<timestamp>_*.py and review:
#    - Are op.add_column / op.alter_column doing what you expect?
#    - For NOT NULL columns on tables with data, add a backfill in upgrade()
#      BEFORE the alter so existing rows get a sane default.
#    - Does downgrade() correctly reverse the change?
# 4. Apply locally to verify
python -m alembic upgrade head
# 5. Commit the migration file alongside the model change.

Deploy: the next boot runs alembic upgrade head automatically (see backend/database.py::init_db). New deploys pick up pending migrations with no manual step.

Common commands:

python -m alembic current               # what revision is live?
python -m alembic history --verbose     # full migration log
python -m alembic check                 # any pending diffs vs models?
python -m alembic downgrade -1          # roll back one revision (LOCAL ONLY)

Operational note on the cutover. init_db detects pre-Alembic databases (app tables present but no alembic_version table) and auto-stamps them to the initial revision. This is safe ONLY because the initial migration was autogenerated from the same models that the prior create_all path produced. After the first post-cutover deploy reaches every environment, this branch is a no-op and can be deleted in a future cleanup.

Backups

Railway PostgreSQL backups are enabled in the database service's Settings → Backups tab on railway.com. With backups enabled, Railway takes daily snapshots and offers point-in-time restore for any moment in the retention window. There is no manual pg_dump job to maintain.

Verify on Railway:

  1. Open the project → click the Postgres service
  2. Settings → scroll to Backups
  3. Confirm backups are Enabled and note the retention window
  4. (Optional) test restore once: clone the service from a recent snapshot into a staging environment and confirm the data round-trips

If retention is shorter than you want, the plan tier on Railway controls it — bump there rather than building a parallel backup pipeline.

Environment variables

Set in Railway → service → Variables. Local dev uses a .env file or shell exports.

Variable Required? Default What it does
DATABASE_URL Prod (sqlite local) Railway sets automatically when the Postgres service is linked.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET Prod — OAuth client credentials from Google Cloud Console → APIs & Services → Credentials.
LTP_OAUTH_REDIRECT_URI Prod — The full https://yourdomain/auth/callback URL. Must match what's registered in Google Cloud Console.
LTP_SESSION_SECRET Prod (ephemeral) Signs Authlib's transient state cookie. Generate via python -c "import secrets; print(secrets.token_urlsafe(32))". Without it, sign-in breaks across pod restarts.
LTP_ALLOWED_DOMAIN Optional (none) Restrict sign-in to a single Workspace domain (e.g. ltpnj.com). Empty = anyone with a Google account.
LTP_TOKEN_ENCRYPTION_KEY Required for Gmail send — Fernet key for encrypting Gmail OAuth tokens at rest. Generate via python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())". Missing key causes a RuntimeError on the first sign-in after the email feature ships — set this before deploying the email feature. Comma-separated values support key rotation (new key first; old keys still decrypt).
LTP_TRUST_PROXY_HOPS Optional 1 Number of trusted reverse-proxy hops sitting between the client and the app. 1 is correct for Railway with no CDN. Bump to 2 if you front Railway with Cloudflare/Fastly, 3 for two CDNs, etc. Set to 0 for purely-local dev with no proxy. Misconfiguring higher than actual hops enables IP spoofing — match it to your real topology.
LTP_MAX_PAYLOAD_BYTES Optional 10485760 Max request body size; rejects with 413 above this.
LTP_SESSION_SWEEP_INTERVAL_SECONDS Optional 3600 How often the background sweeper deletes expired session rows.
QBO_CLIENT_ID / QBO_CLIENT_SECRET Required for QuickBooks — OAuth app credentials from the Intuit Developer portal → your app → Keys & credentials. Reuses LTP_TOKEN_ENCRYPTION_KEY to encrypt the stored QuickBooks tokens at rest.
QBO_REDIRECT_URI Required for QuickBooks — The full https://yourdomain/api/qbo/callback URL. Must match a redirect URI registered on the Intuit app (add both sandbox and production variants).
QBO_ENVIRONMENT Optional sandbox sandbox or production — selects the QuickBooks API host. Start in sandbox; switch to production (and reconnect) when ready to go live.
LTP_VAPID_PUBLIC_KEY Required for push — VAPID public key (base64url uncompressed P-256 point, ~87 chars) handed to browsers as the applicationServerKey. Generate with the command in Push notifications.
LTP_VAPID_PRIVATE_KEY Required for push — VAPID private key (base64url 32-byte scalar) that signs each push. Secret — never sent to the browser. Generated alongside the public key.
LTP_VAPID_SUBJECT Required for push — mailto:you@yourdomain.com — a contact the push services can reach about delivery problems. All three VAPID vars must be present for push to send; missing any one makes push a no-op.
LTP_APP_VARIANT Optional (none) Set to dev on a non-production deployment to give its installed PWA a distinct home-screen icon (the "DEV"-tagged variant) and name ("LTP Dev"), so it's unmistakable next to production ("LTP"). Unset = normal production identity. See Dev app identity.
ANTHROPIC_API_KEY Optional (none) Enables label OCR in the barcode scan-import flow: when the barcode won't decode (glare/angle), the frame is sent to Claude to read the serial printed on the label. Unset = the feature hides itself entirely. Secret — server-side only. Get a key at console.anthropic.com; usage is roughly $2–3 per 1,000 fallback reads.
LTP_OCR_MODEL Optional claude-haiku-4-5-20251001 Overrides the Claude model used for label OCR. Haiku is fast and cheap; only change if Anthropic deprecates the default.
LTP_CREW_PORTAL_ORIGIN Optional (none) The crew portal's own domain, e.g. https://crew.luminarytechnology.productions — a second custom domain on the same Railway service. When set, every crew-facing email link (invitations, resets, call sheets) is built on it, and requests arriving on that host get the crew identity: a bare visit lands on #/crew-portal, the tab and home-screen name read LTP Crew, and the PWA installs opening on the portal. Unset = crew links use the app origin. Setup: docs/CREW_DOMAIN.md.
LTP_CREW_PORTAL_SENDER_EMAIL Optional (none) Which team member's Gmail sends the crew portal's self-serve emails — a crew member's own "Forgot your password?" and "Request access" (invitations and resets a producer sends from the roster go out as that producer). Must be a user who has signed in with Gmail connected. Unset = the most recently signed-in admin with Gmail connected. See Crew portal.

Inline add / edit on entity pickers

Anywhere you pick a company, contact, or project, you can create or fix that record without leaving the page you're on — no abandoning a half-built quote to go to CRM and back.

  • Create. Type a name that doesn't match anything and the dropdown offers + Create "…" as a new company (previously the dropdown just vanished on zero matches, which read as a broken search box). There's also a + at the right edge of every such field. The form that opens is the same CRM form, so there's one set of fields and one set of validation rules. What you typed is carried in as the name, and the new record is selected the moment you save.
  • Edit. When something is selected, a ✎ opens that record's form pre-filled — fix a company's billing address (which drives QuickBooks sales tax) or a contact's email mid-quote, and the change lands on the record everywhere.
  • Context carries over. A project created from a quote or invoice inherits that document's company, its primary contact, and any custom dates. A contact created from a Primary Contact field is linked to the document's company, so it appears in that narrowed list immediately. A vendor created from a rentals field is flagged isVendor.
  • Nesting works. Creating a project from a quote, then creating that project's company from inside the project form, is fine — each form stacks above the one that opened it.

Deleting is not offered here — it stays in CRM, where the guided teardown reviews linked quotes, invoices, and crew assignments first.

Where it is (and deliberately isn't)

Surface Field
Quote builder Company · Primary Contact · Contact (individual) · Linked Project
Invoice builder Company · Primary Contact · Contact (individual) · Linked Project
Project form Company · Project Contacts
Contact form Link to Companies
Meeting form Attendees
Rentals — equipment & scan import Purchase Vendor (CRM)

Not added, on purpose: crew selection (schedule editor, assignments, manual shift) picks from a roster, not a record you author; statuses, categories, departments, units and payment terms are fixed lists; QuickBooks income/expense accounts are created in QuickBooks; and products, services, fees, equipment, kits and containers keep their existing catalog screens.

If you add a new picker, pass createKind / allowEdit to it only when the user would be naming a new record rather than choosing from a known set — see the header comment in components/entity-quick-form.js.

Linked Project is now a search field

The Linked Project picker on quotes and invoices was a native dropdown; it is now a type-to-search chip field matching the Company field. Clearing the chip is what the old "(no project — use custom name)" option did. It still filters to the document's company, still hides internal manual-shift projects and completed ones, and still warns on a company mismatch.

Searchable dropdowns

Dropdowns whose list grows are type-to-search fields rather than native <select>s (window.LTPSearchSelect, components/search-dropdown.js). Clicking one opens the full list immediately — you never have to type to browse — with the search box focused so you can type straight away. ↑/↓ move, Enter picks, Esc cancels without changing anything.

Converted: crew pickers (schedule editor, Assignments, Manual Shift), role / rate-card pickers, the Assignments project filter, Primary Contact on quotes and invoices, and Link to Meeting on project notes. The company / contact / project chip fields already worked this way and now also show their list on focus instead of waiting for a keystroke.

Left as native dropdowns, deliberately:

Why
Status, Category, Unit, Department, Type, Payment Method, Net terms Frozen lists of 3–10. Typing to narrow four options is slower, not faster.
QuickBooks income / expense accounts (Settings and the per-item overrides) Seeing every account at once is the point of those fields.
Rate type and pricing variant inside quote/invoice line rows A popover fights the row layout and horizontal scrolling.
Person-slot #1 / #2 Positional, not a list you search.

If you add a picker, the test is whether the list grows with the business. It should not be searchable just for visual consistency.

Crew pickers: role-matched first, everyone else behind a click

A crew dropdown lists only crew tagged with the role being filled. Everyone else is behind an "Other crew (N) — not tagged for this role" row you have to click, so a name match from another department can't bury the people who are actually qualified. Each row shows the person's roles and department underneath.

Crew is picked, never authored. These fields have no inline-create — a crew member is a roster entry, not something you invent from a schedule row. Adding createKind to a crew picker would be a bug; tests/test_pickers.js asserts the authorable set stays company / contact / project.

Whoever is currently assigned always appears in the first tier, flagged (— inactive, or — not tagged for this role) when they'd otherwise be filtered out. This fixes a quiet data bug: a native <select> whose value matches none of its options renders its first option, so a position held by a crew member who had since gone inactive displayed "Crew…" — indistinguishable from unassigned — while the booking was still live. The same fix applies to Primary Contact, which likewise shows the selected person even when the narrowed list excludes them, with "Other contacts" as the escape hatch.

Previously declined this shift. A crew member who turned down a request for a shift is listed under their own heading at the bottom of that shift's picker — dated, with the note they left — instead of among the eligible names, so nobody is asked twice by accident. Picking someone from that section asks you to confirm before they're assigned (and so re-asked on the next send). "This shift" is the schedule day: a decline for L2 #1 on the show day also marks L2 #2 that day; a flat-rate position is its own shift. The latest ask wins, so someone asked again who accepted (or hasn't answered) drops out of the section, while a re-ask you withdrew doesn't clear it. Nothing new is stored — the record is the crew request itself, read back per shift by LTP_declinedCrewIndex (components/domain-crew.js, covered by tests/test_declined_crew.js). Labor → Assignments and both Schedule Builder pickers (day rows and flat-rate positions) read the same index.

Saved venues

The project form's Venue Name box remembers every venue you have used. Type a few letters and the venues from past projects appear underneath, matched on the name or the address; pick one and the Site Address and Parking & Access Instructions fill in from the last project held there. Type a name nothing matches and it is simply a new venue, remembered the moment the project is saved.

The project form is the only place this list is edited. There is no venue screen and no venue API write path. Saving a project is what teaches the memory:

  • A venue row exists for every distinct name (matched without regard to case). Renaming a project's venue remembers a new one; the old row stays, since other projects may still carry it.
  • An existing venue takes a field only when that save changed the field on the project. A schedule save, a status change or a crew send that writes the whole unchanged row leaves the memory alone, so a stale window can never rewrite what a newer project taught.
  • The address remembered is the typed site address. A site address derived from the client company's record is that company's, not the venue's, and an emptied address never blanks the saved one. Instructions are mirrored once edited, clearing included.
  • Deleting a project never touches the saved venue.

Each project keeps its own copy of the address and instructions, so a one-off note for this job ("use the north dock this time") can be edited on the project without being the venue's permanent instructions until it is saved there again. The instructions travel with the address everywhere crew see it: the project's Location block, the public call sheet (modules/crew-view.js) and the crew portal.

Where it lives: backend/models.py::Venue, written by backend/routes/api.py::_remember_venue on every project create/update and served read-only at GET /api/venues (POST/PUT/DELETE answer 405). The frontend reads it as the venues collection — components/data-state.js lists it under READONLY_KEYS, so a setter on it never writes — and the picker is window.VenueField (components/search-select.js) over LTP_HELPERS.venueSuggestions / venueFill (components/helpers.js). Covered by tests/test_venues.py and the venue cases in tests/test_pickers.js.

Client service rates (contract rates + day minimums)

A client can be on a negotiated rate for specific roles, with its own crew pay and its own minimum charge — the shape of a real contract:

A1 for FUMC is a reduced day rate, but carries a full 10-hour day minimum, so a 4-hour call bills the full adjusted day rate, not the half day.

Rates are added one role at a time. Nothing is toggled on catalog-wide: a role with no negotiated row bills the base rate card exactly as before, so the feature is invisible until you use it.

Where you edit them

  • Quotes → Client Rates — pick a client (company or an individual billed directly), then add the roles they negotiated. Also lists every client who has rates, so you can jump between contracts.
  • CRM → a company → Service Rates — the same editor, in context.

Each row carries:

Field Meaning
Client Rate (day / half / hourly / OT) What this client pays. Blank = inherit the base card.
Crew Pay (day / half / hourly / OT) What we pay for that role on this client's work — the margin column, independent of the rate.
Bill Min (hrs) The day bills as if at least this many hours were worked.
Pay Min (hrs) Same, for the payout. Separate on purpose — see below.
Contract Label / Notes Which agreement this came from.
Active / Paused Park a rate without deleting it; paused rows are ignored everywhere.

Blank means inherit, 0 means zero. Set only the Day row and the half / hourly / OT tiers derive off it (÷2, ÷10, ÷10×1.5) exactly like the base card — so a discounted day rate can't leave a stale full-price half day behind. Restate any tier explicitly to override that.

How a minimum behaves

The minimum floors the non-penalty hours of a person's day, then the normal tier and OT math runs on top:

  • 4h worked, 10h minimum → full day at the contract rate, no OT.
  • 7h straight through, 10h minimum → full day plus the 2h meal penalty. A guarantee can't absorb a penalty the client actually incurred.
  • 12h worked, 10h minimum → priced on the 12 hours. The minimum is a floor, never a cap.
  • 4h worked, 12h minimum → full day + 2h OT.

Bill and pay minimums are separate numbers. A client's 10-hour billing guarantee does not silently become a 10-hour payout — with a bill minimum only, the same day bills full and pays half. Set both when the crew is owed the guarantee too. A crew member's own negotiated floor (Labor → Crew Roster → Minimum Day Rate) still applies on top of whatever the contract pays.

Where it shows up

Resolution happens at one seam — theme.js::LTP_servicesForClient folds a client's overrides into the rate card, and every pricing surface consumes the result:

  • Schedule builder / editor — day totals, per-person breakdown ("A1 — Full 4h · billed 10h (10h min)"), a CLIENT RATE chip on the position, and a panel listing the client's negotiated roles.
  • Quote from a schedule — lines price at the contract rate, and a day billed up to a minimum says so in the line notes.
  • Quote builder / invoice editor — the service picker and the rate-type switch price at the client's rate; a line whose snapshot no longer matches the contract offers a one-click apply rather than re-pricing itself (a sent document must never move on its own).
  • Payouts — the recomputed figure and the pay locked at confirm/sign-off use the project's client rate card; a day paid up to a minimum is labelled "(contract minimum)".

Already-priced quote/invoice lines and already-locked pay keep their saved amounts when a contract changes — the same rule the base rate card follows.

Data model

client_rates, one row per (client, service) — see backend/models.py::ClientRate. All three FKs CASCADE, so an override can't outlive its client or its service. The engine reads only minHours / minCostHours off the resolved service; the rate/cost columns are ordinary rate-card values by the time they reach it.

Hourly roles (shop and warehouse work)

Every role has always priced a shift through the day-rate card: up to 5 hours is a half day, up to 10 a full day, then overtime. That is wrong for shop work, where a 3-hour call is 3 hours. A role can be marked Hourly (Quotes → Services → the role → Billing), and only that role changes; every other role is a day-rate role by default and is untouched — the flag is invisible until you set it.

  • Pricing — every paid hour bills the role's hourly rate and pays its hourly cost; there is no half or full day. Overtime is unchanged: meal-penalty hours and hours past 10 in a day go at the OT tier (hourly × 1.5 unless restated), the same rule every other role follows.
  • The rate card — an hourly role's hourly figure is its source of truth; the day and half-day tiers derive from it (×10, ×5) rather than the other way round, so a day rate left behind on a role that was flipped to hourly can't price anything. A role flipped with only a day rate on file prices at day ÷ 10 until an hourly rate is typed (the form seeds that number).
  • Where it shows — the schedule editor's breakdown reads "SH — Hourly 6h"; Send to Quote / Invoice produces one Hourly line per role (qty = hours across people and days, OT pooled as usual); the service picker on a quote or invoice starts the line on the Hourly tier; Payouts and the QuickBooks vendor bill label the day "Hourly · 6h".
  • Minimums still apply — a client's contract minimum floors the hours (a 2-hour call on a 4-hour minimum bills 4 hours), and a crew member's negotiated day minimum floors their hourly cost at ÷10.
  • Manual shifts need nothing special: a manual shift with an hourly role prices hourly through the same engine, request and payout pipeline.

services.hourly (backend/models.py::Service). Engine: components/domain-labor.js::LTP_calcDayLabor / LTP_hourlyTiers, guarded by tests/test_labor_rates.js (section N) and tests/test_doc_projects.js.

Cross rentals & vendor pricing

Gear rented in from a vendor to cover what we don't stock or don't have free on the dates. The whole thing rests on three ideas:

  • A cross rental is an order. One per vendor, with a reference number, a rental period, an optional project and a list of lines — the fixtures (catalog items) and the parts and accessories that come with them (free text, cost only). It reads like the vendor's PO because that is what it is.
  • Quoted is not inventory; confirmed is. Like a quote, an order the vendor has only quoted does not change availability. It is flagged as quoted wherever availability is shown, so nobody sources it twice. Once the order is confirmed (or picked up), its catalog-item lines count as our stock for the dates they cover. Owned units and cross-rented units stack.
  • Vendor prices are remembered. What each vendor charges us per item, in the same 3-day / week / month tiers as our own rates, with the date it was last quoted — so the next time you can see who to call and what they charged.

Where it lives

Surface What you get
Rentals → Cross Rentals The orders list (filters for open / status / vendor, overdue flag), the order form, and the detail popup with each line's cost and what other vendors price the item at.
Rentals → Availability Checker Totals include confirmed cross-rented units for the range, with an incl. ×N cross-rented · Vendor chip; quoted orders show as ×N quoted. When an item is short, the row lists the vendors who price it (preferred first, then cheapest) and + Cross rental opens the order form already filled in with the item, the dates and the vendor.
Quote builder → Add Item Same totals and chips. An item with nothing free offers Cross-rent, which opens the order form over the picker so the draft stays put.
Quote builder → margin Equipment margin is net of what the gear costs to rent in — see Margin on rented-in gear below.
Rentals → an item's popup A Cross-rented tile, the item's Vendor Pricing list (editable in place), the open cross rentals naming it, and its Bookings — which job, which document, when, and a per-booking state. The checker opens this popup when you click an item.
Rentals → Equipment List Where says which jobs each item is on ("Autumn Gala ×3 (out)"), and the status reads out (physically checked out), reserved (booked for today or later), available, or cross-rental.
CRM → a vendor Rental Rates — every item this vendor prices — and the orders placed with them, with the year's spend.
Rentals → Equipment → Add / Edit Cross-rental only marks gear we never stock. The row still exists so the item can be quoted at our rates and checked for availability; the inventory list labels it instead of showing a 0-unit item as available. It is a label, not a mode — switch it off the day you buy some.

The order form

Pick the vendor and every catalog line's rates are seeded from that vendor's price rows (a RATE ON FILE chip while unchanged, with the quoted date). Type a different price and the line says so. Each line carries qty, the three tiers, a computed cost from the rental-pricing engine (or a flat override total for a negotiated deal), optional own dates when a part comes back on a different day, and notes. Remember these prices (on by default) refreshes the vendor's price rows on save — only for lines whose price differs from what is on file, stamped with today's date, so re-saving an old order to mark it returned never re-dates a price.

Status runs quoted → confirmed → picked-up → returned, or cancelled. It never moves on its own; an order past its end date and still out shows as overdue.

Margin on rented-in gear

A quote's margin used to treat every equipment line as pure profit, because we own what we send out and an owned unit costs nothing extra on a job. That stops being true the moment a line needs more than we own. For each equipment line, over its own rental dates:

  • Units are taken from owned stock first — those cost nothing.
  • What is left is taken from the confirmed cross rentals covering those dates, and costs the vendor's price for that order (a line's flat total, or its 3-day / week / month rate priced by the same engine as everything else). Several orders can supply one item; each is costed at its own price.
  • Bookings other documents hold over the same dates are charged against owned stock first, so whatever they spill onto the cross rentals is not available to this quote. The quote's own bookings are excluded, or an accepted quote would read as competing with itself.

The cost shows in the Totals panel as incl. cross rentals under Total Cost, and both the section margin and the two summary margins are net of it. It is resolved live from the orders, never stored on the line, so it follows a cross rental being confirmed, re-priced or cancelled.

A cross rental shared across projects is split, not counted whole against each. One order line has a single fixed cost however many jobs borrow its units across the period, so that cost is spread over every live (non-declined) quote that draws cross-rented units from it, weighted by unit-days — units drawn times that line's own rental days. Only used days count: a piece rented Mon–Fri two weeks running with the weekend idle splits 50/50 between the two jobs, never by the ten-day span, and the idle gap is nobody's cost. A job that uses more units, or holds them longer, pays proportionally more. A sole user bears the whole line. The engine (modules/rentals-utils.js::crossRentalMargin) runs over every quote so an edit anywhere re-splits immediately.

When it can't be resolved — nothing covers those dates, what does is already spoken for, or the item is gone from the catalog — the unresolved units are not silently treated as free. A small ⚠ UNCOSTED chip sits beside the section margin and both summary margins, and its tooltip names the units. A merely quoted cross rental never counts as cost, for the same reason it never counts as stock.

Counting rule

A cross rental counts toward a date range only when its period covers the whole range — deliberately conservative, and the mirror of how bookings are counted (any overlapping booking counts against the whole range). A line's own dates, when set, are what is checked; otherwise the order's.

Data model

vendor_rates, one row per (vendor company, equipment item), both FKs CASCADE — see backend/models.py::VendorRate. cross_rentals, one row per order with the lines in JSON (CrossRental.lines, documented on the model); vendor and project FKs SET NULL so the cost record outlives either. equipment.cross_rental_only is the label above. The helpers every surface shares live in modules/rentals-utils.js (crossRentedQty, crossQuotedQty, totalQty, vendorOptions, lineCost, orderCost), guarded by tests/test_rentals_cross.js and tests/test_quote_availability.py.

Quote rental periods when a project's dates move

A quote's rental window is its primary project's dates, read live, and every section that doesn't set its own dates follows it. Equipment lines, though, are priced once — for the window in force when they were added or last repriced — and opening a quote never silently reprices them (that would overwrite the snapshot a sent quote was priced at whenever a rate card changed since). So when a project's dates moved after a quote existed, the quote showed the new dates over prices computed for the old ones, and nobody was told.

Each section now remembers the window its equipment was priced for (pricedStartDate / pricedEndDate in the section JSON — stamped by the builder whenever it reprices a section and again on save; a one-off migration stamped every existing quote with its window at the time). When that window no longer matches the project, nothing is repriced on its own. The editor is told, and decides:

  • In the builder, a Rental periods are out of sync notice under Quote Details names the stale sections, with Update all to new dates and Keep old dates on all. Each stale section shows the same choice on its own row. Update to new dates reprices its equipment for the project's dates from the current rate card. Keep turns the old window into that section's custom rental period, prices untouched — the same thing as ticking Custom rental period and typing the old dates. A line added to a stale section prices on the window the section is still on, so "keep" never leaves one line on the new dates.
  • The quotes list and the project's Quotes tab show a Dates changed chip on the draft and sent quotes concerned, so they can be found without opening each one.
  • Saving a project whose dates moved toasts which live quotes price their equipment on them — from the CRM edit form and from the inline project edit on a quote's Linked Projects chip alike.
  • The save log records an update as
    Rental Period: Repriced for …; a keep shows as the section switching to its own rental period.

Only draft and sent quotes are flagged (accepted and converted are locked; declined is over), only sections that follow the quote's dates (a custom-dated section prices on its own window), and only sections holding an equipment line (nothing else is priced by the dates). A section never stamped — appended from a schedule, or one the migration could not resolve a window for — is treated as in sync and starts being watched from its next save. Custom-dated quotes (no project) can't drift: their dates are edited in the builder itself, which reprices as you type, as before. One consequence of the stamp: a date edit on one section no longer reprices every other section from today's rate card — a section already priced for its window is left alone.

The helpers are pure and tested (components/domain-docs.js: LTP_staleRentalSections, LTP_stampRentalWindows, LTP_rentalDriftNotice; tests/test_rental_drift.js, tests/test_quote_priced_window_migration.py).

Marking a quote converted by hand

A quote normally becomes converted on its own: Send to Invoice flips it the moment every line has been invoiced. The job billed some other way — an invoice raised straight in QuickBooks, a quote from before the app, a client settled off-system — would otherwise sit in accepted forever, still flagged as owing an invoice. Mark Converted on a sent or accepted quote (desktop header; the ⋯ menu on a phone) is the override. A draft never went to the client, so it has nothing to convert; on a sent quote the override also stands in for the acceptance the client gave outside the app.

  • It asks for confirmation and spells out what it does: every line not yet invoiced in full is recorded as delivered and invoiced, so the document reads the way a real conversion leaves it. Lines a linked invoice already drew keep that invoice's draw; only the remainder is written off.
  • The activity log entry says marked converted manually and names the user, with the written-off quantities, so it can never be mistaken for a conversion that produced an invoice.
  • Gear the quote still held is released on save (a converted quote books nothing and no invoice is taking the bookings over — see Bookings below). Gear already checked out is not touched and keeps counting until it is marked returned. The confirmation says how many bookings are affected.
  • It is reversible: a quote converted by hand has no invoice to unpick, so Recall to Draft stays available on it and clears the quantities the override recorded. Once a real invoice is linked, recall is refused as for any converted quote.

The data change is pure and tested (components/domain-docs.js: LTP_markQuoteConverted; tests/test_quote_mark_converted.js). Only a sent or accepted, saved quote qualifies — the helper returns null for anything else.

Bookings (allocations)

An allocation is the record that some units of an item are booked to a project for a date range — it is what makes the checker read "3 of 10 available" and the item popup say "On Rental". Bookings are derived from confirmed documents (backend/rental_bookings.py):

  • An accepted quote books one allocation per equipment line for that line's rental dates (the section's own period when set, else the quote's project dates, else its custom dates). A draft or sent quote books nothing.
  • An invoice books its equipment lines in every status — an invoice only exists for confirmed work, and the draft created by converting a quote must keep the booking alive without a gap. A partially invoiced quote books the un-invoiced remainder while the invoice books what it drew, so nothing is counted twice.
  • Bookings are keyed by document and line, so editing the document updates the same row instead of stacking a second one, and the state someone set on it survives. A line that stops booking releases its row only while it is still reserved / allocated; gear that is physically checked-out keeps counting until it is returned by hand.
  • Converting a quote hands its bookings to the invoice. An invoice line drawn from a quote line adopts the quote's booking: the same row is re-keyed to the invoice with its state intact, so gear checked out against the quote stays checked out. A partial conversion splits the quantity and the invoice's part inherits the state. Either save order lands on the same rows — and the two saves normally run at once (the builder writes the converted quote and the new invoice in one tick), so every hand-over write is guarded by the row's current owner: the invoice re-keys a row only while the quote still holds it (otherwise it books the line itself), and the quote releases a row only while it is still the quote's.
  • Gear leaves and comes back from the quote. An accepted (or converted) quote's header shows Gear: 3 reserved · 2 out with Check Out Gear and Mark Returned — one click moves every booking behind the quote's equipment lines, including the ones its invoices now own (POST /api/quotes/{id}/gear). Per-item, the equipment popup's Bookings list has the same state on each row, and the Equipment List's Where column says which jobs an item is on.
  • The reconcile runs on every quote/invoice create, save and delete, on the client's public accept link, and once at boot as a backfill. An admin can rebuild every derived booking with POST /api/allocations/reconcile. Bookings with docType: "manual" (entered through the API) are never touched by the engine.

Double-bookings: same day vs. time conflict

A crew member on two shifts the same day is badged in the schedule editor, the builder's side panel and Labor → Assignments. The badge has two levels:

  • Yellow — same day, no overlap. The person is on another shift that day (another project, or a second role on this one) but the times don't overlap: a morning load-in here and an evening show there. The day can work.
  • Red — time conflict. The two shifts' times overlap, or one of them has no times set so the overlap can't be ruled out.

Hover the ! to see the other shift with its times. The assign-time dialog lists the same information per line ("— times overlap" / "— no overlap") before you choose Assign anyway. Nothing that was flagged before stops being flagged; the split only changes the colour. Rules that don't change: a confirmed position never wears the badge (it is settled on purpose), and the same role across several items on one day is one day booking, not a conflict. The logic is LTP_shiftTimesOverlap / LTP_conflictLevel in components/domain-crew.js: a wrap earlier than its call runs past midnight, 24:00 is end-of-day, and a multi-day row ends on its last day.

Flat-rate positions (fixed-cost hires)

Some people are hired for the whole production at a flat fee and never get contracted call times — a lighting designer, a stage manager. We hand them the schedule and they make their own hours. These are flat-rate positions: they live on the project (projects.fixed_positions), not on a schedule day, and they ride the same crew-request and payroll rails as a shift.

Where they are

  • Schedule Builder → Flat-rate positions (the panel above the day list). Each row is a rate-card role (required — it routes the fee to that role's QuickBooks expense account and names the quote line), the crew member (role-matched picker, same as a shift), the fee (what we pay), the bill (what the client is charged), a full-margin toggle, and a crew-facing scope note. Margin is bill − fee; the Schedule Summary folds the flat totals into Total Rate / Total Cost / Margin. The panel also says which payroll period and pay day the fee lands on (see the rules below).
  • Labor → Assignments lists them under a Flat-rate — whole project group with a Flat $… chip. Send Request / Book Without Emailing, Confirm, Release and Cancel all work as for a shift. A confirm locks the fee the way a shift locks its computed pay.
  • Labor → Payouts shows each confirmed flat-rate position as a row dated on the project's end date, so it sits in the payroll period that date falls into and is paid on that period's pay day (the following Friday, per the pay-period settings) together with every other payout in the period. It is an estimate until you Mark complete (✓ Complete, or Complete… to enter a different final amount); that freezes the figure exactly like a day sign-off, and the QuickBooks vendor-bill export posts it in that period. Adjustments, undo, lock/re-lock and the paid-day guard apply as they do to days.
  • Send to Quote / Invoice adds one Flat service line per billed flat-rate position (qty 1 at the bill amount, cost = the fee). A flat-rate position billed at $0 (absorbed in a package price) produces no line.

What the crew member sees

The request email and the crew page describe the flat-rate position, not calls: the role, the flat fee (stated on purpose — it is the offer being accepted), the project's date range, and a schedule outline listing each scheduled day and what it is, with no times. Hourly shifts on the same request still show no pay. A confirmed flat-rate position's Add to Calendar button creates one all-day event spanning the project dates.

Rules worth knowing

  • A flat-rate position is payable only after Mark complete; a confirmed-but- incomplete one is reported as pending (and excluded from the export).
  • There is deliberately no per-position pay date: the project's end date decides the payroll period. With no project end date the fee cannot land in any period — the builder flags the row until one is set.
  • A flat fee paid on the same date as a signed shift day for the same person and project is merged into one bill line in QuickBooks (the ledger is one line per person-project-date); the Payouts tab still shows them as two rows.
  • Changing the fee, role, status or full-margin flag on a flat-rate position that is already paid in QuickBooks trips the same paid-day guard as a shift edit (409 paid_day_conflict unless overridden).
  • Labor → Weekly Schedule shows a flat-rate position on every day of the project's date range (the project's own dates, else the span of its scheduled days), marked with a dashed edge and "Flat rate · whole project" in place of call times. The Calendar grid, which places rows on a single date, does not show them.

Crew portal

Crew members get their own sign-in at #/crew-portal — a dashboard that answers, from their phone, the three things they otherwise had to ask about: what am I on next, what's still waiting on my answer, and what am I owed.

It is deliberately a separate credential system from the staff app. Staff sign in with Google and carry the ltp_session cookie; crew sign in with an email + password and carry ltp_crew_session, which is checked only by the portal's own routes (backend/routes/crew_portal.py). A crew session reaches no /api/* staff route, and a staff session is not a crew session. Passwords are scrypt-hashed (backend/crew_auth.py, standard library — no new dependency); sessions and one-time links are stored as SHA-256 hashes, so a database read yields nothing usable.

What crew see

Tab What's on it
Overview Tiles (requests needing an answer · accepted-awaiting-confirmation · confirmed calls · next call), then this pay period beside their recent answers with where each stands (awaiting confirmation → confirmed, declined, released), then the requests waiting on them with Accept / I Can't Make It and an optional note right there (the same state machine as the emailed call sheet, which stays one tap away), and the next five calls last.
Schedule Three views of the same calls. List: every call from today forward, grouped by date, with role, call/wrap, project, venue, the shift note, a map link, Add to calendar on confirmed calls, and a status chip — Needs your answer, Awaiting confirmation, Confirmed; flat-rate positions list as one block with the fee and the schedule outline, and a Recently worked view shows the last few weeks' confirmed calls with a Signed off marker. Week: seven days across (stacked day rows on a phone), each call on its day with the status as a coloured edge. Month: a calendar with a line per call (status dots on a phone) and the tapped day's calls in full underneath; a flat-rate job is spread over its project's dates with a dashed edge.
Pay Paid this year, this period, pending, next pay day; then one card per pay period (the next, the current, and the last five): signed-off days with the frozen figure the QuickBooks bill posts (tier, hours, adjustments itemized), confirmed-but-unsigned days as ~estimates from the pay locked at confirm, the period's pay day, and whether its bill is Not yet submitted / Submitted / Paid (from payout_bills). Never another crew member's day, never the client's rate.
Account Sign-in email, the roster email requests go to (when different), roles and departments, a phone they keep current themselves (it lands on their roster row), change email (they enter the new address and their current password; a confirmation link goes to the new address, and only when it is opened do the sign-in email and the roster address switch, so a typo can't lock anyone out; a pending change shows on the tab and can be cancelled), change password (signs out other devices), sign out.

The dashboard re-checks itself every ten seconds (the same freshness poll as the call sheet) and on every return to the app, and adopts changes silently — a producer moving a call, confirming them, or signing off a day shows up without a reload. Crew-facing copy says production manager (never "producer") and carries no em dashes; the stat tiles share one grid with the figures on a common baseline; and the page runs up to 1180px wide on a desktop so the two-column sections hold more before anything scrolls.

Getting crew in

Access is by invitation, and the roster is the onboarding step:

  • Labor → Crew Roster → + Add Crew with an email address emails a crew portal invitation as soon as the row saves (a checkbox on the form, on by default). Existing crew get a Crew Portal block on their edit form — Send invitation, Resend invitation, Send password reset, and for admins Turn off / on access — plus a Portal column on the list (— / Invited / Active / Off).
  • An invitation link (#/crew-portal/signup/<token>) is single-use and good for 7 days; accepting it means choosing a password (8+ characters). Resending retires the earlier link. Invitations go to the email on file; an unsaved email edit disables the button rather than sending to the old address. If the email can't go out (the sender's Google isn't connected, say), the roster shows the link once so the producer can text it — that link is the credential, so it is never returned otherwise.
  • Forgot your password? on the sign-in screen emails a reset link (#/crew-portal/reset/<token>, good for 1 hour); finishing a reset signs out every other device. Request access does the right thing for the address given: an invitation for a roster email with no account, a reset for one that has one, nothing otherwise — and always answers the same way, so an address can't be tested for existence. Both are throttled to one email per crew member every two minutes.
  • Turning access off (admin) signs the crew member out everywhere, retires their open links and blocks sign-in; the password survives a later Turn on. Setting a crew member inactive on the roster also blocks sign-in. Deleting the contact deletes the account.

Sign-in is rate-limited per IP (30 attempts a minute on the sign-in surface) and per account (10 wrong passwords locks the account for 15 minutes), and a wrong email and a wrong password answer identically. Invitations and resets a producer sends go out as that producer through the Gmail pipeline; the self-serve ones go out as LTP_CREW_PORTAL_SENDER_EMAIL (else the most recently signed-in admin with Gmail connected), as does the email-change confirmation. The three templates — Crew Portal Invitation, Crew Portal Password Reset and Crew Portal Email Change — are editable under Settings → Email Templates.

Its own domain

The portal can live at crew.luminarytechnology.productions — a second custom domain on the same Railway service, switched per request on the Host header. Set LTP_CREW_PORTAL_ORIGIN and every crew-facing link is built on it; a bare visit to that host lands on the portal instead of the staff sign-in, and the PWA installs there as LTP Crew. Step by step, with verification and rollback: docs/CREW_DOMAIN.md.

Routes

POST /api/crew-portal/auth/login · logout · GET …/auth/me the crew session
POST /api/crew-portal/auth/forgot · request-access self-serve links (always {ok: true})
GET /api/crew-portal/auth/token/{token} who a link is for, and whether it is still live
POST /api/crew-portal/auth/signup · reset · change-password set / change the password
GET /api/crew-portal/me · PUT …/me their profile; the phone is the one field a PUT changes
POST /api/crew-portal/me/email · …/me/email/cancel · POST …/auth/confirm-email change the sign-in email: password-gated ask, a 24-hour link to the new address, the switch on confirmation
GET /api/crew-portal/dashboard?today= · …/dashboard/version the dashboard payload and its freshness stamp
POST /api/crew-portal/requests/{id}/respond answer one of their own requests
GET /api/crew-portal/accounts · POST …/{contactId}/invite · reset · disable · enable staff side (session-gated; disable/enable admin-only)

Tables: crew_accounts, crew_sessions, crew_auth_tokens (migration e0f1a2b3c4d5). Tests: tests/test_crew_portal.py (backend, end to end) and tests/test_crew_portal_routes.js (routing + wiring).

Role order (dropdowns and exported lines)

Every role/position dropdown in the app — the schedule editor and flat-rate panel pickers, the manual-shift and client-rate pickers, the quote/invoice item pickers, the roster's role tags — lists roles alphabetically with numbers compared as numbers: L1, L2, L10, LD, PM, SM, SPOT (window.LTP_sortServices / LTP_compareRoleCodes in components/domain-rates.js).

Lines that Send to Quote / Send to Invoice generate from a schedule (window.LTP_scheduleLaborSections) are ordered the way the document is read: letters-only positions first (LD, PM, SM, SPOT), then letter+number positions (L1, L2, L3 …), alphabetical within each group, and every position's lines together from the largest unit down — flat (whole project), day, half day, then hours (hourly, overtime). Splitting by department keeps that order inside each section (LTP_compareLaborLines in components/domain-crew.js).

Phone layouts

Schedule builder on a phone

At (max-width: 600px) (window.LTP_useIsMobile) the builder switches to a layout designed around 16px inputs — index.html forces that size on every field so iOS never zooms on focus, so the phone layout is built for it rather than shrunk from the desktop ledger. Desktop is untouched.

  • Header — one row: ←, the project name over "Schedule · dates · client", Save (only while there are unsaved edits) and a ⋯ menu holding Send to Quote, Send to Invoice, Print and Discard.
  • Summary strip — Days / Filled / Rate / Margin as four tiles at the top of the column, in place of the side panel's Schedule Summary (which would sit below the whole editor on a phone). Notes and Activity still stack under the editor as cards.
  • Date and time pickers — a formatted chip ("Thu, Sep 10", "8:00 AM | 6:00 PM") with the real LTPDateField / LTPTimeField stretched over it at opacity 0 (chip + NATIVE in components/schedule-editor.js). Tapping opens the native wheel picker; the deferred-commit rules the fields carry are unchanged because the same components render. On a narrow desktop window, where a tap doesn't open a picker by itself, the chip calls showPicker().
  • Rows — role and crew share one line, with a slim footer (status · MGN · breaks … rate / cost · ⇩ · ×). Flat-rate positions read role · crew / FEE $ · BILL $ · MGN / note / status … bill · margin · ×.

Short dates come from window.LTP_formatDateShort / LTP_formatDateRangeShort (components/domain-util.js), which build from the ISO parts so a bare YYYY-MM-DD stays the calendar day it names in every timezone.

The phone kit (components/ui.js)

The builder's pieces are shared so every phone screen uses the same 36px controls (window.LTP_CTL):

  • LTP_pickerChip(opts, field) / LTP_pickerSeg(a, b) — a formatted chip with the real date/time field (styled LTP_NATIVE) stretched over it; prefix adds a small label ("FROM · Mon, Sep 7").
  • LTP_inlineField(label, input, opts) — "FEE $ [1500]" in one box, the input styled LTP_INLINE_INPUT.
  • LTPStatStrip({ items }) — N tiles in one bordered row, the phone stand-in for a row of StatCards.

Where they are used, besides the schedule builder: the quote and invoice builders (line items read badge · name · ▲▼ · ×, then [rate] [QTY] [ADJ $] on one row; the add-item picker puts the name on its own line; Discard sits in the ⋯ menu), Labor → Assignments (stat strip, chip strip, project · Manual Shift row), Labor → Payouts (one-row period navigator, From/To chips, stat strip), Labor → Crew Roster (search row + chip strip) and Weekly Schedule (short week label). Desktop rendering is untouched everywhere — the phone styles are merged in only under LTP_useIsMobile().

Send modals on a phone

The email send modals — Send / Resend Quote, Send / Resend Invoice, Send Payment Receipt and Send Crew Requests — are laid out for the full-screen sheet a phone turns every LTPModal into. On desktop they are unchanged: the document preview (or the recipient list) sits in a column beside the email, and the action row is under the whole thing.

  • The preview folds. The 260px preview column left the email about fifty pixels wide on a phone, so there it collapses into one line above the compose pane — reference · name and the amount — that opens on a tap to show the breakdown. The email, the thing being written, gets the whole width (window.LTPSendModalBody in components/doc-email-pane.js, shared by the three document modals).
  • The action row is pinned. LTPModal takes a footer; on a phone the sheet becomes a column, the body scrolls in the middle and the footer sits above the home indicator, so Send is reachable without scrolling past the email. The primary button fills the row at 44px (window.LTP_SHEET_BTN). Desktop draws the same footer under the children behind the usual hairline. Modals that pass no footer are laid out exactly as before.
  • Nothing is cut short. Recipient chips take a full line each with the name over the address, the subject is a textarea that grows to show all of itself (Enter is swallowed, so it stays one line), and the folded preview puts the name and company on their own line under the reference.
  • The email reads at 13px. iOS zooms the page on focusing any editable element under 16px, contenteditable included, and the 16px rule in index.html only reaches input/select/textarea — but it decides from the focused element's own size, not its children's. So the editor's root stays 16px while its paragraphs render at 13px (the .ltp-email-editor > p rule in the phone CSS layer), and the auto-managed header card and signature block show at 80%. Display only: both blocks are swapped back to their placeholders whenever the body is read out of the editor. A phone sheet also gets 16px side gutters instead of the floating card's 24px.
  • Finger-sized controls. The → To / ✕ on a chip, the add field, the subject and the crew-request checkboxes all grow to the phone kit's sizes.
  • Crew requests stack — recipients above the request summary — and the footer reads count / Cancel · Book Without Emailing / a full-width Send.
  • The crew notify tray (components/crew-outbox.js) sits above the bottom tab bar on a phone, clear of the list FAB, and below the modal layer so a sheet's pinned footer is never covered by it.

Email feature deploy notes

The Gmail-send feature ships in stages. Before the first deploy that includes it:

  1. In Google Cloud Console → APIs & Services → OAuth consent screen → Audience, confirm User Type is Internal. (Required for the sensitive gmail.send scope to skip Google's CASA verification.)
  2. On the same consent screen, add scope https://www.googleapis.com/auth/gmail.send under Scopes.
  3. In APIs & Services → Library, enable the Gmail API.
  4. Set LTP_TOKEN_ENCRYPTION_KEY in Railway Variables (see Environment variables table for the generation command).

After the first post-deploy sign-in, every existing user must sign out and back in once — the OAuth login flow now passes prompt=consent to force Google to issue a refresh token, which requires re-consent. Users who don't re-consent can use the app normally but can't send email until they do. The frontend shows a "Reconnect Google" banner when this is the case.

Until a user re-consents, the Send button in every quote, invoice, and receipt modal is disabled (greyed out, not clickable). The banner in the modal explains the fix. Once they sign out and back in, the gmail.send scope is granted and the Send button works again. Admins can spot users in this state from the Team Members section of Settings — a "Gmail not connected" warning shows below their email.

The signature template ships with a {{userPhoto}} placeholder that resolves to each sender's Google profile picture. Users who somehow lack a profile picture (rare for Google OAuth) get the LTP logo as a fallback. The Team Members section in Settings shows each user's photo as a small circular avatar so admins can verify what each team member's signature will display.

Profile photos are cached in the app, not hotlinked from Google. Google's lh*.googleusercontent.com URLs rot and rate-limit when embedded elsewhere, which made avatars silently stop rendering. On sign-in the app downloads the photo bytes once and serves its own stable copy from GET /api/users/photo/{token} (public — the same URL is embedded in outbound email signatures, so recipients' mail clients must be able to load it). Existing users' photos are cached automatically on their next sign-in. If a photo is stale, an admin can click Re-pull photo on that person's row in Team Members: the app first tries to re-download it immediately from the stored Google URL (which usually works server-side even when the browser/email can't load it), and if that URL is itself expired it queues the re-pull for that user's next sign-in, where a fresh login provides a working URL. Team Members avatars fall back to an initial-letter placeholder whenever the image can't load, so a broken photo never shows a broken-image icon.

Deliverability (landing in spam?)

Mail is sent through the Gmail API as the signed-in user, so inbox-vs-spam is decided by your domain's email authentication (SPF/DKIM/DMARC) and sender reputation — not by app code. As of the last check the domain's SPF/DKIM/DMARC all pass and align, so if mail still lands in some recipients' spam folders the lever is sender reputation (Google Postmaster Tools, Microsoft SNDS/JMRP, warm-up, "Not spam" engagement). See docs/EMAIL_DELIVERABILITY.md for the diagnosis, what was fixed, and how to verify with Show original / mail-tester.

QuickBooks Online integration

The app pushes generated invoices to a QuickBooks Online company so the books stay in sync. It's a company-wide connection (one QB company / realm), managed by an admin, with all tokens stored encrypted server-side. Mirrors the Gmail integration's security posture.

One-time setup

  1. Create an app at Intuit Developer → My Apps → Create an app → QuickBooks Online and Payments. It needs the single scope com.intuit.quickbooks.accounting (no payroll, no payments).
  2. On the app's Keys & credentials page — note there's a separate set for Development (sandbox) and Production — copy the Client ID + Secret and add a Redirect URI of https://<your-app>/api/qbo/callback.
  3. Set the env vars (see the table above): QBO_CLIENT_ID, QBO_CLIENT_SECRET, QBO_REDIRECT_URI, and QBO_ENVIRONMENT (sandbox to start). LTP_TOKEN_ENCRYPTION_KEY must also be set — it encrypts the QB tokens at rest (same key as Gmail).
  4. Redeploy, then go to Settings → QuickBooks Online → Connect and authorize. An admin does this once; the connection is shared by the whole workspace.

The redirect_uri the app sends and the one registered on the Intuit app must match exactly, and the Development keys pair with sandbox while Production keys pair with production. (If you authorize through Intuit's OAuth 2.0 Playground instead of the app's Connect button, you'll land back on the Playground — always use the app's button.)

How invoices sync

  • Export is tied to sending, and comes first. An invoice reaches QuickBooks when it is sent: sending it — the first time and every resend — auto-exports it, and there's no separate manual push in the normal flow. The export runs before the email, for every customer (not only taxable ones, whose sales tax QuickBooks has to compute first). If QuickBooks is slow, down or refuses the invoice, nothing is sent: the invoice stays a draft, the send button explains why, and the failure is on the invoice's activity and under Settings → Error Log. The email is the last step, so a customer can never be holding an invoice the books don't have. (Send modals used to export tax-exempt invoices after the email — "sent, but held up at QuickBooks".) If the email then fails, the QuickBooks invoice the send just created is deleted again (unwind-send).
  • The send marks the document sent. POST /api/email/send moves a draft to sent — status, sent date, a quote's frozen expiry, the remembered recipients — in the same transaction as the email, and hands the row back. This used to be a separate save the browser made afterwards; a tab closed on a phone, a lost connection or a refused stale write left the email out and the document a draft. That state can no longer exist. A window still holding the pre-send draft is now refused as stale (409, with the sent row to adopt) rather than allowed to put the document back to draft.
  • Out-of-sync detection. Once an invoice is in QB, the QuickBooks card on the invoice (above the summary) shows its status and a View in QuickBooks ↗ deep link. It surfaces an Update QuickBooks button only when something QB-relevant has changed since the last push — line items, dates, discount, taxability, the customer's info, or the project name.
  • Recall. Recalling a sent invoice immediately pushes a prominent "RECALLED — MAY NOT BE UP TO DATE" notice (a banner as the first line on the QB invoice, plus a private note); re-sending the corrected invoice clears it.
  • Delete. Deleting an invoice deletes its QuickBooks counterpart too (the confirmation dialog says so when it's synced). The local invoice is removed only if the QB delete succeeds, so it's never orphaned.
  • Customers & items are created in QuickBooks automatically the first time they're referenced (matched by name, then cached by id) — no pre-creation needed. Equipment lines map to one generic "Equipment Rental" item; each product/service maps to its own item. A product's pricing variants (multiple pricing structures for the same product, e.g. Transportation: flat local delivery vs. per-mile — set up in Quotes → Products) all share that product's single QB item: the variant label rides in the line description and QB takes each line's own amount.

Income accounts

Which QuickBooks income account each item posts to is mapped in Settings → QuickBooks Online → Income Accounts: one account each for Services (labor), Products, and Equipment Rentals, plus a default for anything unmapped. Individual services and products can override the mapping in their edit forms (Quotes → Services / Products). The dropdowns are fed by a cached account list — click Update Account List after adding accounts in QuickBooks; nothing refreshes in the background.

Changing a mapping (or an override) re-points the backing QB item on the next push that references it, and the invoice's activity log records the change. Re-pointing only affects future postings — QuickBooks does not reclassify existing transactions — but note that re-pushing an old invoice re-posts its lines at the item's current account, which shifts that invoice's P&L to the new account as of its transaction date.

Error Log (Settings)

Settings → Error Log lists email delivery faults and QuickBooks faults gathered from the documents' own activity logs, so they survive the toast that first reported them. Every failure path records itself there:

  • a Gmail failure stamps email_failed on the quote or invoice (the route returns the error as JSON rather than raising it, which used to roll the stamp back with the transaction, so the log stayed empty);
  • every kind of QuickBooks push failure — a fault, an unreachable Intuit, a connection that needs reconnecting, an invoice that cannot be synced — stamps qbo_sync_failed on the invoice and sets its sync status to error, with the reason on the invoice card and an Update QuickBooks retry;
  • every kind of quote tax-calculation failure stamps qbo_sync_failed on the quote (previously nothing was recorded for quotes at all).

Sales tax

QuickBooks is authoritative for sales tax: it computes the tax from the customer's billing address (Automated Sales Tax), and the app stores the result and folds it into the invoice total everywhere (builder, list, client view, PDF).

  • Company customers carry an optional Taxable flag (most clients are exempt). Contacts billed directly are always taxable. Per-line overrides are available on taxable invoices.
  • For QB to find the right jurisdiction the customer needs a structured billing address — set City / State / ZIP on the company (or contact) in CRM. These sync to the QB customer on every push, so moving a client into a taxable area and re-pushing recomputes the tax.

Quotes use the same taxability model (customer flag + per-line overrides), but since we don't keep QB estimates, tax is obtained on demand: the Calculate Sales Tax button in the quote builder — and the moment a quote is sent — creates a temporary QB Estimate, reads its computed tax, and deletes the estimate (so none accumulate). The result is stored on the quote (qbTaxTotal) and shown in the builder, client view, and PDF. Editing tax-relevant fields afterward flips the button to Recalculate — out of date. Tax-exempt quotes skip QuickBooks entirely ($0). Calculating tax may create the QB customer (reused when the quote later converts to an invoice); only the estimate is removed.

Sandbox → production

Start in sandbox against an Intuit test company. To go live: switch QBO_CLIENT_ID / QBO_CLIENT_SECRET to the app's Production keys, set QBO_ENVIRONMENT=production, make sure the production redirect URI is registered, redeploy, and reconnect from Settings. (Your Intuit app must pass Intuit's production review before the production keys work.)

Security

Access + refresh tokens are stored as Fernet ciphertext in the qbo_connection table and never exposed to the browser; the client secret stays in env vars; every Intuit call is server-side; connect / disconnect / push / delete are admin-only; and GET /api/qbo/status returns booleans + a masked realm id only.

Dev app identity

When you install both the production and a dev/staging deployment to the same home screen, they otherwise look identical. Setting LTP_APP_VARIANT=dev on the dev deployment's Variables gives it a distinct identity so you never confuse them:

  • the "DEV"-tagged icon (assets/icons/dev/) in place of the production icon, and
  • the app name "LTP Dev" — in the manifest, the iOS home-screen label (apple-mobile-web-app-title), and the browser tab title. Production shows "LTP".

The switch is environment-driven, not branch-driven: the dev icons and the serving code live on every branch, but only activate where the variable is set. Production, with the variable unset, serves its normal icons and name byte-for-byte — so the dev identity can never leak into prod through a merge. To turn it on, add LTP_APP_VARIANT=dev to the dev deployment's Railway Variables and redeploy; reinstall the PWA (remove from the home screen and re-add) to pick up the new icon and name.

Icon source & regeneration

The finished icon art lives in assets/icons/src/ — app-icon-prod.png and app-icon-dev.png (square masters). From each, the full icon set (192/512 "any" + maskable, apple-touch, favicon) is generated with:

python assets/icons/generate_from_src.py

To change an icon, drop a new square master (ideally 512×512+ for crisp large icons) into assets/icons/src/ and re-run that script. generate_icons.py remains as the original tool that builds the production set from the brand lockup (assets/logos/primary.png).

Push notifications

Admins can get Web Push notifications on an installed home-screen PWA (iPhone/iPad on iOS 16.4+, plus Android and desktop Chrome) for the events that otherwise require re-opening the app to notice:

  • a crew member accepts or declines a request → the producer who sent it
  • a client accepts or declines a quote → whoever sent the quote
  • an invoice is marked paid in QuickBooks → whoever sent the invoice
  • a client opens a quote or invoice → whoever sent it

Quote/invoice notifications name the job — the document's custom name if set, otherwise its project name (e.g. "Chris accepted Summer Gala").

For the three terminal events (crew response, quote accept/decline, invoice paid), if the entity has no recorded sender — e.g. a quote shared via an anonymous link — the notification broadcasts to all admins instead.

Opened notifications are the exception: they go to the sender only (never broadcast to admins), and they're debounced to ~once per 24h per viewer and suppressed for internal previews and link-scanner bots — so opening a doc several times, or an LTP user previewing it, won't ping you.

Push is optional: with the VAPID vars unset the app boots normally and sends become no-ops — the Settings toggle just reports "not set up". Same graceful-degrade posture as the Gmail path.

Generate VAPID keys

A VAPID keypair identifies your server to the browser push services. Generate one once and keep it — rotating it invalidates every existing subscription (users would have to toggle notifications off and back on).

Using the project's Python venv (nothing extra to install):

.venv/bin/python - <<'PY'
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
b64url = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
priv = ec.generate_private_key(ec.SECP256R1())
prv = b64url(priv.private_numbers().private_value.to_bytes(32, "big"))
pub = b64url(priv.public_key().public_bytes(
    serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint))
print("LTP_VAPID_PUBLIC_KEY=" + pub)
print("LTP_VAPID_PRIVATE_KEY=" + prv)
PY

Or, if you have Node, npx web-push generate-vapid-keys prints an equivalent Public/Private pair (its Public Key → LTP_VAPID_PUBLIC_KEY, Private Key → LTP_VAPID_PRIVATE_KEY).

Setup

  1. Set LTP_VAPID_PUBLIC_KEY, LTP_VAPID_PRIVATE_KEY, and LTP_VAPID_SUBJECT (mailto:you@yourdomain.com) in Railway → Variables, then redeploy.
  2. On the device, open the app in Safari → Share → Add to Home Screen, and launch it from the new icon. (iOS only delivers Web Push to installed PWAs — a normal Safari tab can't subscribe.)
  3. In the app, go to Settings → Notifications → Turn On and allow the permission prompt. Notifications are per-device — repeat on each phone or tablet you want alerted.

Security & notes

  • The private key is a signing secret — treat it like a password; only the public key ever reaches the browser.
  • Keep the keypair stable. If you must rotate it, every device has to re-subscribe (toggle notifications off then on) because existing subscriptions are bound to the old public key.
  • Each subscription is tied to the signed-in user and stored in the push_subscriptions table (migration runs automatically on deploy). Dead subscriptions are pruned automatically when a push service reports them gone (HTTP 404/410).

API Endpoints

All entities follow REST conventions:

Method Path Description
GET /api/{entity} List all
GET /api/{entity}/{id} Get one
POST /api/{entity} Create
PUT /api/{entity}/{id} Update
DELETE /api/{entity}/{id} Delete

Entities: companies, contacts, projects, quotes, invoices, equipment, products, services, fees, client-rates, allocations, containers, kits, vendor-rates, cross-rentals

PUT accepts an optional If-Match header carrying the row's _rev (every row the API returns includes one). If the row changed since you read it, the PUT is refused with 409 and the response body carries the current row so the client can adopt it — unless the write would change nothing, which passes as the no-op it is (a stale token with nothing to write is not a conflict). Omitting the header keeps last-write-wins. See Live sync below.

Special endpoints:

  • GET/PUT /api/settings — App settings (singleton)
  • GET /api/versions — Per-collection change stamps (live sync)
  • POST /api/quotes/{id}/gear — Check out ({"state":"checked-out"}) or return ("returned") every booking behind a quote's gear
  • POST /api/allocations/reconcile — Rebuild every document-derived booking (admin)
  • GET /api/stream — Server-sent change feed (live sync)

Live sync (cross-window)

Every open window stays current without polling the data itself, and without a hard refresh. The design is deliberately narrow: the server pushes stamps, not rows.

GET /api/versions returns one stamp per collection, derived from max(updated_at) + count(*) + an in-process sequence number (backend/livesync.py). GET /api/stream pushes the same map over SSE whenever anything changes. A client diffs the map against its own and refetches only the collections that moved — so the cost of being live does not grow with the size of the workspace.

Three channels feed one code path (components/live-sync.js), so they can overlap freely; whichever notices first wins and the rest see no stamp change:

Channel Reach Idle cost
BroadcastChannel same-browser tabs zero — never touches the network
SSE /api/stream everywhere one : keepalive per 25s (~KB/hour)
Poll /api/versions fallback when SSE won't stay up ~400 bytes/tick, foreground tabs only

Plus a one-shot revalidation on visibilitychange, focus and bfcache restore — the moments a window is most likely to be wrong.

Why this mattered. Crew accept/decline is a server-side write: it moves position statuses on the project row (backend/routes/crew.py::_respond). No open window could learn about it, so the Crew Requests tab would show the shift name as the literal string "Project" and hide the Confirm button, and the stale window would revert the acceptance on its next save. If-Match closes that last hole; live sync makes it rare in the first place.

Server-side moves the window itself asked for. Sending or withdrawing a crew request also moves position statuses on the project row, and the Labor tab shows that move at once. It must not push it back: a copy of the move written under the pre-send token is a stale write in name only, and refusing it told the producer the project had "changed in another window" for a change they had just made. So the tab installs the move as server state (LTP_STATE.adoptRow, which moves the sync baseline with the row and queues no PUT), and the send/withdraw responses carry the moved project row with its _rev so the window adopts the real thing — and a direct book's pay stamp, written right after, goes out under a token the server accepts.

A stream is recycled every 30 minutes (LTP_LIVESYNC_MAX_STREAM_SECONDS). Auth is checked when a connection opens and never again, so without a cap a stream opened by a session that later expired would keep receiving for as long as the tab lived. The server ends a recycled stream with a bye frame so the client reconnects immediately rather than treating it as a fault. The safety sweep also idles completely when nobody is connected — this app sits unused most nights, and there is nothing to broadcast to.

Editing a record two people have open

Every editor in the app is covered, in one of two ways. Which one depends on how the editor holds its state — not on how important the record is.

Draft editors (schedule builder, quote builder, invoice builder, Settings) clone the record into a single draft and own a dirty flag, so they use theme.js::LTP_useRemoteEdits: nothing unsaved → adopt the newer version silently; unsaved edits → keep them and warn once per editing session.

Field-per-useState modals (companies, contacts, projects, services, products, fees, client rates, equipment, containers, kits, crew roster, manual shifts, notes) seed one piece of state per field when they open. Re-seeding a dozen setters underneath someone mid-edit is not something to do quietly, so they use theme.js::LTP_useRecordWatch and only warn. It finds the row itself in window.LTP_DATA_LIVE (a render-time mirror kept by data-state.js), so a form needs one line and no new props threaded down from its parent. An optional pick narrows what counts as a change — the note editor watches its own note, not the whole project row it lives in.

Why a warning and not If-Match. The revision guard does not catch this on its own: live sync refreshes a row's _rev underneath a form that is still holding values from before it, so the write looks perfectly current to the server. If-Match covers the window where a client has NOT refetched; the editor notice covers the window where it has. Discarding someone's typing to win a race is the wrong trade at this size, so in both cases the local edit survives and the person is told.

What does NOT count as "elsewhere". The notice promises that saving will replace a newer version, so it only fires when that is true:

  • A row this window asked the server to change — a send, a QuickBooks push, a crew request — comes back through LTP_STATE.adoptRow, which installs it without moving the remote epoch the hooks read. Otherwise pressing Send told the sender that another window had changed their invoice.
  • An append-only log moving (activity, scheduleActivity). The update path unions those by entry id, so a save cannot lose them. Editors declare theirs as the hook's quietKeys; the entries are still adopted by a clean editor, they just never warn a dirty one. Otherwise every share-link open, PDF download and sync failure interrupted whoever was mid-edit.

Adding a new editor? Call one of the two hooks. A form that stays mounted after saving needs the dirty-flag variant, or it will warn about its own write, and pass quietKeys for any append-only log in its snapshot.

Not every read is in the feed. /api/qbo/status, /api/users and /api/qbo/payouts/day-status are backed by tables outside livesync.COLLECTIONS and still load once per mount. The first two change only when an admin acts.

day-status used to be the one to watch — it drives the paid-day warn+confirm, and a stale copy meant the guard silently did not fire. That is now enforced server-side instead of relying on the client being fresh; see below.

Paid days

Once a crew day is billed to QuickBooks AND that bill is paid, the money is gone. Editing the schedule underneath it claws nothing back — it just makes the app disagree with the accounts.

PUT /api/projects/{id} refuses such a write with 409 paid_day_conflict, listing who and which dates, unless the request carries X-LTP-Paid-Day-Override: 1. The comparison (backend/payouts.py::paid_day_signature) fingerprints everything that decides what a crew member is owed for a day — shift times, crew-wide and per-position breaks, service, role, status, and the frozen work/adj snapshot — so an unrelated edit elsewhere in the project passes straight through.

Two ordering details worth keeping:

  • The check runs on the FINAL schedule, after crew_integrity's status-floor and pay-snapshot guards have had their say. On the raw incoming schedule it would refuse writes those guards had already reverted to a no-op.
  • It mirrors modules/schedule-builder.js::_paidSig, and is deliberately stricter (it also covers work/adj, which the pay-snapshot guard only restrains for non-admins). Keep the two in step: a field one side fingerprints and the other does not is a paid day that changes without anyone being asked.

The client still warns first, from day-status, so the common case never round- trips a refusal. When that map is stale the server refuses, the editor prompts from the server's answer, and confirming re-sends with the override — so the client being out of date is now harmless rather than silent.

Notes for future changes:

  • The broadcast bus is in-process. That is correct for one uvicorn worker on one pod (what railway.json starts). More than one and a write served by worker A never reaches a window subscribed to worker B — swap _broadcast() for a real bus. The 30s safety sweep limits the damage but degrades push to slow polling.
  • Only mark a collection dirty when you actually wrote to it. A read path that marks itself dirty is a refetch loop: every window refetches, and each refetch republishes. See livesync.mark_dirty.
  • Responses are gzipped (GZipMiddleware). SSE is excluded by Starlette, which is what keeps the feed from being buffered.

QuickBooks Online (admin-only except status):

  • GET /api/qbo/status — connection status (booleans + masked realm; any signed-in user)
  • GET /api/qbo/connect · GET /api/qbo/callback · POST /api/qbo/disconnect — manage the connection
  • POST /api/qbo/accounts/refresh — re-fetch the income account list (cached; button-driven, no background sync)
  • POST /api/qbo/invoices/{id}/push — export/update an invoice in QuickBooks
  • POST /api/qbo/invoices/{id}/delete — delete an invoice's QuickBooks counterpart

Migrating Data from localStorage

Done, and the endpoint is gone. POST /api/sync was a one-shot importer that seeded the server from a browser's localStorage; it wiped all twelve entity tables and re-inserted whatever the request body held.

It was removed once the migration was complete, because leaving it in place meant a live wipe-everything endpoint with no caller. Following the snippet that used to live here would not have re-imported anything today — the app has been pure API-backed with no localStorage since (see the header comment in components/data-state.js), so the body would have been empty objects and the call would simply have emptied the database. It also cascade-deleted client_rates without restoring them, since that entity was never in its model map.

If a bulk import is ever needed again, recover the handler from git history (git log -S bulk_sync -- backend/routes/api.py) rather than rewriting it, and gate it behind an env flag that is unset in production.

Project Structure

ltp-app/
├── backend/
│   ├── main.py            # FastAPI app entry point
│   ├── database.py        # Async SQLAlchemy setup
│   ├── models.py          # Database models
│   ├── quickbooks.py      # QuickBooks Online REST client (OAuth + transport)
│   ├── qbo_sync.py        # QuickBooks invoice sync engine (customers, items, tax)
│   ├── pdf_generator.py   # Quote/invoice PDF rendering
│   ├── livesync.py        # Cross-window change stamps + SSE broadcast bus
│   ├── rental_bookings.py # Bookings (allocations) derived from accepted quotes + invoices
│   └── routes/
│       ├── api.py         # REST API routes
│       └── qbo.py         # QuickBooks connect/callback/status/push/delete
├── components/            # Frontend: shared React components
│   ├── live-sync.js       # Change feed: SSE + poll fallback + BroadcastChannel
│   ├── data-state.js      # Persisted-state hooks (refetch on change, If-Match)
│   └── client-rates.js    # Per-client rate editor + the CLIENT RATE chip
├── modules/               # Frontend: page modules
├── data/                  # Frontend: default/seed data
├── index.html             # Frontend: entry point
├── app.js                 # Frontend: root React component
├── theme.js               # Frontend: theme + global utilities
├── router.js              # Frontend: hash router
├── build.sh               # Copies frontend → frontend/ for serving
├── requirements.txt       # Python dependencies
├── railway.json           # Railway deployment config
├── nixpacks.toml          # Build configuration
└── README.md

About

Business and project management web app for Luminary Technology & Productions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages