Business management web app for Luminary Technology & Productions.
- Frontend: Vanilla JS + React 18 via CDN (no build step)
- Backend: Python FastAPI + SQLAlchemy (async)
- Database: PostgreSQL (Railway managed)
- Deployment: Railway
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- Go to railway.com and create a new project
- Click "Deploy from GitHub repo" and select your repo
- Click "+ New" → "Database" → "PostgreSQL"
- Railway auto-provisions the database and sets
DATABASE_URL
In your FastAPI service's Variables tab, add a reference:
DATABASE_URL→ click "Add Reference" → select the PostgreSQL service'sDATABASE_URL
Railway will inject the connection string automatically.
Railway will:
- Detect
requirements.txtand install Python dependencies - Run
build.shto copy frontend files tofrontend/ - Start the server with
uvicorn backend.main:app
The app will be live at your Railway domain.
# Install dependencies
pip install -r requirements.txt
# Build frontend
bash build.sh
# Run server (uses SQLite locally)
uvicorn backend.main:app --reload --port 8000Schema 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.
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:
- Open the project → click the Postgres service
- Settings → scroll to Backups
- Confirm backups are Enabled and note the retention window
- (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.
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. |
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.
| 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.
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.
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.
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.
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.
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.
- 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.
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.
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 RATEchip 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.
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.
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.
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.
| 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. |
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.
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.
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.
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.
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).
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.
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 physicallychecked-outkeeps 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 withdocType: "manual"(entered through the API) are never touched by the engine.
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.
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.
- 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, orComplete…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.
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.
- 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_conflictunless 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 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.
| 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.
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.
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.
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).
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).
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/LTPTimeFieldstretched over it at opacity 0 (chip+NATIVEincomponents/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 callsshowPicker(). - 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 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 (styledLTP_NATIVE) stretched over it;prefixadds a small label ("FROM · Mon, Sep 7").LTP_inlineField(label, input, opts)— "FEE $ [1500]" in one box, the input styledLTP_INLINE_INPUT.LTPStatStrip({ items })— N tiles in one bordered row, the phone stand-in for a row ofStatCards.
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().
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.LTPSendModalBodyincomponents/doc-email-pane.js, shared by the three document modals). - The action row is pinned.
LTPModaltakes afooter; 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,
contenteditableincluded, and the 16px rule in index.html only reachesinput/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 > prule 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.
The Gmail-send feature ships in stages. Before the first deploy that includes it:
- In Google Cloud Console → APIs & Services → OAuth consent screen →
Audience, confirm User Type is Internal. (Required for the
sensitive
gmail.sendscope to skip Google's CASA verification.) - On the same consent screen, add scope
https://www.googleapis.com/auth/gmail.sendunder Scopes. - In APIs & Services → Library, enable the Gmail API.
- Set
LTP_TOKEN_ENCRYPTION_KEYin 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.
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.
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.
- 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). - 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. - Set the env vars (see the table above):
QBO_CLIENT_ID,QBO_CLIENT_SECRET,QBO_REDIRECT_URI, andQBO_ENVIRONMENT(sandboxto start).LTP_TOKEN_ENCRYPTION_KEYmust also be set — it encrypts the QB tokens at rest (same key as Gmail). - 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.)
- 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/sendmoves a draft tosent— 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.
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.
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_failedon 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_failedon 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_failedon the quote (previously nothing was recorded for quotes at all).
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.
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.)
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.
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.
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.pyTo 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).
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.
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)
PYOr, 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).
- Set
LTP_VAPID_PUBLIC_KEY,LTP_VAPID_PRIVATE_KEY, andLTP_VAPID_SUBJECT(mailto:you@yourdomain.com) in Railway → Variables, then redeploy. - 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.)
- 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.
- 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_subscriptionstable (migration runs automatically on deploy). Dead subscriptions are pruned automatically when a push service reports them gone (HTTP 404/410).
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 gearPOST /api/allocations/reconcile— Rebuild every document-derived booking (admin)GET /api/stream— Server-sent change feed (live sync)
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.
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'squietKeys; 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.
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 coverswork/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.jsonstarts). 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 connectionPOST /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 QuickBooksPOST /api/qbo/invoices/{id}/delete— delete an invoice's QuickBooks counterpart
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.
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