Add real Shopify Billing API integration: Free/Starter/Growth/Pro plans
(app/lib/billing-plans.ts, priced per PRODUCT_STRATEGY.md §6) wired into
shopify.server.ts's billing config, a merchant-facing plan page
(app/routes/app.billing.tsx) using billing.request/billing.cancel, and
webhooks.app_subscriptions.update.tsx as the durable sync path for
Shop.tier (fires even when a merchant cancels from Shopify's own billing
page, not just from this app).
Gate the features actually built so far in both loader and action (never
just hidden in the UI, so a direct POST can't bypass a tier limit):
delivery zones/rates require Growth+, the dispatch dashboard requires
Starter+, and location count is capped per tier (Free=1, Starter=3,
Growth/Pro=unlimited). Split pure tier logic (app/lib/billing-plans.ts)
from DB-backed reads/writes (app/services/billing.server.ts) so the
client-rendered UpsellState component can import the Tier type without
pulling server code into the client bundle — same split as currency.ts.
Covered by tests/unit/billing-plans.test.ts (pure tier ranking/mapping)
and tests/integration/billing.test.ts (tier persistence and location-limit
enforcement against live Postgres).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Prisma: Booking.totalPriceCents (parsed from the order webhook's
total_price string), populated in booking.server.ts so "revenue by
method" is real data, not a placeholder.
- app/services/dashboard.server.ts: pure aggregation over injected booking
data (CLAUDE.md — no DB calls in the math) — bucketByLocalDate (each
booking grouped under its own location's local calendar day, not a
shared UTC day), revenueByMethod (confirmed/fulfilled only),
utilizationByDateLocation (booked vs. summed SlotTemplate capacity for
that weekday, capped at 100%), upcomingFulfillments, and a CSV
writer/formatter with proper quote-escaping.
- /app/dashboard: filterable (date range, location, method, status) view
with revenue-by-method cards, a capacity-utilization list (color-coded
by load), an upcoming-fulfillments table with one-click
confirmed->fulfilled/no_show status transitions, and a full by-day
booking list.
- /app/dashboard/export: a resource route (loader only, no component)
streaming the same filtered bookings as a downloadable CSV — kept
separate from the dashboard route specifically so it can import
dashboard.server.ts freely without the client-bundling constraint the
main route has to respect (see below).
Fixed the same class of server/client bundling bug from the Phase 5 commit
before it could ship, this time by construction: dashboard.server.ts's
aggregation functions are called only inside app.dashboard.tsx's `loader`,
never referenced by the default-exported component (which only reads
useLoaderData() output) — verified this holds by actually running
`npm run build`, not just tsc/vitest, which both stay silent about this
class of error. Also hit (and fixed) the same "loader Dates arrive as
strings on the client" issue from Phase 1: swapped DateTime.fromJSDate for
DateTime.fromISO in the two places the component formats a booking's
slotStart.
Verified: lint, typecheck, 102 unit tests (+16 new for dashboard.server.ts,
+2 new for totalPriceCents parsing), 18 integration tests, both builds, and
a live script exercising the full aggregation pipeline (revenue exclusion
of cancelled bookings, utilization math, CSV output) against the Postgres
container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>