From 33fb27a14cd5bd15c337c5c5e69871a2afec5b25 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 20:04:35 -0400 Subject: [PATCH 01/18] Add design spec for earnings analytics + payment tracking Co-Authored-By: Claude Fable 5 --- .../2026-07-11-earnings-analytics-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-earnings-analytics-design.md diff --git a/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md new file mode 100644 index 0000000..a71be00 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md @@ -0,0 +1,209 @@ +# Earnings analytics: payment tracking + dashboard — design + +**Date:** 2026-07-11 +**Status:** Draft (pending subagent review + user approval) +**Branch target:** off `custom-services` + +## Problem + +Sitters have no view of the money side of their business. The only +money-shaped data in the schema is `BookingRequests.EstCost` (an INTEGER +whole-dollar *estimate* computed at booking time) and per-option `Rate` on +`TenantServiceOptions`. There is no record of what was actually paid, no +paid/unpaid state, no payment processor, and no reporting of any kind — the +admin app (`app/admin/App.tsx`) has sections for bookings, clients, services, +etc., but nothing that aggregates. + +User decision during brainstorming: build **real payment tracking** (not +EstCost-only pseudo-revenue, not a Stripe integration), then build analytics +on recorded payments. A booking can have **multiple payment records** +(deposits, partial payments); outstanding balance = `EstCost` minus payments +recorded so far. + +## Goals + +1. A sitter can record payments against a booking (amount, method, date, + optional note), and delete a mistakenly-entered one. +2. A new **Earnings** section in the admin dashboard shows: + - Stat tiles: revenue this month, revenue last month, total outstanding, + count of unpaid/partially-paid confirmed bookings. + - **Revenue over time**: monthly bar chart of the last 12 months of + recorded payments. + - **Breakdown by service**: revenue per service (horizontal bars). + - **Top clients**: highest-spending clients by recorded payments, with + booking counts. + - **Outstanding balances**: confirmed bookings not yet fully paid — who + owes what — with an inline "record payment" action. +3. The Bookings section shows each booking's payment state (paid total vs + `EstCost`) and offers the same record-payment action, so a sitter can log + a payment at the moment they confirm a booking. +4. All aggregation happens server-side in SQL (D1/SQLite `GROUP BY`); the + client renders one JSON payload. + +## Non-goals + +- **Payment processing.** No Stripe/PayPal APIs — sitters collect money + however they already do (cash, Venmo, Zelle…) and record it here. The + `Method` column keeps the door open for a processor later. +- **Cents.** Amounts are INTEGER whole dollars, matching `EstCost` and + `Rate`. The rest of the schema is whole-dollar; introducing cents only for + payments would poison every aggregate with unit ambiguity. +- **Invoicing/receipts.** No PDFs, no emails to clients about payments. +- **Charting library.** Charts are hand-rolled SVG/CSS — a 12-bar monthly + chart is ~30 lines of JSX, and the repo's only runtime deps are + hono/react/react-dom. Revisit if charts multiply or need + tooltips/zoom/legends. +- **Date-range pickers / custom periods.** Fixed windows for v1: last 12 + months for the time chart, all-time for service/client breakdowns. + Add filtering when a real sitter asks for it. +- **Expense tracking.** "Spending" for a sitter's business (supplies, gas) + is a different feature; this is revenue-in only. + +## Alternatives considered + +**EstCost-only analytics (no payment tracking)** — zero schema change, but +every number would be an estimate of money that may never have arrived, and +"outstanding balances" would be impossible. Rejected by user during +brainstorming. + +**Single paid/unpaid flag on BookingRequests** — one column instead of a +table, but cannot represent deposits or partial payments, which are the norm +for multi-hundred-dollar boarding stays. Rejected by user in favor of a +Payments table. + +**Charting library (recharts et al.)** — ~100 kB+ into the admin bundle for +four static bar charts. Rejected; hand-rolled SVG. + +## Design + +### 1. Schema + +New migration `migrations/0008_payments.sql` (+ `sql/schema.sql` updated in +lockstep): + +```sql +CREATE TABLE IF NOT EXISTS Payments ( + Id TEXT PRIMARY KEY, + TenantId TEXT NOT NULL REFERENCES Tenants(Id), + BookingRequestId TEXT NOT NULL REFERENCES BookingRequests(Id), + Amount INTEGER NOT NULL CHECK (Amount > 0), -- whole dollars, matching EstCost/Rate + Method TEXT NOT NULL CHECK (Method IN ('cash', 'venmo', 'zelle', 'paypal', 'check', 'card', 'other')), + PaidDate TEXT NOT NULL, -- 'YYYY-MM-DD', sitter-entered (defaults to today in the UI) + Note TEXT, + CreatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_Payments_Tenant_Date ON Payments (TenantId, PaidDate); +CREATE INDEX IF NOT EXISTS idx_Payments_Tenant_Booking ON Payments (TenantId, BookingRequestId); +``` + +Follows the Model A invariant (TenantId on every table). `PaidDate` is a +plain date the sitter types/accepts, not a server timestamp — payments are +often recorded days after they happen, and monthly grouping should follow +the sitter's stated date, not insertion time. + +### 2. Repo layer (`server/db/repo.ts`) + +New functions, all tenant-scoped like every existing query: + +- `insertPayment(db, { tenantId, bookingRequestId, amount, method, paidDate, note })` + — inserts iff the booking exists for this tenant, is not `ServiceType='blocked'`, + and is not cancelled (guard in the `WHERE`/subquery, same atomic-guard + idiom as `updateBookingStatus`). Returns the row or null. +- `deletePayment(db, tenantId, paymentId)` — returns whether a row changed + (route 404s on false, the existing idiom). +- `listPaymentsForBookings(db, tenantId, bookingIds)` — payments grouped by + booking, for the Bookings section's paid-total display. In practice + implemented as a `PaidTotal` aggregate added to `listBookingsForTenant` + via `LEFT JOIN (SELECT BookingRequestId, SUM(Amount) ...)` — one query, + no N+1. +- `getAnalytics(db, tenantId)` — runs the four aggregate queries and + returns one object: + - **monthly**: `SELECT substr(PaidDate,1,7) AS Month, SUM(Amount) ... GROUP BY Month` + over the last 12 months (missing months filled to 0 in JS). + - **byService**: payments joined to `BookingRequests.ServiceType`, joined + to `TenantServices.Label` for display names, `GROUP BY ServiceType`, + all-time. Services deleted since payment keep the raw slug as label + (LEFT JOIN, `COALESCE(Label, ServiceType)`). + - **topClients**: payments joined through bookings to `EndUsers`, + `GROUP BY EndUserId`, `SUM(Amount)` + `COUNT(DISTINCT BookingRequestId)`, + ordered by total desc, `LIMIT 10`. + - **outstanding**: confirmed bookings where + `COALESCE(paid.Total, 0) < EstCost`, with customer name/email, EstCost, + paid total, ordered by balance desc. Rows with `EstCost IS NULL` are + excluded — a booking with no estimate can't have a computable balance. + - **tiles**: this-month / last-month revenue derived from **monthly** in + JS; outstanding total + count derived from **outstanding** in JS. No + extra queries. + +### 3. Admin routes (`server/routes/admin.ts`) + +Added to the existing chained Hono app (same `adminAuth` + tenant middleware +stack as every other `/:slug/admin/*` route): + +- `GET /:slug/admin/analytics` — returns `getAnalytics` payload verbatim. +- `POST /:slug/admin/bookings/:id/payments` — body + `{ amount, method, paidDate, note? }`. Validates: amount is a positive + integer (reuse the defensive-validation style at the top of the file), + method is one of the allowed set, paidDate matches `YYYY-MM-DD`. 404 if + `insertPayment` refuses (wrong tenant / blocked / cancelled). Returns the + created payment plus the booking's new paid total. +- `DELETE /:slug/admin/bookings/:id/payments/:paymentId` — 404 if nothing + deleted. + +No KV caching on the analytics endpoint — it's a handful of indexed +aggregates over a prototype-scale D1; add caching only if it measurably +drags. + +### 4. Frontend + +- **`app/admin/sections/EarningsSection.tsx`** (new) — fetches + `adminApi.analytics.get` on mount; renders stat tiles, the 12-month SVG + bar chart, by-service horizontal bars, top-clients table, and the + outstanding table with an inline record-payment form (amount, method + select, date defaulting to today, note). Recording a payment re-fetches + the payload. Empty states ("No payments recorded yet") for a brand-new + tenant. +- **`app/admin/sections/BookingsSection.tsx`** — each non-blocked, + non-cancelled booking row additionally shows `paid $X of $Y` (or + `paid in full`) from the new `paidTotal` field, plus a "Record payment" + action opening the same small form. The form component is shared: + **`app/admin/RecordPaymentForm.tsx`**, imported by both sections. +- **`app/shared-ui/api.ts`** — `AnalyticsPayload`, `Payment` types; + `adminApi.analytics.get` and `adminApi.payments.record/remove`; + `AdminBooking` gains `paidTotal`. +- **`app/admin/App.tsx`** — `'earnings'` added to the `SectionKey` union, + `SECTIONS` array (icon: an existing icon component or a small new one in + the same style), and the `panels` record. +- Charts follow the admin app's existing visual language (accent color from + tenant settings where the UI already does that); axis labels are plain + text, no interactivity. + +## Error handling + +- Payment validation failures → 400 with a message, same shape as existing + admin validation errors. +- Recording against a cancelled/blocked/foreign booking → 404 (repo guard + returns null/false), matching the "row didn't change → not found" idiom. +- Analytics endpoint has no partial-failure mode: it's read-only SELECTs; + any D1 error surfaces as the standard 500. +- Frontend: fetch errors in EarningsSection render the section's error + message pattern used by other sections (whatever BookingsSection does on + load failure). + +## Testing + +Per-concern test files, mirroring the existing convention: + +- `server/__tests__/payments-repo.test.ts` — `insertPayment` guards + (cancelled booking refused, blocked row refused, cross-tenant refused, + happy path), `deletePayment` tenant scoping, paid-total aggregation on + `listBookingsForTenant`. +- `server/__tests__/analytics.test.ts` — seed a tenant with bookings + + payments and assert each aggregate: monthly buckets (including + zero-filled months), by-service totals (including a deleted-service slug + fallback), top-client ordering and booking counts, outstanding math + (partial payment → correct balance; EstCost NULL excluded; fully-paid + excluded; cancelled excluded), and route-level validation (bad amount, + bad method, bad date → 400; foreign booking → 404). +- `server/__tests__/migration-0008.test.ts` — migration applies cleanly on + a 0007-state DB, per the existing migration-test pattern. From 7415ee252ea314dd1bcd09e31063e884befcb18e Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 20:10:32 -0400 Subject: [PATCH 02/18] Revise earnings analytics spec per subagent review Fact-check pass: guarded INSERT idiom spelled out, paidTotal route glue, icon/error-banner conventions corrected, migration test dropped to match repo convention. Design pass: per-booking payments panel (list + delete), deletePayment scoped to booking id, refund/cancel-after-payment decision documented, isRealDate/isValidRate reuse, pending-deposit semantics, all-time labels. Co-Authored-By: Claude Fable 5 --- .../2026-07-11-earnings-analytics-design.md | 99 +++++++++++++------ 1 file changed, 70 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md index a71be00..6dc346b 100644 --- a/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md +++ b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md @@ -1,7 +1,7 @@ # Earnings analytics: payment tracking + dashboard — design **Date:** 2026-07-11 -**Status:** Draft (pending subagent review + user approval) +**Status:** Revised after two-subagent review (fact-check vs codebase + design review); pending user approval **Branch target:** off `custom-services` ## Problem @@ -49,6 +49,13 @@ recorded so far. `Rate`. The rest of the schema is whole-dollar; introducing cents only for payments would poison every aggregate with unit ambiguity. - **Invoicing/receipts.** No PDFs, no emails to clients about payments. +- **Refunds / cancel-after-payment netting.** Revenue aggregates count + payments regardless of the booking's later status — cash already received + is real revenue (only the *outstanding* query filters to confirmed). + There are no negative amounts (`CHECK (Amount > 0)`); deleting the + payment record is the only correction mechanism, which is why + `deletePayment` has no booking-status guard. Revisit if real refund + bookkeeping is ever needed. - **Charting library.** Charts are hand-rolled SVG/CSS — a 12-bar monthly chart is ~30 lines of JSX, and the repo's only runtime deps are hono/react/react-dom. Revisit if charts multiply or need @@ -107,15 +114,27 @@ New functions, all tenant-scoped like every existing query: - `insertPayment(db, { tenantId, bookingRequestId, amount, method, paidDate, note })` — inserts iff the booking exists for this tenant, is not `ServiceType='blocked'`, - and is not cancelled (guard in the `WHERE`/subquery, same atomic-guard - idiom as `updateBookingStatus`). Returns the row or null. -- `deletePayment(db, tenantId, paymentId)` — returns whether a row changed - (route 404s on false, the existing idiom). -- `listPaymentsForBookings(db, tenantId, bookingIds)` — payments grouped by - booking, for the Bookings section's paid-total display. In practice - implemented as a `PaidTotal` aggregate added to `listBookingsForTenant` - via `LEFT JOIN (SELECT BookingRequestId, SUM(Amount) ...)` — one query, - no N+1. + and is not cancelled. A plain `INSERT` has no `WHERE`, so this is an + `INSERT INTO Payments (...) SELECT ... FROM BookingRequests WHERE + TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'` + — a new idiom for `repo.ts` (no existing guarded insert to copy), atomic + like `updateBookingStatus`'s `UPDATE ... WHERE` guard. Returns whether a + row was inserted (`meta.changes`). `pending` bookings are **deliberately + allowed** — deposits are commonly collected before a booking is + confirmed. Note the outstanding table only lists confirmed bookings, so a + deposit on a pending booking won't appear there until confirmation. +- `deletePayment(db, tenantId, bookingRequestId, paymentId)` — the `WHERE` + includes `BookingRequestId = ?` so a payment id paired with the wrong + booking id in the URL 404s instead of silently deleting. Deliberately has + **no status guard** (works on cancelled bookings too) — deleting the + record is the only correction mechanism for refunds (see Non-goals). + Returns whether a row changed (route 404s on false, the existing idiom). +- `listPaymentsForBooking(db, tenantId, bookingRequestId)` — the individual + payment rows for one booking (date/amount/method/note), for the + payment-list UI below. +- `listBookingsForTenant` (existing) gains a `PaidTotal` aggregate via + `LEFT JOIN (SELECT BookingRequestId, SUM(Amount) ...)` — one query, no + N+1. - `getAnalytics(db, tenantId)` — runs the four aggregate queries and returns one object: - **monthly**: `SELECT substr(PaidDate,1,7) AS Month, SUM(Amount) ... GROUP BY Month` @@ -141,14 +160,23 @@ Added to the existing chained Hono app (same `adminAuth` + tenant middleware stack as every other `/:slug/admin/*` route): - `GET /:slug/admin/analytics` — returns `getAnalytics` payload verbatim. +- `GET /:slug/admin/bookings` (existing) — its response-mapping object + (`server/routes/admin.ts:~746`, the `status: r.Declined ? 'declined' : + r.Status` block) gains `paidTotal: r.PaidTotal ?? 0` so the repo-layer + join actually reaches the client. - `POST /:slug/admin/bookings/:id/payments` — body - `{ amount, method, paidDate, note? }`. Validates: amount is a positive - integer (reuse the defensive-validation style at the top of the file), - method is one of the allowed set, paidDate matches `YYYY-MM-DD`. 404 if + `{ amount, method, paidDate, note? }`. Validates: amount via the existing + `isValidRate` (whole dollars ≥ 1, `server/lib/services.ts`), method is + one of the allowed set, paidDate via the existing `isRealDate` + (`server/lib/validation.ts` — a bare `YYYY-MM-DD` regex accepts + 2026-02-30, which would corrupt monthly bucketing). 404 if `insertPayment` refuses (wrong tenant / blocked / cancelled). Returns the created payment plus the booking's new paid total. -- `DELETE /:slug/admin/bookings/:id/payments/:paymentId` — 404 if nothing - deleted. +- `DELETE /:slug/admin/bookings/:id/payments/:paymentId` — passes both ids + to `deletePayment`; 404 if nothing deleted (including a payment/booking + mismatch). +- `GET /:slug/admin/bookings/:id/payments` — the individual payment rows + for one booking, backing the payment-list UI. No KV caching on the analytics endpoint — it's a handful of indexed aggregates over a prototype-scale D1; add caching only if it measurably @@ -162,18 +190,28 @@ drags. outstanding table with an inline record-payment form (amount, method select, date defaulting to today, note). Recording a payment re-fetches the payload. Empty states ("No payments recorded yet") for a brand-new - tenant. + tenant. Because `byService`/`topClients` are all-time while the chart is + 12 months, those two widgets are labeled "(all-time)". - **`app/admin/sections/BookingsSection.tsx`** — each non-blocked, - non-cancelled booking row additionally shows `paid $X of $Y` (or - `paid in full`) from the new `paidTotal` field, plus a "Record payment" - action opening the same small form. The form component is shared: - **`app/admin/RecordPaymentForm.tsx`**, imported by both sections. + non-cancelled booking row additionally shows `paid $X of $Y` from the new + `paidTotal` field (`paid in full` once `paidTotal >= EstCost`, covering + overpayment/tips), plus a "Payments" action that expands an inline panel: + the booking's individual payment rows (date, amount, method, note) each + with a delete button, and the record-payment form beneath. Both record + and delete re-fetch the bookings list, mirroring the existing + `setStatus → reload` pattern in this file. The panel is a shared + component — **`app/admin/PaymentsPanel.tsx`** (list + form + delete) — + also used by EarningsSection's outstanding table (which re-fetches the + analytics payload instead). `app/admin/` currently only holds `App.tsx`; + a shared non-section component at that level is a new-but-consistent + placement. - **`app/shared-ui/api.ts`** — `AnalyticsPayload`, `Payment` types; - `adminApi.analytics.get` and `adminApi.payments.record/remove`; + `adminApi.analytics.get` and `adminApi.payments.list/record/remove`; `AdminBooking` gains `paidTotal`. - **`app/admin/App.tsx`** — `'earnings'` added to the `SectionKey` union, - `SECTIONS` array (icon: an existing icon component or a small new one in - the same style), and the `panels` record. + `SECTIONS` array, and the `panels` record. Icon: reuse an existing + component from `app/shared-ui/icons.tsx` or add a new one there (icons do + not live in `App.tsx`; they're imported). - Charts follow the admin app's existing visual language (accent color from tenant settings where the UI already does that); axis labels are plain text, no interactivity. @@ -186,9 +224,9 @@ drags. returns null/false), matching the "row didn't change → not found" idiom. - Analytics endpoint has no partial-failure mode: it's read-only SELECTs; any D1 error surfaces as the standard 500. -- Frontend: fetch errors in EarningsSection render the section's error - message pattern used by other sections (whatever BookingsSection does on - load failure). +- Frontend: sections don't render their own errors — they call the + `handleError` prop that funnels into `App.tsx`'s single app-level error + banner (`App.tsx:499`). EarningsSection does the same. ## Testing @@ -196,7 +234,9 @@ Per-concern test files, mirroring the existing convention: - `server/__tests__/payments-repo.test.ts` — `insertPayment` guards (cancelled booking refused, blocked row refused, cross-tenant refused, - happy path), `deletePayment` tenant scoping, paid-total aggregation on + pending booking *allowed*, happy path), `deletePayment` tenant scoping + and booking/payment-mismatch refusal (and that it works on a cancelled + booking), `listPaymentsForBooking`, paid-total aggregation on `listBookingsForTenant`. - `server/__tests__/analytics.test.ts` — seed a tenant with bookings + payments and assert each aggregate: monthly buckets (including @@ -205,5 +245,6 @@ Per-concern test files, mirroring the existing convention: (partial payment → correct balance; EstCost NULL excluded; fully-paid excluded; cancelled excluded), and route-level validation (bad amount, bad method, bad date → 400; foreign booking → 404). -- `server/__tests__/migration-0008.test.ts` — migration applies cleanly on - a 0007-state DB, per the existing migration-test pattern. +- No dedicated migration test: the repo only writes those for table-rebuild + migrations (0002/0003/0006); pure additive `CREATE TABLE` migrations + (0004, this one) rely on repo/route tests exercising the new table. From eacf1013c2016e7e281cf580d3f9cf52d1d582ac Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 21:48:03 -0400 Subject: [PATCH 03/18] Add Payments table and payment repo layer Co-Authored-By: Claude Fable 5 --- migrations/0008_payments.sql | 21 ++++ server/__tests__/payments-repo.test.ts | 151 +++++++++++++++++++++++++ server/db/repo.ts | 91 ++++++++++++++- server/lib/validation.ts | 17 +++ server/types.ts | 12 ++ sql/schema.sql | 15 +++ 6 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 migrations/0008_payments.sql create mode 100644 server/__tests__/payments-repo.test.ts diff --git a/migrations/0008_payments.sql b/migrations/0008_payments.sql new file mode 100644 index 0000000..db6c9fb --- /dev/null +++ b/migrations/0008_payments.sql @@ -0,0 +1,21 @@ +-- Earnings analytics: real payment records against bookings. Multiple rows per booking +-- (deposits/partial payments); amounts are whole dollars matching EstCost/Rate. PaidDate is +-- sitter-entered (payments are often recorded days late; monthly grouping follows the sitter's +-- stated date, not insertion time). See +-- docs/superpowers/specs/2026-07-11-earnings-analytics-design.md. +-- Run against provisioned DBs (local then remote): +-- npx wrangler d1 execute pawbook-db --local --file ./migrations/0008_payments.sql +-- npx wrangler d1 execute pawbook-db --remote --file ./migrations/0008_payments.sql + +CREATE TABLE IF NOT EXISTS Payments ( + Id TEXT PRIMARY KEY, + TenantId TEXT NOT NULL REFERENCES Tenants(Id), + BookingRequestId TEXT NOT NULL REFERENCES BookingRequests(Id), + Amount INTEGER NOT NULL CHECK (Amount > 0), -- whole dollars, matching EstCost/Rate + Method TEXT NOT NULL CHECK (Method IN ('cash', 'venmo', 'zelle', 'paypal', 'check', 'card', 'other')), + PaidDate TEXT NOT NULL, -- 'YYYY-MM-DD', sitter-entered (defaults to today in the UI) + Note TEXT, + CreatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_Payments_Tenant_Date ON Payments (TenantId, PaidDate); +CREATE INDEX IF NOT EXISTS idx_Payments_Tenant_Booking ON Payments (TenantId, BookingRequestId); diff --git a/server/__tests__/payments-repo.test.ts b/server/__tests__/payments-repo.test.ts new file mode 100644 index 0000000..ab6c459 --- /dev/null +++ b/server/__tests__/payments-repo.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { + deletePayment, + insertBookingRequest, + insertPayment, + listBookingsForTenant, + listPaymentsForBooking, + updateBookingStatus, +} from '../db/repo'; +import { createTestEnv, TENANT_A, TENANT_B } from './helpers'; + +const makeBooking = (env: Env, tenantId: string, status: 'pending' | 'confirmed' = 'confirmed') => + insertBookingRequest(env.PAWBOOK_DB, tenantId, { + endUserId: null, + serviceType: 'boarding', + startDate: '2030-01-01', + endDate: '2030-01-03', + optionKey: 'standard', + petType: 'dog', + petCount: 1, + estCost: 100, + status, + }); + +const pay = (env: Env, tenantId: string, bookingRequestId: string, amount = 50) => + insertPayment(env.PAWBOOK_DB, tenantId, { + bookingRequestId, + amount, + method: 'cash', + paidDate: '2026-07-01', + note: null, + }); + +describe('payments repo', () => { + it('records a payment against a confirmed booking and lists it back', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const paymentId = await insertPayment(env.PAWBOOK_DB, TENANT_A, { + bookingRequestId: bookingId, + amount: 75, + method: 'venmo', + paidDate: '2026-07-02', + note: 'deposit', + }); + expect(paymentId).not.toBeNull(); + const rows = await listPaymentsForBooking(env.PAWBOOK_DB, TENANT_A, bookingId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + Id: paymentId, + TenantId: TENANT_A, + BookingRequestId: bookingId, + Amount: 75, + Method: 'venmo', + PaidDate: '2026-07-02', + Note: 'deposit', + }); + }); + + it('allows payments on a PENDING booking (deposits before confirmation)', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A, 'pending'); + expect(await pay(env, TENANT_A, bookingId)).not.toBeNull(); + }); + + it('refuses a payment on a cancelled booking', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + await updateBookingStatus(env.PAWBOOK_DB, TENANT_A, bookingId, 'cancelled'); + expect(await pay(env, TENANT_A, bookingId)).toBeNull(); + }); + + it('refuses a payment on a blocked sentinel row', async () => { + const { env } = createTestEnv(); + const blockedId = await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, { + endUserId: null, + serviceType: 'blocked', + startDate: '2030-02-01', + endDate: '2030-02-03', + optionKey: null, + petType: null, + petCount: 1, + estCost: null, + status: 'confirmed', + }); + expect(await pay(env, TENANT_A, blockedId)).toBeNull(); + }); + + it('refuses a cross-tenant payment (booking belongs to another tenant)', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + expect(await pay(env, TENANT_B, bookingId)).toBeNull(); + }); + + it('deletePayment is tenant-scoped and requires the matching booking id', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const otherBookingId = await makeBooking(env, TENANT_A); + const paymentId = (await pay(env, TENANT_A, bookingId))!; + // Wrong tenant: refused. + expect(await deletePayment(env.PAWBOOK_DB, TENANT_B, bookingId, paymentId)).toBe(false); + // Right tenant, WRONG booking id in the pair: refused (404 at the route, never a silent delete). + expect(await deletePayment(env.PAWBOOK_DB, TENANT_A, otherBookingId, paymentId)).toBe(false); + // Correct pair: deleted. + expect(await deletePayment(env.PAWBOOK_DB, TENANT_A, bookingId, paymentId)).toBe(true); + expect(await listPaymentsForBooking(env.PAWBOOK_DB, TENANT_A, bookingId)).toHaveLength(0); + // Gone now: a second delete reports false. + expect(await deletePayment(env.PAWBOOK_DB, TENANT_A, bookingId, paymentId)).toBe(false); + }); + + it('deletePayment works on a cancelled booking (delete is the only correction mechanism)', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const paymentId = (await pay(env, TENANT_A, bookingId))!; + await updateBookingStatus(env.PAWBOOK_DB, TENANT_A, bookingId, 'cancelled'); + expect(await deletePayment(env.PAWBOOK_DB, TENANT_A, bookingId, paymentId)).toBe(true); + }); + + it('listPaymentsForBooking returns only that booking, newest paid-date first', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const otherBookingId = await makeBooking(env, TENANT_A); + await insertPayment(env.PAWBOOK_DB, TENANT_A, { + bookingRequestId: bookingId, + amount: 10, + method: 'cash', + paidDate: '2026-06-01', + note: null, + }); + await insertPayment(env.PAWBOOK_DB, TENANT_A, { + bookingRequestId: bookingId, + amount: 20, + method: 'zelle', + paidDate: '2026-07-01', + note: null, + }); + await pay(env, TENANT_A, otherBookingId, 999); + const rows = await listPaymentsForBooking(env.PAWBOOK_DB, TENANT_A, bookingId); + expect(rows.map((r) => r.Amount)).toEqual([20, 10]); + }); + + it('listBookingsForTenant aggregates PaidTotal (0 when unpaid)', async () => { + const { env } = createTestEnv(); + const paidId = await makeBooking(env, TENANT_A); + const unpaidId = await makeBooking(env, TENANT_A); + await pay(env, TENANT_A, paidId, 30); + await pay(env, TENANT_A, paidId, 45); + const rows = await listBookingsForTenant(env.PAWBOOK_DB, TENANT_A); + expect(rows.find((r) => r.Id === paidId)?.PaidTotal).toBe(75); + expect(rows.find((r) => r.Id === unpaidId)?.PaidTotal).toBe(0); + }); +}); diff --git a/server/db/repo.ts b/server/db/repo.ts index 1102dd7..df9ec51 100644 --- a/server/db/repo.ts +++ b/server/db/repo.ts @@ -2,6 +2,7 @@ import type { BookingRow, EndUser, EndUserPet, + PaymentRow, PetType, ProviderConnection, ProviderConnectionWithTokens, @@ -12,6 +13,7 @@ import type { TenantUser, } from '../types'; import type { ServiceType } from '../lib/services'; +import type { PaymentMethod } from '../lib/validation'; import type { ServiceQuestion } from '../../src/shared/index.js'; import { constantTimeEqual } from '../lib/timing'; @@ -311,18 +313,23 @@ export async function listBookingsForUser( export async function listBookingsForTenant( db: D1Database, tenantId: string, -): Promise<(BookingRow & { Email: string | null; Name: string | null })[]> { +): Promise<(BookingRow & { Email: string | null; Name: string | null; PaidTotal: number })[]> { const { results } = await db .prepare( - `SELECT BookingRequests.*, EndUsers.Email AS Email, EndUsers.Name AS Name + `SELECT BookingRequests.*, EndUsers.Email AS Email, EndUsers.Name AS Name, + COALESCE(paid.Total, 0) AS PaidTotal FROM BookingRequests LEFT JOIN EndUsers ON EndUsers.Id = BookingRequests.EndUserId AND EndUsers.TenantId = BookingRequests.TenantId + LEFT JOIN ( + SELECT BookingRequestId, SUM(Amount) AS Total + FROM Payments WHERE TenantId = ? GROUP BY BookingRequestId + ) paid ON paid.BookingRequestId = BookingRequests.Id WHERE BookingRequests.TenantId = ? AND BookingRequests.ServiceType != 'blocked' ORDER BY BookingRequests.StartDate DESC, BookingRequests.CreatedAt DESC`, ) - .bind(tenantId) - .all(); + .bind(tenantId, tenantId) + .all(); return results; } @@ -379,6 +386,82 @@ export async function getBookingWithCustomer( .first(); } +/** + * Record a payment iff the booking exists for THIS tenant, is not a 'blocked' sentinel, and is + * not cancelled — the guard lives in the SQL (INSERT ... SELECT ... WHERE) so it is atomic with + * the write, like updateBookingStatus's guarded UPDATE. 'pending' is deliberately allowed: + * deposits are commonly collected before a booking is confirmed. Returns the new payment id, or + * null when the guard refused (route 404s on null, the existing idiom). + */ +export async function insertPayment( + db: D1Database, + tenantId: string, + payment: { + bookingRequestId: string; + amount: number; + method: PaymentMethod; + paidDate: string; + note: string | null; + }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare( + `INSERT INTO Payments (Id, TenantId, BookingRequestId, Amount, Method, PaidDate, Note) + SELECT ?, ?, ?, ?, ?, ?, ? + FROM BookingRequests + WHERE TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'`, + ) + .bind( + id, + tenantId, + payment.bookingRequestId, + payment.amount, + payment.method, + payment.paidDate, + payment.note, + tenantId, + payment.bookingRequestId, + ) + .run(); + return (result.meta as { changes?: number }).changes !== 0 ? id : null; +} + +/** + * Delete one payment. The WHERE includes BookingRequestId so a payment id paired with the wrong + * booking id in the URL reports false (route 404s) instead of silently deleting. Deliberately NO + * booking-status guard — deleting the record is the only correction mechanism for refunds, so it + * must work on cancelled bookings too (see the design's Non-goals). + */ +export async function deletePayment( + db: D1Database, + tenantId: string, + bookingRequestId: string, + paymentId: string, +): Promise { + const result = await db + .prepare('DELETE FROM Payments WHERE TenantId = ? AND BookingRequestId = ? AND Id = ?') + .bind(tenantId, bookingRequestId, paymentId) + .run(); + return (result.meta as { changes?: number }).changes !== 0; +} + +export async function listPaymentsForBooking( + db: D1Database, + tenantId: string, + bookingRequestId: string, +): Promise { + const { results } = await db + .prepare( + `SELECT Id, TenantId, BookingRequestId, Amount, Method, PaidDate, Note, CreatedAt + FROM Payments WHERE TenantId = ? AND BookingRequestId = ? + ORDER BY PaidDate DESC, CreatedAt DESC`, + ) + .bind(tenantId, bookingRequestId) + .all(); + return results; +} + export async function listProviderConnections( db: D1Database, tenantId: string, diff --git a/server/lib/validation.ts b/server/lib/validation.ts index 64563df..94561a2 100644 --- a/server/lib/validation.ts +++ b/server/lib/validation.ts @@ -91,3 +91,20 @@ export function isValidRate(value: unknown): value is number { export function isValidDuration(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 1; } + +/** How a sitter collected money. Mirrors the SQL CHECK on Payments.Method — keep in lockstep. */ +export const PAYMENT_METHODS = [ + 'cash', + 'venmo', + 'zelle', + 'paypal', + 'check', + 'card', + 'other', +] as const; + +export type PaymentMethod = (typeof PAYMENT_METHODS)[number]; + +export function isPaymentMethod(value: unknown): value is PaymentMethod { + return typeof value === 'string' && (PAYMENT_METHODS as readonly string[]).includes(value); +} diff --git a/server/types.ts b/server/types.ts index d5eb260..1ab985e 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,4 +1,5 @@ import type { CapacityKind, PetType, RateUnit, ServiceShape, ServiceType } from './lib/services'; +import type { PaymentMethod } from './lib/validation'; import type { ServiceQuestion } from '../src/shared/index.js'; export type { CapacityKind, PetType, RateUnit, ServiceShape, ServiceType }; @@ -92,6 +93,17 @@ export type BookingRow = { CreatedAt: string; }; +export type PaymentRow = { + Id: string; + TenantId: string; + BookingRequestId: string; + Amount: number; + Method: PaymentMethod; + PaidDate: string; + Note: string | null; + CreatedAt: string; +}; + export type ProviderConnection = { Id: string; TenantId: string; diff --git a/sql/schema.sql b/sql/schema.sql index 7ba7cf9..3cb4e8b 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -137,6 +137,21 @@ CREATE TABLE IF NOT EXISTS BookingRequestPets ( PRIMARY KEY (BookingRequestId, PetId) ); +-- Recorded payments against bookings (earnings analytics). Multiple rows per booking +-- (deposits/partials); whole dollars matching EstCost/Rate. PaidDate is sitter-entered. +CREATE TABLE IF NOT EXISTS Payments ( + Id TEXT PRIMARY KEY, + TenantId TEXT NOT NULL REFERENCES Tenants(Id), + BookingRequestId TEXT NOT NULL REFERENCES BookingRequests(Id), + Amount INTEGER NOT NULL CHECK (Amount > 0), -- whole dollars, matching EstCost/Rate + Method TEXT NOT NULL CHECK (Method IN ('cash', 'venmo', 'zelle', 'paypal', 'check', 'card', 'other')), + PaidDate TEXT NOT NULL, -- 'YYYY-MM-DD', sitter-entered (defaults to today in the UI) + Note TEXT, + CreatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_Payments_Tenant_Date ON Payments (TenantId, PaidDate); +CREATE INDEX IF NOT EXISTS idx_Payments_Tenant_Booking ON Payments (TenantId, BookingRequestId); + CREATE TABLE IF NOT EXISTS ProviderConnections ( Id TEXT PRIMARY KEY, TenantId TEXT NOT NULL REFERENCES Tenants(Id), From ccf1c836aacba7b99caa166698a58bf95cc2296e Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 21:54:46 -0400 Subject: [PATCH 04/18] Add getAnalytics earnings aggregates to repo Co-Authored-By: Claude Fable 5 --- server/__tests__/analytics.test.ts | 161 +++++++++++++++++++++++++++++ server/db/repo.ts | 87 ++++++++++++++++ server/types.ts | 23 +++++ 3 files changed, 271 insertions(+) create mode 100644 server/__tests__/analytics.test.ts diff --git a/server/__tests__/analytics.test.ts b/server/__tests__/analytics.test.ts new file mode 100644 index 0000000..ec091c1 --- /dev/null +++ b/server/__tests__/analytics.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { + getAnalytics, + insertBookingRequest, + insertInvitedCustomer, + insertPayment, + updateBookingStatus, +} from '../db/repo'; +import { createTestEnv, TENANT_A, TENANT_B } from './helpers'; + +// Seeded clean-slate tenant (sql/seed.sql): has customers but NO bookings, so outstanding +// assertions can be exact. TENANT_A/B each carry a seeded confirmed unpaid booking. +const TENANT_C = 'tnt_pawsandrelax'; + +// Fixed anchor for repo-level tests — the 12-month window is 2025-08 .. 2026-07. +const TODAY = '2026-07-15'; + +const makeBooking = ( + env: Env, + tenantId: string, + over: { + endUserId?: string | null; + serviceType?: string; + estCost?: number | null; + status?: 'pending' | 'confirmed'; + } = {}, +) => + insertBookingRequest(env.PAWBOOK_DB, tenantId, { + endUserId: over.endUserId ?? null, + serviceType: over.serviceType ?? 'boarding', + startDate: '2030-01-01', + endDate: '2030-01-03', + optionKey: 'standard', + petType: 'dog', + petCount: 1, + estCost: over.estCost !== undefined ? over.estCost : 100, + status: over.status ?? 'confirmed', + }); + +const pay = ( + env: Env, + tenantId: string, + bookingRequestId: string, + amount: number, + paidDate = '2026-07-01', +) => + insertPayment(env.PAWBOOK_DB, tenantId, { + bookingRequestId, + amount, + method: 'cash', + paidDate, + note: null, + }); + +describe('getAnalytics (repo)', () => { + it('monthly: 12 zero-filled buckets, oldest first, out-of-window payments excluded', async () => { + const { env } = createTestEnv(); + const b1 = await makeBooking(env, TENANT_A); + await pay(env, TENANT_A, b1, 40, '2026-07-01'); + await pay(env, TENANT_A, b1, 60, '2026-07-20'); + await pay(env, TENANT_A, b1, 25, '2026-05-10'); + await pay(env, TENANT_A, b1, 999, '2025-07-31'); // month 2025-07: just outside the window + const { monthly } = await getAnalytics(env.PAWBOOK_DB, TENANT_A, TODAY); + expect(monthly).toHaveLength(12); + expect(monthly[0]).toEqual({ Month: '2025-08', Total: 0 }); + expect(monthly[11]).toEqual({ Month: '2026-07', Total: 100 }); + expect(monthly.find((m) => m.Month === '2026-05')).toEqual({ Month: '2026-05', Total: 25 }); + expect(monthly.find((m) => m.Month === '2025-07')).toBeUndefined(); + expect(monthly.filter((m) => m.Total === 0)).toHaveLength(10); + }); + + it('byService: labels from TenantServices, slug fallback for deleted services, ordered by total desc', async () => { + const { env } = createTestEnv(); + const boarding = await makeBooking(env, TENANT_A, { serviceType: 'boarding' }); + const walk = await makeBooking(env, TENANT_A, { serviceType: 'walk' }); + const gone = await makeBooking(env, TENANT_A, { serviceType: 'retired-svc' }); + await pay(env, TENANT_A, boarding, 200); + await pay(env, TENANT_A, walk, 35); + await pay(env, TENANT_A, gone, 80); + const { byService } = await getAnalytics(env.PAWBOOK_DB, TENANT_A, TODAY); + expect(byService).toEqual([ + { ServiceType: 'boarding', Label: 'Boarding', Total: 200 }, + { ServiceType: 'retired-svc', Label: 'retired-svc', Total: 80 }, + { ServiceType: 'walk', Label: 'Walks', Total: 35 }, + ]); + }); + + it('revenue counts payments on later-cancelled bookings (cash received is real revenue)', async () => { + const { env } = createTestEnv(); + const b1 = await makeBooking(env, TENANT_A); + await pay(env, TENANT_A, b1, 150); + await updateBookingStatus(env.PAWBOOK_DB, TENANT_A, b1, 'cancelled'); + const { byService, monthly } = await getAnalytics(env.PAWBOOK_DB, TENANT_A, TODAY); + expect(byService).toEqual([{ ServiceType: 'boarding', Label: 'Boarding', Total: 150 }]); + expect(monthly[11].Total).toBe(150); + }); + + it('topClients: ordered by total desc, distinct booking counts, LIMIT 10', async () => { + const { env } = createTestEnv(); + for (let i = 0; i < 12; i++) { + const user = await insertInvitedCustomer( + env.PAWBOOK_DB, + TENANT_C, + `client${i}@example.com`, + `Client ${i}`, + ); + const bookingId = await makeBooking(env, TENANT_C, { endUserId: user.Id }); + await pay(env, TENANT_C, bookingId, 10 + i); + } + const { topClients } = await getAnalytics(env.PAWBOOK_DB, TENANT_C, TODAY); + expect(topClients).toHaveLength(10); // clients 0 and 1 ($10, $11) fall off + expect(topClients[0]).toMatchObject({ Email: 'client11@example.com', Total: 21, Bookings: 1 }); + expect(topClients.some((t) => t.Email === 'client0@example.com')).toBe(false); + expect(topClients.some((t) => t.Email === 'client1@example.com')).toBe(false); + }); + + it('topClients: two payments on one booking count as ONE booking; two bookings as two', async () => { + const { env } = createTestEnv(); + const jess = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_C, 'jess@example.com', 'Jess'); + const b1 = await makeBooking(env, TENANT_C, { endUserId: jess.Id }); + const b2 = await makeBooking(env, TENANT_C, { endUserId: jess.Id }); + await pay(env, TENANT_C, b1, 30); + await pay(env, TENANT_C, b1, 20); + await pay(env, TENANT_C, b2, 50); + const { topClients } = await getAnalytics(env.PAWBOOK_DB, TENANT_C, TODAY); + // Name asserted via jess.Name (not a literal): tnt_pawsandrelax seeds an active customer at + // this exact email (sql/seed.sql), and insertInvitedCustomer is idempotent — it returns the + // existing seeded record ('Jess Demo') rather than the 'Jess' passed here. + expect(topClients).toEqual([ + { EndUserId: jess.Id, Name: jess.Name, Email: 'jess@example.com', Total: 100, Bookings: 2 }, + ]); + }); + + it('outstanding: partial payments listed with paid totals, ordered by balance desc; paid/overpaid, NULL-EstCost, pending, and cancelled excluded', async () => { + const { env } = createTestEnv(); + const partial = await makeBooking(env, TENANT_C, { estCost: 100 }); // owes 60 + await pay(env, TENANT_C, partial, 40); + const unpaid = await makeBooking(env, TENANT_C, { estCost: 300 }); // owes 300 + const paidInFull = await makeBooking(env, TENANT_C, { estCost: 100 }); + await pay(env, TENANT_C, paidInFull, 100); + const overpaid = await makeBooking(env, TENANT_C, { estCost: 100 }); + await pay(env, TENANT_C, overpaid, 120); + await makeBooking(env, TENANT_C, { estCost: null }); // no estimate -> no computable balance + await makeBooking(env, TENANT_C, { estCost: 500, status: 'pending' }); // not confirmed yet + const cancelled = await makeBooking(env, TENANT_C, { estCost: 400 }); + await updateBookingStatus(env.PAWBOOK_DB, TENANT_C, cancelled, 'cancelled'); + const { outstanding } = await getAnalytics(env.PAWBOOK_DB, TENANT_C, TODAY); + expect(outstanding.map((o) => o.BookingId)).toEqual([unpaid, partial]); + expect(outstanding[1]).toMatchObject({ EstCost: 100, PaidTotal: 40 }); + }); + + it('outstanding is tenant-isolated (another tenant sees nothing of TENANT_C)', async () => { + const { env } = createTestEnv(); + await makeBooking(env, TENANT_C, { estCost: 300 }); + const { outstanding } = await getAnalytics(env.PAWBOOK_DB, TENANT_C, TODAY); + expect(outstanding).toHaveLength(1); + // TENANT_B's view contains only its own seeded unpaid booking (seed_ht_board1), never C's. + const other = await getAnalytics(env.PAWBOOK_DB, TENANT_B, TODAY); + expect(other.outstanding.map((o) => o.BookingId)).toEqual(['seed_ht_board1']); + }); +}); diff --git a/server/db/repo.ts b/server/db/repo.ts index df9ec51..7eed51a 100644 --- a/server/db/repo.ts +++ b/server/db/repo.ts @@ -1,4 +1,5 @@ import type { + AnalyticsData, BookingRow, EndUser, EndUserPet, @@ -462,6 +463,92 @@ export async function listPaymentsForBooking( return results; } +/** + * The four earnings aggregates in one round trip (Promise.all over indexed SELECTs — no KV + * caching; revisit only if it measurably drags). `today` ('YYYY-MM-DD', tenant-timezone at the + * route) anchors the 12-month window; months with no payments are zero-filled here in JS. + * Revenue queries count payments regardless of the booking's later status — cash already + * received is real revenue; only `outstanding` filters to confirmed (and skips EstCost IS NULL: + * a booking with no estimate has no computable balance). + */ +export async function getAnalytics( + db: D1Database, + tenantId: string, + today: string, +): Promise { + // Last 12 calendar months ending with today's month, oldest first (e.g. '2025-08'..'2026-07'). + const [y, m] = today.split('-').map(Number); + const months: string[] = []; + for (let i = 11; i >= 0; i--) { + const d = new Date(Date.UTC(y, m - 1 - i, 1)); + months.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`); + } + const windowStart = `${months[0]}-01`; + + const [monthlyRes, byServiceRes, topClientsRes, outstandingRes] = await Promise.all([ + db + .prepare( + `SELECT substr(PaidDate, 1, 7) AS Month, SUM(Amount) AS Total + FROM Payments WHERE TenantId = ? AND PaidDate >= ? + GROUP BY Month`, + ) + .bind(tenantId, windowStart) + .all<{ Month: string; Total: number }>(), + db + .prepare( + `SELECT b.ServiceType AS ServiceType, COALESCE(s.Label, b.ServiceType) AS Label, + SUM(p.Amount) AS Total + FROM Payments p + JOIN BookingRequests b ON b.Id = p.BookingRequestId AND b.TenantId = p.TenantId + LEFT JOIN TenantServices s ON s.TenantId = p.TenantId AND s.ServiceType = b.ServiceType + WHERE p.TenantId = ? + GROUP BY b.ServiceType + ORDER BY Total DESC`, + ) + .bind(tenantId) + .all<{ ServiceType: string; Label: string; Total: number }>(), + db + .prepare( + `SELECT b.EndUserId AS EndUserId, u.Name AS Name, u.Email AS Email, + SUM(p.Amount) AS Total, COUNT(DISTINCT p.BookingRequestId) AS Bookings + FROM Payments p + JOIN BookingRequests b ON b.Id = p.BookingRequestId AND b.TenantId = p.TenantId + LEFT JOIN EndUsers u ON u.Id = b.EndUserId AND u.TenantId = b.TenantId + WHERE p.TenantId = ? AND b.EndUserId IS NOT NULL + GROUP BY b.EndUserId + ORDER BY Total DESC + LIMIT 10`, + ) + .bind(tenantId) + .all(), + db + .prepare( + `SELECT b.Id AS BookingId, u.Name AS Name, u.Email AS Email, + b.ServiceType AS ServiceType, b.StartDate AS StartDate, + b.EstCost AS EstCost, COALESCE(paid.Total, 0) AS PaidTotal + FROM BookingRequests b + LEFT JOIN EndUsers u ON u.Id = b.EndUserId AND u.TenantId = b.TenantId + LEFT JOIN ( + SELECT BookingRequestId, SUM(Amount) AS Total + FROM Payments WHERE TenantId = ? GROUP BY BookingRequestId + ) paid ON paid.BookingRequestId = b.Id + WHERE b.TenantId = ? AND b.Status = 'confirmed' AND b.ServiceType != 'blocked' + AND b.EstCost IS NOT NULL AND COALESCE(paid.Total, 0) < b.EstCost + ORDER BY b.EstCost - COALESCE(paid.Total, 0) DESC`, + ) + .bind(tenantId, tenantId) + .all(), + ]); + + const byMonth = new Map(monthlyRes.results.map((r) => [r.Month, r.Total])); + return { + monthly: months.map((month) => ({ Month: month, Total: byMonth.get(month) ?? 0 })), + byService: byServiceRes.results, + topClients: topClientsRes.results, + outstanding: outstandingRes.results, + }; +} + export async function listProviderConnections( db: D1Database, tenantId: string, diff --git a/server/types.ts b/server/types.ts index 1ab985e..7409e2a 100644 --- a/server/types.ts +++ b/server/types.ts @@ -104,6 +104,29 @@ export type PaymentRow = { CreatedAt: string; }; +/** getAnalytics result: raw PascalCase aggregate rows. monthly is exactly 12 entries, oldest + * month first, zero-filled. The route maps to camelCase and derives the stat tiles in JS. */ +export type AnalyticsData = { + monthly: { Month: string; Total: number }[]; + byService: { ServiceType: string; Label: string; Total: number }[]; + topClients: { + EndUserId: string; + Name: string | null; + Email: string | null; + Total: number; + Bookings: number; + }[]; + outstanding: { + BookingId: string; + Name: string | null; + Email: string | null; + ServiceType: string; + StartDate: string; + EstCost: number; + PaidTotal: number; + }[]; +}; + export type ProviderConnection = { Id: string; TenantId: string; From 3f587cb930fca40181bb0b6f0e821d439015aadf Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 22:00:19 -0400 Subject: [PATCH 05/18] Add admin payment routes and paidTotal on the bookings list Co-Authored-By: Claude Fable 5 --- server/__tests__/payments-routes.test.ts | 171 +++++++++++++++++++++++ server/routes/admin.ts | 69 +++++++++ 2 files changed, 240 insertions(+) create mode 100644 server/__tests__/payments-routes.test.ts diff --git a/server/__tests__/payments-routes.test.ts b/server/__tests__/payments-routes.test.ts new file mode 100644 index 0000000..9f3f3fb --- /dev/null +++ b/server/__tests__/payments-routes.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import app from '../index'; +import { insertBookingRequest, insertPayment, updateBookingStatus } from '../db/repo'; +import { adminHeaders, createTestEnv, TENANT_A, TENANT_B } from './helpers'; + +const makeBooking = ( + env: Env, + tenantId: string, + status: 'pending' | 'confirmed' = 'confirmed', +) => + insertBookingRequest(env.PAWBOOK_DB, tenantId, { + endUserId: null, + serviceType: 'boarding', + startDate: '2030-01-01', + endDate: '2030-01-03', + optionKey: 'standard', + petType: 'dog', + petCount: 1, + estCost: 100, + status, + }); + +const postPayment = async (env: Env, bookingId: string, body: unknown) => + app.request( + `/api/sunny-paws/admin/bookings/${bookingId}/payments`, + { + method: 'POST', + headers: { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + env, + ); + +const goodBody = { amount: 40, method: 'venmo', paidDate: '2026-07-11', note: 'deposit' }; + +describe('admin payment routes', () => { + it('records a payment and returns it with the new paid total', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + await insertPayment(env.PAWBOOK_DB, TENANT_A, { + bookingRequestId: bookingId, + amount: 10, + method: 'cash', + paidDate: '2026-07-01', + note: null, + }); + const res = await postPayment(env, bookingId, goodBody); + expect(res.status).toBe(201); + const body = (await res.json()) as { + payment: { id: string; amount: number; method: string; paidDate: string; note: string | null }; + paidTotal: number; + }; + expect(body.payment).toMatchObject({ + amount: 40, + method: 'venmo', + paidDate: '2026-07-11', + note: 'deposit', + }); + expect(body.paidTotal).toBe(50); + }); + + it('allows recording against a PENDING booking (deposits)', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A, 'pending'); + const res = await postPayment(env, bookingId, goodBody); + expect(res.status).toBe(201); + }); + + it('400s on a non-integer, zero, or missing amount', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + for (const amount of [12.5, 0, -3, '40', undefined]) { + const res = await postPayment(env, bookingId, { ...goodBody, amount }); + expect(res.status).toBe(400); + } + }); + + it('400s on an unknown payment method', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const res = await postPayment(env, bookingId, { ...goodBody, method: 'stripe' }); + expect(res.status).toBe(400); + }); + + it('400s on an impossible calendar date (isRealDate, not just the regex)', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + for (const paidDate of ['2026-02-30', '2026-13-01', 'not-a-date', undefined]) { + const res = await postPayment(env, bookingId, { ...goodBody, paidDate }); + expect(res.status).toBe(400); + } + }); + + it('404s recording against a cancelled booking', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + await updateBookingStatus(env.PAWBOOK_DB, TENANT_A, bookingId, 'cancelled'); + expect((await postPayment(env, bookingId, goodBody)).status).toBe(404); + }); + + it('404s recording against a blocked sentinel row', async () => { + const { env } = createTestEnv(); + const blockedId = await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, { + endUserId: null, + serviceType: 'blocked', + startDate: '2030-02-01', + endDate: '2030-02-03', + optionKey: null, + petType: null, + petCount: 1, + estCost: null, + status: 'confirmed', + }); + expect((await postPayment(env, blockedId, goodBody)).status).toBe(404); + }); + + it("404s recording against another tenant's booking", async () => { + const { env } = createTestEnv(); + const foreignId = await makeBooking(env, TENANT_B); + expect((await postPayment(env, foreignId, goodBody)).status).toBe(404); + }); + + it('lists a booking\'s payments', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + await postPayment(env, bookingId, goodBody); + const res = await app.request( + `/api/sunny-paws/admin/bookings/${bookingId}/payments`, + { headers: await adminHeaders(TENANT_A) }, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { payments: { amount: number; method: string }[] }; + expect(body.payments).toHaveLength(1); + expect(body.payments[0]).toMatchObject({ amount: 40, method: 'venmo' }); + }); + + it('deletes a payment (204), 404s on repeat and on a booking/payment mismatch', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const otherBookingId = await makeBooking(env, TENANT_A); + const created = (await (await postPayment(env, bookingId, goodBody)).json()) as { + payment: { id: string }; + }; + const del = async (booking: string, payment: string) => + app.request( + `/api/sunny-paws/admin/bookings/${booking}/payments/${payment}`, + { method: 'DELETE', headers: await adminHeaders(TENANT_A) }, + env, + ); + // Mismatched booking id in the URL: refused, nothing deleted. + expect((await del(otherBookingId, created.payment.id)).status).toBe(404); + expect((await del(bookingId, created.payment.id)).status).toBe(204); + expect((await del(bookingId, created.payment.id)).status).toBe(404); + }); + + it('the bookings list carries paidTotal', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + await postPayment(env, bookingId, goodBody); + const res = await app.request( + '/api/sunny-paws/admin/bookings', + { headers: await adminHeaders(TENANT_A) }, + env, + ); + const body = (await res.json()) as { bookings: { id: string; paidTotal: number }[] }; + expect(body.bookings.find((b) => b.id === bookingId)?.paidTotal).toBe(40); + // Seeded unpaid booking reports 0, not null/undefined. + expect(body.bookings.find((b) => b.id === 'seed_sp_board1')?.paidTotal).toBe(0); + }); +}); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 54b4916..6b7f6ae 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -12,14 +12,17 @@ import { getEndUserByEmail, deleteBlockedRange, deleteCustomer, + deletePayment, deleteService, getProviderConnection, insertBookingRequest, insertInvitedCustomer, + insertPayment, listAllEndUserPetsByTenant, listBlockedRanges, listBookingsForTenant, listCustomers, + listPaymentsForBooking, listPetTypes, listProviderConnections, listServiceOptions, @@ -58,6 +61,7 @@ import { DEFENSIVE_MAX_PET_COUNT, EMAIL_RE, isNullableLimit, + isPaymentMethod, isRealDate, isValidDuration, isValidRate, @@ -744,6 +748,7 @@ export const adminRoutes = new Hono() optionKey: r.OptionKey, petCount: r.PetCount, estCost: r.EstCost, + paidTotal: r.PaidTotal ?? 0, status: r.Declined ? 'declined' : r.Status, createdAt: r.CreatedAt, })), @@ -783,4 +788,68 @@ export const adminRoutes = new Hono() } } return c.json({ status, notified }); + }) + + .post('/:slug/admin/bookings/:id/payments', async (c) => { + const tenant = c.get('tenant'); + const bookingId = c.req.param('id'); + const body = await c.req + .json<{ amount?: unknown; method?: unknown; paidDate?: unknown; note?: unknown }>() + .catch(() => ({}) as Record); + if (!isValidRate(body.amount)) + return c.json({ error: 'Amount must be whole dollars ≥ 1.' }, 400); + if (!isPaymentMethod(body.method)) return c.json({ error: 'Unknown payment method.' }, 400); + if (typeof body.paidDate !== 'string' || !isRealDate(body.paidDate)) + return c.json({ error: 'Invalid payment date.' }, 400); + const note = typeof body.note === 'string' && body.note.trim() !== '' ? body.note.trim() : null; + const paymentId = await insertPayment(c.env.PAWBOOK_DB, tenant.Id, { + bookingRequestId: bookingId, + amount: body.amount, + method: body.method, + paidDate: body.paidDate, + note, + }); + // Guard refused: foreign, blocked, or cancelled booking (pending is deliberately allowed). + if (!paymentId) return c.json({ error: 'Not found.' }, 404); + const payments = await listPaymentsForBooking(c.env.PAWBOOK_DB, tenant.Id, bookingId); + const created = payments.find((p) => p.Id === paymentId)!; + return c.json( + { + payment: { + id: created.Id, + amount: created.Amount, + method: created.Method, + paidDate: created.PaidDate, + note: created.Note, + }, + paidTotal: payments.reduce((sum, p) => sum + p.Amount, 0), + }, + 201, + ); + }) + + .get('/:slug/admin/bookings/:id/payments', async (c) => { + const tenant = c.get('tenant'); + const rows = await listPaymentsForBooking(c.env.PAWBOOK_DB, tenant.Id, c.req.param('id')); + return c.json({ + payments: rows.map((p) => ({ + id: p.Id, + amount: p.Amount, + method: p.Method, + paidDate: p.PaidDate, + note: p.Note, + })), + }); + }) + + .delete('/:slug/admin/bookings/:id/payments/:paymentId', async (c) => { + const tenant = c.get('tenant'); + const deleted = await deletePayment( + c.env.PAWBOOK_DB, + tenant.Id, + c.req.param('id'), + c.req.param('paymentId'), + ); + if (!deleted) return c.json({ error: 'Not found.' }, 404); + return c.body(null, 204); }); From 7ffee54b32b74194eb9f601e678054e22213ceb8 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 22:29:01 -0400 Subject: [PATCH 06/18] Add admin analytics route with earnings tiles Co-Authored-By: Claude Fable 5 --- server/__tests__/analytics.test.ts | 86 ++++++++++++++++++++++++++++++ server/routes/admin.ts | 42 +++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/server/__tests__/analytics.test.ts b/server/__tests__/analytics.test.ts index ec091c1..ceed2e2 100644 --- a/server/__tests__/analytics.test.ts +++ b/server/__tests__/analytics.test.ts @@ -7,6 +7,9 @@ import { updateBookingStatus, } from '../db/repo'; import { createTestEnv, TENANT_A, TENANT_B } from './helpers'; +import app from '../index'; +import { getPacificDateStr } from '../../src/shared/index.js'; +import { adminHeaders } from './helpers'; // Seeded clean-slate tenant (sql/seed.sql): has customers but NO bookings, so outstanding // assertions can be exact. TENANT_A/B each carry a seeded confirmed unpaid booking. @@ -159,3 +162,86 @@ describe('getAnalytics (repo)', () => { expect(other.outstanding.map((o) => o.BookingId)).toEqual(['seed_ht_board1']); }); }); + +describe('GET /:slug/admin/analytics (route)', () => { + // paws-and-relax has no seeded bookings and a NULL Timezone, so the route's "today" + // (getPacificDateStr default) matches what these tests compute. + const SLUG_C = 'paws-and-relax'; + + const getAnalyticsRoute = async (env: Env) => + app.request(`/api/${SLUG_C}/admin/analytics`, { headers: await adminHeaders(TENANT_C) }, env); + + it('401s without a token', async () => { + const { env } = createTestEnv(); + const res = await app.request(`/api/${SLUG_C}/admin/analytics`, {}, env); + expect(res.status).toBe(401); + }); + + it('returns an all-zero payload for a tenant with no payments', async () => { + const { env } = createTestEnv(); + const res = await getAnalyticsRoute(env); + expect(res.status).toBe(200); + const body = (await res.json()) as { + tiles: { thisMonth: number; lastMonth: number; outstandingTotal: number; outstandingCount: number }; + monthly: { month: string; total: number }[]; + byService: unknown[]; + topClients: unknown[]; + outstanding: unknown[]; + }; + expect(body.tiles).toEqual({ + thisMonth: 0, + lastMonth: 0, + outstandingTotal: 0, + outstandingCount: 0, + }); + expect(body.monthly).toHaveLength(12); + expect(body.monthly.every((m) => m.total === 0)).toBe(true); + expect(body.monthly[11].month).toBe(getPacificDateStr().slice(0, 7)); + expect(body.byService).toEqual([]); + expect(body.topClients).toEqual([]); + expect(body.outstanding).toEqual([]); + }); + + it('derives tiles in JS and maps every aggregate to camelCase', async () => { + const { env } = createTestEnv(); + const jess = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_C, 'jess@example.com', 'Jess'); + const bookingId = await makeBooking(env, TENANT_C, { endUserId: jess.Id, estCost: 300 }); + const today = getPacificDateStr(); + await pay(env, TENANT_C, bookingId, 100, today); + // A payment dated inside LAST month, for the lastMonth tile. + const [ty, tm] = today.split('-').map(Number); + const prev = new Date(Date.UTC(ty, tm - 2, 15)); + const lastMonthDate = `${prev.getUTCFullYear()}-${String(prev.getUTCMonth() + 1).padStart(2, '0')}-15`; + await pay(env, TENANT_C, bookingId, 60, lastMonthDate); + const body = (await (await getAnalyticsRoute(env)).json()) as { + tiles: { thisMonth: number; lastMonth: number; outstandingTotal: number; outstandingCount: number }; + monthly: { month: string; total: number }[]; + byService: { serviceType: string; label: string; total: number }[]; + topClients: { endUserId: string; name: string | null; email: string | null; total: number; bookings: number }[]; + outstanding: { bookingId: string; estCost: number; paidTotal: number; balance: number }[]; + }; + expect(body.tiles).toEqual({ + thisMonth: 100, + lastMonth: 60, + outstandingTotal: 140, // 300 est - 160 paid + outstandingCount: 1, + }); + expect(body.monthly[11]).toEqual({ month: today.slice(0, 7), total: 100 }); + expect(body.byService).toEqual([{ serviceType: 'boarding', label: 'Boarding', total: 160 }]); + expect(body.topClients).toEqual([ + { endUserId: jess.Id, name: jess.Name, email: 'jess@example.com', total: 160, bookings: 1 }, + ]); + expect(body.outstanding).toEqual([ + { + bookingId, + name: jess.Name, + email: 'jess@example.com', + serviceType: 'boarding', + startDate: '2030-01-01', + estCost: 300, + paidTotal: 160, + balance: 140, + }, + ]); + }); +}); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 6b7f6ae..c5c8260 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -7,6 +7,7 @@ import { countBookingsForService, countBookingsForUser, createService, + getAnalytics, getBookingWithCustomer, getEndUserById, getEndUserByEmail, @@ -68,6 +69,7 @@ import { } from '../lib/validation'; import type { AppEnv } from '../types'; import type { ServiceQuestion } from '../../src/shared/index.js'; +import { getPacificDateStr } from '../../src/shared/index.js'; const COLOR_RE = /^#[0-9a-fA-F]{6}$/; @@ -852,4 +854,44 @@ export const adminRoutes = new Hono() ); if (!deleted) return c.json({ error: 'Not found.' }, 404); return c.body(null, 204); + }) + + // Earnings dashboard payload. All aggregation is SQL (getAnalytics); the tiles are derived + // here in JS from the aggregates — no extra queries, no KV caching (prototype-scale D1). + .get('/:slug/admin/analytics', async (c) => { + const tenant = c.get('tenant'); + const today = getPacificDateStr(undefined, tenant.Timezone ?? undefined); + const data = await getAnalytics(c.env.PAWBOOK_DB, tenant.Id, today); + const outstanding = data.outstanding.map((o) => ({ + bookingId: o.BookingId, + name: o.Name, + email: o.Email, + serviceType: o.ServiceType, + startDate: o.StartDate, + estCost: o.EstCost, + paidTotal: o.PaidTotal, + balance: o.EstCost - o.PaidTotal, + })); + return c.json({ + tiles: { + thisMonth: data.monthly.at(-1)?.Total ?? 0, + lastMonth: data.monthly.at(-2)?.Total ?? 0, + outstandingTotal: outstanding.reduce((sum, o) => sum + o.balance, 0), + outstandingCount: outstanding.length, + }, + monthly: data.monthly.map((m) => ({ month: m.Month, total: m.Total })), + byService: data.byService.map((s) => ({ + serviceType: s.ServiceType, + label: s.Label, + total: s.Total, + })), + topClients: data.topClients.map((t) => ({ + endUserId: t.EndUserId, + name: t.Name, + email: t.Email, + total: t.Total, + bookings: t.Bookings, + })), + outstanding, + }); }); From 3bb7aa0a550d8db1d2929ceecb6cfa29556b8452 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 22:35:10 -0400 Subject: [PATCH 07/18] Pin seeded customer name literal in analytics tests Co-Authored-By: Claude Fable 5 --- server/__tests__/analytics.test.ts | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/server/__tests__/analytics.test.ts b/server/__tests__/analytics.test.ts index ceed2e2..9be5229 100644 --- a/server/__tests__/analytics.test.ts +++ b/server/__tests__/analytics.test.ts @@ -119,6 +119,7 @@ describe('getAnalytics (repo)', () => { it('topClients: two payments on one booking count as ONE booking; two bookings as two', async () => { const { env } = createTestEnv(); + // jess@example.com is pre-seeded (eu_pr_jess, 'Jess Demo', active); insertInvitedCustomer is idempotent and keeps the seeded row. const jess = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_C, 'jess@example.com', 'Jess'); const b1 = await makeBooking(env, TENANT_C, { endUserId: jess.Id }); const b2 = await makeBooking(env, TENANT_C, { endUserId: jess.Id }); @@ -126,11 +127,8 @@ describe('getAnalytics (repo)', () => { await pay(env, TENANT_C, b1, 20); await pay(env, TENANT_C, b2, 50); const { topClients } = await getAnalytics(env.PAWBOOK_DB, TENANT_C, TODAY); - // Name asserted via jess.Name (not a literal): tnt_pawsandrelax seeds an active customer at - // this exact email (sql/seed.sql), and insertInvitedCustomer is idempotent — it returns the - // existing seeded record ('Jess Demo') rather than the 'Jess' passed here. expect(topClients).toEqual([ - { EndUserId: jess.Id, Name: jess.Name, Email: 'jess@example.com', Total: 100, Bookings: 2 }, + { EndUserId: jess.Id, Name: 'Jess Demo', Email: 'jess@example.com', Total: 100, Bookings: 2 }, ]); }); @@ -182,7 +180,12 @@ describe('GET /:slug/admin/analytics (route)', () => { const res = await getAnalyticsRoute(env); expect(res.status).toBe(200); const body = (await res.json()) as { - tiles: { thisMonth: number; lastMonth: number; outstandingTotal: number; outstandingCount: number }; + tiles: { + thisMonth: number; + lastMonth: number; + outstandingTotal: number; + outstandingCount: number; + }; monthly: { month: string; total: number }[]; byService: unknown[]; topClients: unknown[]; @@ -204,6 +207,7 @@ describe('GET /:slug/admin/analytics (route)', () => { it('derives tiles in JS and maps every aggregate to camelCase', async () => { const { env } = createTestEnv(); + // jess@example.com is pre-seeded (eu_pr_jess, 'Jess Demo', active); insertInvitedCustomer is idempotent and keeps the seeded row. const jess = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_C, 'jess@example.com', 'Jess'); const bookingId = await makeBooking(env, TENANT_C, { endUserId: jess.Id, estCost: 300 }); const today = getPacificDateStr(); @@ -214,10 +218,21 @@ describe('GET /:slug/admin/analytics (route)', () => { const lastMonthDate = `${prev.getUTCFullYear()}-${String(prev.getUTCMonth() + 1).padStart(2, '0')}-15`; await pay(env, TENANT_C, bookingId, 60, lastMonthDate); const body = (await (await getAnalyticsRoute(env)).json()) as { - tiles: { thisMonth: number; lastMonth: number; outstandingTotal: number; outstandingCount: number }; + tiles: { + thisMonth: number; + lastMonth: number; + outstandingTotal: number; + outstandingCount: number; + }; monthly: { month: string; total: number }[]; byService: { serviceType: string; label: string; total: number }[]; - topClients: { endUserId: string; name: string | null; email: string | null; total: number; bookings: number }[]; + topClients: { + endUserId: string; + name: string | null; + email: string | null; + total: number; + bookings: number; + }[]; outstanding: { bookingId: string; estCost: number; paidTotal: number; balance: number }[]; }; expect(body.tiles).toEqual({ @@ -229,12 +244,12 @@ describe('GET /:slug/admin/analytics (route)', () => { expect(body.monthly[11]).toEqual({ month: today.slice(0, 7), total: 100 }); expect(body.byService).toEqual([{ serviceType: 'boarding', label: 'Boarding', total: 160 }]); expect(body.topClients).toEqual([ - { endUserId: jess.Id, name: jess.Name, email: 'jess@example.com', total: 160, bookings: 1 }, + { endUserId: jess.Id, name: 'Jess Demo', email: 'jess@example.com', total: 160, bookings: 1 }, ]); expect(body.outstanding).toEqual([ { bookingId, - name: jess.Name, + name: 'Jess Demo', email: 'jess@example.com', serviceType: 'boarding', startDate: '2030-01-01', From 9beef2176ca375871bfbe5ae2b9b858faa4f5344 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 22:38:24 -0400 Subject: [PATCH 08/18] Add payments API client and shared PaymentsPanel Co-Authored-By: Claude Fable 5 --- app/admin/PaymentsPanel.tsx | 142 ++++++++++++++++++++++++++++++++++++ app/admin/admin.css | 12 +++ app/shared-ui/api.ts | 77 +++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 app/admin/PaymentsPanel.tsx diff --git a/app/admin/PaymentsPanel.tsx b/app/admin/PaymentsPanel.tsx new file mode 100644 index 0000000..ab6e3b0 --- /dev/null +++ b/app/admin/PaymentsPanel.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { adminApi, PAYMENT_METHODS, type Payment } from '../shared-ui/api.js'; +import type { Session } from './shared.js'; + +/** Local 'YYYY-MM-DD' default for the paid-date field (the sitter can change it). */ +function todayStr(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** + * One booking's payment ledger: existing payments (each deletable — deleting the record is the + * only correction mechanism) plus the record-payment form. Shared by BookingsSection (rows) and + * EarningsSection (outstanding table); `onChanged` lets each parent re-fetch its own payload. + */ +export function PaymentsPanel({ + session, + bookingId, + onChanged, + handleError, +}: { + session: Session; + bookingId: string; + onChanged: () => void | Promise; + handleError: (e: unknown) => void; +}) { + const [payments, setPayments] = useState(null); + const [amount, setAmount] = useState(''); + const [method, setMethod] = useState('cash'); + const [paidDate, setPaidDate] = useState(todayStr); + const [note, setNote] = useState(''); + const [busy, setBusy] = useState(false); + + const load = () => + adminApi.payments + .list(session.slug, session.token, bookingId) + .then(({ payments: list }) => list); + + useEffect(() => { + let active = true; + load() + .then((list) => active && setPayments(list)) + .catch((e) => active && handleError(e)); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookingId, session]); + + const record = async () => { + if (busy) return; + setBusy(true); + try { + await adminApi.payments.record(session.slug, session.token, bookingId, { + amount: Number(amount), + method, + paidDate, + ...(note.trim() ? { note: note.trim() } : {}), + }); + setAmount(''); + setNote(''); + setPaidDate(todayStr()); + setPayments(await load()); + await onChanged(); + } catch (e) { + handleError(e); + } finally { + setBusy(false); + } + }; + + const remove = async (paymentId: string) => { + if (busy) return; + setBusy(true); + try { + await adminApi.payments.remove(session.slug, session.token, bookingId, paymentId); + setPayments(await load()); + await onChanged(); + } catch (e) { + handleError(e); + } finally { + setBusy(false); + } + }; + + return ( +
+ {payments === null ? ( +

Loading…

+ ) : payments.length === 0 ? ( +

No payments recorded yet.

+ ) : ( +
    + {payments.map((p) => ( +
  • + + ${p.amount} · {p.method} · {p.paidDate} + {p.note ? ` — ${p.note}` : ''} + + +
  • + ))} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/app/admin/admin.css b/app/admin/admin.css index 10d1612..5344e21 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -538,6 +538,18 @@ body { margin-top: 4px; } +/* ── Payments panel (shared by Bookings rows + Earnings outstanding table) ── */ +.pb-payments { + width: 100%; + border-top: 1px dashed var(--line); + margin-top: 8px; + padding-top: 8px; +} + +.pb-payments .pb-row { + align-items: flex-end; +} + @media (prefers-reduced-motion: reduce) { .pb-wrap button { transition: none; diff --git a/app/shared-ui/api.ts b/app/shared-ui/api.ts index 8b0c5e2..b39b6db 100644 --- a/app/shared-ui/api.ts +++ b/app/shared-ui/api.ts @@ -94,10 +94,58 @@ export type AdminBooking = { optionKey: string | null; petCount: number; estCost: number | null; + paidTotal: number; status: string; createdAt: string; }; +export type Payment = { + id: string; + amount: number; + method: string; + paidDate: string; + note: string | null; +}; + +/** Frontend copy of the server's whitelist (server/lib/validation.ts) — keep in lockstep. */ +export const PAYMENT_METHODS = [ + 'cash', + 'venmo', + 'zelle', + 'paypal', + 'check', + 'card', + 'other', +] as const; + +export type AnalyticsPayload = { + tiles: { + thisMonth: number; + lastMonth: number; + outstandingTotal: number; + outstandingCount: number; + }; + monthly: { month: string; total: number }[]; + byService: { serviceType: string; label: string; total: number }[]; + topClients: { + endUserId: string; + name: string | null; + email: string | null; + total: number; + bookings: number; + }[]; + outstanding: { + bookingId: string; + name: string | null; + email: string | null; + serviceType: string; + startDate: string; + estCost: number; + paidTotal: number; + balance: number; + }[]; +}; + export class ApiError extends Error { constructor( public status: number, @@ -234,6 +282,35 @@ export const adminApi = { body: JSON.stringify({ status }), }), }, + payments: { + list: (slug: string, token: string, bookingId: string) => + request<{ payments: Payment[] }>(`/api/${slug}/admin/bookings/${bookingId}/payments`, { + headers: authHeaders(token), + }), + record: ( + slug: string, + token: string, + bookingId: string, + body: { amount: number; method: string; paidDate: string; note?: string }, + ) => + request<{ payment: Payment; paidTotal: number }>( + `/api/${slug}/admin/bookings/${bookingId}/payments`, + { + method: 'POST', + headers: { ...jsonHeaders, ...authHeaders(token) }, + body: JSON.stringify(body), + }, + ), + remove: (slug: string, token: string, bookingId: string, paymentId: string) => + request(`/api/${slug}/admin/bookings/${bookingId}/payments/${paymentId}`, { + method: 'DELETE', + headers: authHeaders(token), + }), + }, + analytics: { + get: (slug: string, token: string) => + request(`/api/${slug}/admin/analytics`, { headers: authHeaders(token) }), + }, calendar: { start: (slug: string, token: string) => request<{ url: string }>(`/api/${slug}/admin/providers/calendar/oauth/start`, { From b08caca5e5505672f3d6fdef2b820de77477832a Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 22:45:35 -0400 Subject: [PATCH 09/18] Guard payment form submit against invalid amount/date Add client-side validation to prevent submission of zero/negative amounts or empty dates. Validates amount is a positive integer and date is non-empty. Co-Authored-By: Claude Fable 5 --- app/admin/PaymentsPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/admin/PaymentsPanel.tsx b/app/admin/PaymentsPanel.tsx index ab6e3b0..d234a81 100644 --- a/app/admin/PaymentsPanel.tsx +++ b/app/admin/PaymentsPanel.tsx @@ -31,6 +31,9 @@ export function PaymentsPanel({ const [note, setNote] = useState(''); const [busy, setBusy] = useState(false); + const amountNum = Number(amount); + const canSubmit = Number.isInteger(amountNum) && amountNum >= 1 && paidDate.trim() !== ''; + const load = () => adminApi.payments .list(session.slug, session.token, bookingId) @@ -133,7 +136,7 @@ export function PaymentsPanel({ Note setNote(e.target.value)} /> - From dd408a90fba6d6ca8afb6904d2dd90342ee3a01a Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sat, 11 Jul 2026 22:48:29 -0400 Subject: [PATCH 10/18] Show payment state and payments panel in Bookings section Co-Authored-By: Claude Fable 5 --- app/admin/sections/BookingsSection.tsx | 51 ++++++++++++++++++++------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/app/admin/sections/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx index 3c21886..bc3cb03 100644 --- a/app/admin/sections/BookingsSection.tsx +++ b/app/admin/sections/BookingsSection.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { adminApi, type AdminBooking } from '../../shared-ui/api.js'; import { IconClipboardCheck } from '../../shared-ui/icons'; +import { PaymentsPanel } from '../PaymentsPanel'; import type { Session } from '../shared.js'; /** Renders the dates for one row: single date (+ time, for timed services) or a range. */ @@ -18,6 +19,14 @@ function chipClass(status: string): string { return ''; } +/** Payment state for a live row; null for cancelled/declined (money display would mislead) and + * for unpaid estimate-less rows. 'paid in full' covers overpayment/tips (paidTotal > estCost). */ +function paidText(b: AdminBooking): string | null { + if (b.status === 'cancelled' || b.status === 'declined') return null; + if (b.estCost == null) return b.paidTotal > 0 ? `paid $${b.paidTotal}` : null; + return b.paidTotal >= b.estCost ? 'paid in full' : `paid $${b.paidTotal} of $${b.estCost}`; +} + export function BookingsSection({ session, handleError, @@ -29,6 +38,7 @@ export function BookingsSection({ }) { const [bookings, setBookings] = useState(null); const [busyId, setBusyId] = useState(null); + const [openId, setOpenId] = useState(null); const [message, setMessage] = useState(''); const load = () => @@ -98,21 +108,38 @@ export function BookingsSection({ Cancel )} + {b.status !== 'cancelled' && b.status !== 'declined' && ( + + )} ); - const row = (b: AdminBooking) => ( -
  • - - {b.customerName || b.customerEmail || 'Unknown customer'} — {b.type} -
    - {formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'} - {b.estCost != null ? ` · $${b.estCost}` : ''}{' '} - {b.status} -
    - {actionsFor(b)} -
  • - ); + const row = (b: AdminBooking) => { + const paid = paidText(b); + return ( +
  • + + {b.customerName || b.customerEmail || 'Unknown customer'} — {b.type} +
    + {formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'} + {b.estCost != null ? ` · $${b.estCost}` : ''}{' '} + {b.status} + {paid && <> · {paid}} +
    + {actionsFor(b)} + {openId === b.id && ( + setBookings(await load())} + handleError={handleError} + /> + )} +
  • + ); + }; const pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate); const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate); From 28052abc4ca70da3087202228a9058c59fc94b65 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 08:20:36 -0400 Subject: [PATCH 11/18] Hide zero paid text and gate payments panel on row status Fix 1: paidText now returns null when paidTotal is 0 (no paid text shown until a payment exists). Fix 2: PaymentsPanel mount now gated on row status (cancelled/declined), matching the toggle button condition to prevent stuck-open panels. Co-Authored-By: Claude Fable 5 --- app/admin/sections/BookingsSection.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/admin/sections/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx index bc3cb03..d5ff88b 100644 --- a/app/admin/sections/BookingsSection.tsx +++ b/app/admin/sections/BookingsSection.tsx @@ -23,6 +23,7 @@ function chipClass(status: string): string { * for unpaid estimate-less rows. 'paid in full' covers overpayment/tips (paidTotal > estCost). */ function paidText(b: AdminBooking): string | null { if (b.status === 'cancelled' || b.status === 'declined') return null; + if (b.paidTotal === 0) return null; if (b.estCost == null) return b.paidTotal > 0 ? `paid $${b.paidTotal}` : null; return b.paidTotal >= b.estCost ? 'paid in full' : `paid $${b.paidTotal} of $${b.estCost}`; } @@ -129,7 +130,7 @@ export function BookingsSection({ {paid && <> · {paid}} {actionsFor(b)} - {openId === b.id && ( + {b.status !== 'cancelled' && b.status !== 'declined' && openId === b.id && ( Date: Sun, 12 Jul 2026 08:31:51 -0400 Subject: [PATCH 12/18] Add Earnings dashboard section Co-Authored-By: Claude Fable 5 --- app/admin/App.tsx | 7 + app/admin/admin.css | 60 +++++++ app/admin/sections/EarningsSection.tsx | 207 +++++++++++++++++++++++++ app/shared-ui/icons.tsx | 11 ++ 4 files changed, 285 insertions(+) create mode 100644 app/admin/sections/EarningsSection.tsx diff --git a/app/admin/App.tsx b/app/admin/App.tsx index 85cc515..460e458 100644 --- a/app/admin/App.tsx +++ b/app/admin/App.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } fro import { adminApi, isAuthExpired, type Customer, type ImportResult } from '../shared-ui/api.js'; import { IconCalendar, + IconChartBar, IconClipboardCheck, IconCode, IconPaw, @@ -14,6 +15,7 @@ import { AppsSection } from './sections/AppsSection'; import { BookingsSection } from './sections/BookingsSection'; import { BusinessSection } from './sections/BusinessSection'; import { ClientsSection } from './sections/ClientsSection'; +import { EarningsSection } from './sections/EarningsSection'; import { EmbedSection } from './sections/EmbedSection'; import { PetsSection } from './sections/PetsSection'; import { ServicesSection } from './sections/ServicesSection'; @@ -120,6 +122,7 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) { type SectionKey = | 'bookings' + | 'earnings' | 'business' | 'pets' | 'services' @@ -130,6 +133,7 @@ type SectionKey = const SECTIONS: { key: SectionKey; label: string; icon: typeof IconStore }[] = [ { key: 'bookings', label: 'Bookings', icon: IconClipboardCheck }, + { key: 'earnings', label: 'Earnings', icon: IconChartBar }, { key: 'business', label: 'Business', icon: IconStore }, { key: 'pets', label: 'Pets', icon: IconPaw }, { key: 'services', label: 'Services & rates', icon: IconTag }, @@ -402,6 +406,9 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => bookings: ( setError('')} /> ), + earnings: ( + setError('')} /> + ), business: , pets: , services: ( diff --git a/app/admin/admin.css b/app/admin/admin.css index 5344e21..69ae5c9 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -563,3 +563,63 @@ body { font-size: 16px; /* prevents iOS zoom-on-focus */ } } + +/* ── Earnings section ──────────────────────────────────────────── */ +.pb-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; + margin: 16px 0; +} + +.pb-tile { + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; +} + +.pb-tile strong { + font-size: 1.4rem; + color: var(--ink); +} + +.pb-tile span { + color: var(--soft); + font-size: 0.85rem; +} + +.pb-earnings-chart { + width: 100%; + max-width: 480px; + display: block; +} + +.pb-earnings-chart rect { + fill: var(--sage); +} + +.pb-earnings-chart text { + fill: var(--soft); +} + +.pb-hbars li { + display: grid; + grid-template-columns: 140px 1fr 60px; + align-items: center; + gap: 10px; +} + +.pb-hbar { + background: var(--sage-wash); + border-radius: 4px; + height: 12px; +} + +.pb-hbar-fill { + background: var(--sage); + border-radius: 4px; + height: 100%; + min-width: 2px; +} diff --git a/app/admin/sections/EarningsSection.tsx b/app/admin/sections/EarningsSection.tsx new file mode 100644 index 0000000..6b3c863 --- /dev/null +++ b/app/admin/sections/EarningsSection.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; +import { adminApi, type AnalyticsPayload } from '../../shared-ui/api.js'; +import { IconChartBar } from '../../shared-ui/icons'; +import { PaymentsPanel } from '../PaymentsPanel'; +import type { Session } from '../shared.js'; + +const MONTH_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/** '2026-07' → 'Jul 26'. */ +function monthLabel(month: string): string { + const [y, m] = month.split('-'); + return `${MONTH_NAMES[Number(m) - 1]} ${y.slice(2)}`; +} + +/** Hand-rolled 12-bar SVG chart — no chart library (see the design's non-goals). */ +function MonthlyChart({ monthly }: { monthly: AnalyticsPayload['monthly'] }) { + const max = Math.max(1, ...monthly.map((m) => m.total)); + const barW = 22; + const gap = 8; + const chartH = 110; + const width = monthly.length * (barW + gap) - gap; + return ( + + {monthly.map((m, i) => { + const h = m.total === 0 ? 0 : Math.max(2, Math.round((m.total / max) * (chartH - 16))); + const x = i * (barW + gap); + return ( + + {m.total > 0 && ( + + ${m.total} + + )} + + + {monthLabel(m.month)} + + + ); + })} + + ); +} + +export function EarningsSection({ + session, + handleError, + clearError, +}: { + session: Session; + handleError: (e: unknown) => void; + clearError: () => void; +}) { + const [data, setData] = useState(null); + const [openId, setOpenId] = useState(null); + + const load = () => adminApi.analytics.get(session.slug, session.token); + + useEffect(() => { + let active = true; + load() + .then((d) => active && setData(d)) + .catch((e) => active && handleError(e)); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + const reload = async () => { + clearError(); + try { + setData(await load()); + } catch (e) { + handleError(e); + } + }; + + if (data === null) + return ( + <> +

    + Earnings +

    +

    Loading…

    + + ); + + const hasPayments = data.byService.length > 0; + const maxService = Math.max(1, ...data.byService.map((s) => s.total)); + + return ( + <> +

    + Earnings +

    + +
    +
    + ${data.tiles.thisMonth} + This month +
    +
    + ${data.tiles.lastMonth} + Last month +
    +
    + ${data.tiles.outstandingTotal} + Outstanding +
    +
    + {data.tiles.outstandingCount} + Unpaid bookings +
    +
    + +

    Revenue over time

    + {hasPayments ? ( + + ) : ( +

    No payments recorded yet.

    + )} + +

    By service (all-time)

    + {data.byService.length === 0 ? ( +

    No payments recorded yet.

    + ) : ( +
      + {data.byService.map((s) => ( +
    • + {s.label} +
      +
      +
      + ${s.total} +
    • + ))} +
    + )} + +

    Top clients (all-time)

    + {data.topClients.length === 0 ? ( +

    No payments recorded yet.

    + ) : ( +
      + {data.topClients.map((t) => ( +
    • + {t.name || t.email || 'Unknown client'} + + ${t.total} · {t.bookings} booking{t.bookings === 1 ? '' : 's'} + +
    • + ))} +
    + )} + +

    Outstanding balances

    + {data.outstanding.length === 0 ? ( +

    Every confirmed booking is fully paid.

    + ) : ( +
      + {data.outstanding.map((o) => ( +
    • + + {o.name || o.email || 'Unknown client'} — {o.serviceType} ({o.startDate}) +
      + owes ${o.balance} (paid ${o.paidTotal} of ${o.estCost}) +
      + + {openId === o.bookingId && ( + + )} +
    • + ))} +
    + )} + + ); +} diff --git a/app/shared-ui/icons.tsx b/app/shared-ui/icons.tsx index 905ef9b..7df8b4e 100644 --- a/app/shared-ui/icons.tsx +++ b/app/shared-ui/icons.tsx @@ -147,6 +147,17 @@ export function IconStore({ size }: IconProps) { ); } +export function IconChartBar({ size }: IconProps) { + return ( + + + + + + + ); +} + export function IconChevronLeft({ size }: IconProps) { return ( From fc8be92bbd7b8900e5582bb260671f063fc21653 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 08:32:07 -0400 Subject: [PATCH 13/18] Fix Prettier formatting in pre-existing files The final-task verification gate runs `npm run format`, which caught whitespace-only drift left over from earlier tasks; no behavior change. Co-Authored-By: Claude Fable 5 --- app/embed/App.tsx | 3 +-- app/shared-ui/api.ts | 3 +-- .../2026-07-11-earnings-analytics-design.md | 10 +++++----- server/__tests__/payments-routes.test.ts | 16 +++++++++------- server/lib/availability.ts | 3 +-- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/app/embed/App.tsx b/app/embed/App.tsx index e45108d..f859178 100644 --- a/app/embed/App.tsx +++ b/app/embed/App.tsx @@ -76,8 +76,7 @@ function QuestionField({ const slug = window.location.pathname.split('/').filter(Boolean)[1] ?? ''; type IdentifyState = - | { step: 'email' } - | { step: 'code'; codeId: string; prototypeCode?: string; email: string }; + { step: 'email' } | { step: 'code'; codeId: string; prototypeCode?: string; email: string }; function Identify({ onDone }: { onDone: () => void }) { const [state, setState] = useState({ step: 'email' }); diff --git a/app/shared-ui/api.ts b/app/shared-ui/api.ts index b39b6db..bc35f6f 100644 --- a/app/shared-ui/api.ts +++ b/app/shared-ui/api.ts @@ -52,8 +52,7 @@ export type MonthDay = { }; export type Availability = - | { available: true; estCost: number; nights?: number } - | { available: false; reason: string }; + { available: true; estCost: number; nights?: number } | { available: false; reason: string }; export type Booking = { id: string; diff --git a/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md index 6dc346b..1cea751 100644 --- a/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md +++ b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md @@ -8,7 +8,7 @@ Sitters have no view of the money side of their business. The only money-shaped data in the schema is `BookingRequests.EstCost` (an INTEGER -whole-dollar *estimate* computed at booking time) and per-option `Rate` on +whole-dollar _estimate_ computed at booking time) and per-option `Rate` on `TenantServiceOptions`. There is no record of what was actually paid, no paid/unpaid state, no payment processor, and no reporting of any kind — the admin app (`app/admin/App.tsx`) has sections for bookings, clients, services, @@ -51,7 +51,7 @@ recorded so far. - **Invoicing/receipts.** No PDFs, no emails to clients about payments. - **Refunds / cancel-after-payment netting.** Revenue aggregates count payments regardless of the booking's later status — cash already received - is real revenue (only the *outstanding* query filters to confirmed). + is real revenue (only the _outstanding_ query filters to confirmed). There are no negative amounts (`CHECK (Amount > 0)`); deleting the payment record is the only correction mechanism, which is why `deletePayment` has no booking-status guard. Revisit if real refund @@ -116,7 +116,7 @@ New functions, all tenant-scoped like every existing query: — inserts iff the booking exists for this tenant, is not `ServiceType='blocked'`, and is not cancelled. A plain `INSERT` has no `WHERE`, so this is an `INSERT INTO Payments (...) SELECT ... FROM BookingRequests WHERE - TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'` +TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'` — a new idiom for `repo.ts` (no existing guarded insert to copy), atomic like `updateBookingStatus`'s `UPDATE ... WHERE` guard. Returns whether a row was inserted (`meta.changes`). `pending` bookings are **deliberately @@ -162,7 +162,7 @@ stack as every other `/:slug/admin/*` route): - `GET /:slug/admin/analytics` — returns `getAnalytics` payload verbatim. - `GET /:slug/admin/bookings` (existing) — its response-mapping object (`server/routes/admin.ts:~746`, the `status: r.Declined ? 'declined' : - r.Status` block) gains `paidTotal: r.PaidTotal ?? 0` so the repo-layer +r.Status` block) gains `paidTotal: r.PaidTotal ?? 0` so the repo-layer join actually reaches the client. - `POST /:slug/admin/bookings/:id/payments` — body `{ amount, method, paidDate, note? }`. Validates: amount via the existing @@ -234,7 +234,7 @@ Per-concern test files, mirroring the existing convention: - `server/__tests__/payments-repo.test.ts` — `insertPayment` guards (cancelled booking refused, blocked row refused, cross-tenant refused, - pending booking *allowed*, happy path), `deletePayment` tenant scoping + pending booking _allowed_, happy path), `deletePayment` tenant scoping and booking/payment-mismatch refusal (and that it works on a cancelled booking), `listPaymentsForBooking`, paid-total aggregation on `listBookingsForTenant`. diff --git a/server/__tests__/payments-routes.test.ts b/server/__tests__/payments-routes.test.ts index 9f3f3fb..3acc53b 100644 --- a/server/__tests__/payments-routes.test.ts +++ b/server/__tests__/payments-routes.test.ts @@ -3,11 +3,7 @@ import app from '../index'; import { insertBookingRequest, insertPayment, updateBookingStatus } from '../db/repo'; import { adminHeaders, createTestEnv, TENANT_A, TENANT_B } from './helpers'; -const makeBooking = ( - env: Env, - tenantId: string, - status: 'pending' | 'confirmed' = 'confirmed', -) => +const makeBooking = (env: Env, tenantId: string, status: 'pending' | 'confirmed' = 'confirmed') => insertBookingRequest(env.PAWBOOK_DB, tenantId, { endUserId: null, serviceType: 'boarding', @@ -47,7 +43,13 @@ describe('admin payment routes', () => { const res = await postPayment(env, bookingId, goodBody); expect(res.status).toBe(201); const body = (await res.json()) as { - payment: { id: string; amount: number; method: string; paidDate: string; note: string | null }; + payment: { + id: string; + amount: number; + method: string; + paidDate: string; + note: string | null; + }; paidTotal: number; }; expect(body.payment).toMatchObject({ @@ -120,7 +122,7 @@ describe('admin payment routes', () => { expect((await postPayment(env, foreignId, goodBody)).status).toBe(404); }); - it('lists a booking\'s payments', async () => { + it("lists a booking's payments", async () => { const { env } = createTestEnv(); const bookingId = await makeBooking(env, TENANT_A); await postPayment(env, bookingId, goodBody); diff --git a/server/lib/availability.ts b/server/lib/availability.ts index c306d62..1a8dacb 100644 --- a/server/lib/availability.ts +++ b/server/lib/availability.ts @@ -41,8 +41,7 @@ export function rowsToCapacityEvents(rows: CapacityRow[]): CapacityEvent[] { } export type AvailabilityResult = - | { available: true; estCost: number; nights?: number } - | { available: false; reason: string }; + { available: true; estCost: number; nights?: number } | { available: false; reason: string }; /** * The estimated cost of a booking — the ONE place the price formula lives, so the availability From 85179fdcc31338cb3b1b10b3f18eac87919a8006 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 08:40:01 -0400 Subject: [PATCH 14/18] Guard earnings reload against post-unmount state updates Co-Authored-By: Claude Fable 5 --- app/admin/sections/EarningsSection.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/admin/sections/EarningsSection.tsx b/app/admin/sections/EarningsSection.tsx index 6b3c863..a899e13 100644 --- a/app/admin/sections/EarningsSection.tsx +++ b/app/admin/sections/EarningsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { adminApi, type AnalyticsPayload } from '../../shared-ui/api.js'; import { IconChartBar } from '../../shared-ui/icons'; import { PaymentsPanel } from '../PaymentsPanel'; @@ -71,6 +71,7 @@ export function EarningsSection({ }) { const [data, setData] = useState(null); const [openId, setOpenId] = useState(null); + const alive = useRef(true); const load = () => adminApi.analytics.get(session.slug, session.token); @@ -85,12 +86,23 @@ export function EarningsSection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [session]); + useEffect(() => { + return () => { + alive.current = false; + }; + }, []); + const reload = async () => { clearError(); try { - setData(await load()); + const result = await load(); + if (alive.current) { + setData(result); + } } catch (e) { - handleError(e); + if (alive.current) { + handleError(e); + } } }; From 079b7ec8e422cfe702a1e1d054639b303a9614ba Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 08:46:23 -0400 Subject: [PATCH 15/18] Reset alive ref on mount so StrictMode remount keeps earnings reload working Co-Authored-By: Claude Fable 5 --- app/admin/sections/EarningsSection.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/admin/sections/EarningsSection.tsx b/app/admin/sections/EarningsSection.tsx index a899e13..79c0441 100644 --- a/app/admin/sections/EarningsSection.tsx +++ b/app/admin/sections/EarningsSection.tsx @@ -87,6 +87,7 @@ export function EarningsSection({ }, [session]); useEffect(() => { + alive.current = true; return () => { alive.current = false; }; From dbf8b5f11f4155f3174144812a123787fbd07678 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 08:54:57 -0400 Subject: [PATCH 16/18] Address review follow-ups: payments GET 404, analytics reconcile, query bounds Co-Authored-By: Claude Fable 5 --- server/__tests__/payments-routes.test.ts | 19 +++++++++++++++++++ server/db/repo.ts | 14 ++++++++++---- server/lib/validation.ts | 7 ++++++- server/routes/admin.ts | 11 +++++++++-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/server/__tests__/payments-routes.test.ts b/server/__tests__/payments-routes.test.ts index 3acc53b..91314d8 100644 --- a/server/__tests__/payments-routes.test.ts +++ b/server/__tests__/payments-routes.test.ts @@ -137,6 +137,25 @@ describe('admin payment routes', () => { expect(body.payments[0]).toMatchObject({ amount: 40, method: 'venmo' }); }); + it('404s listing payments for a nonexistent booking', async () => { + const { env } = createTestEnv(); + const res = await app.request( + '/api/sunny-paws/admin/bookings/nope/payments', + { headers: await adminHeaders(TENANT_A) }, + env, + ); + expect(res.status).toBe(404); + }); + + it('records a payment with an empty note as null', async () => { + const { env } = createTestEnv(); + const bookingId = await makeBooking(env, TENANT_A); + const res = await postPayment(env, bookingId, { ...goodBody, note: '' }); + expect(res.status).toBe(201); + const body = (await res.json()) as { payment: { note: string | null } }; + expect(body.payment.note).toBeNull(); + }); + it('deletes a payment (204), 404s on repeat and on a booking/payment mismatch', async () => { const { env } = createTestEnv(); const bookingId = await makeBooking(env, TENANT_A); diff --git a/server/db/repo.ts b/server/db/repo.ts index 7eed51a..e2ff66c 100644 --- a/server/db/repo.ts +++ b/server/db/repo.ts @@ -484,16 +484,22 @@ export async function getAnalytics( months.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`); } const windowStart = `${months[0]}-01`; + // Exclusive upper bound: first day of the month AFTER today's month. Without it, a future-dated + // payment (post-dated deposit, clock skew) would be summed into `Total` by SQL then discarded by + // the zero-fill map below since its month key isn't in `months` — silently dropping real revenue + // from the response instead of excluding it up front. + const nextMonth = new Date(Date.UTC(y, m, 1)); + const windowEnd = `${nextMonth.getUTCFullYear()}-${String(nextMonth.getUTCMonth() + 1).padStart(2, '0')}-01`; const [monthlyRes, byServiceRes, topClientsRes, outstandingRes] = await Promise.all([ db .prepare( `SELECT substr(PaidDate, 1, 7) AS Month, SUM(Amount) AS Total - FROM Payments WHERE TenantId = ? AND PaidDate >= ? + FROM Payments WHERE TenantId = ? AND PaidDate >= ? AND PaidDate < ? GROUP BY Month`, ) - .bind(tenantId, windowStart) - .all<{ Month: string; Total: number }>(), + .bind(tenantId, windowStart, windowEnd) + .all(), db .prepare( `SELECT b.ServiceType AS ServiceType, COALESCE(s.Label, b.ServiceType) AS Label, @@ -506,7 +512,7 @@ export async function getAnalytics( ORDER BY Total DESC`, ) .bind(tenantId) - .all<{ ServiceType: string; Label: string; Total: number }>(), + .all(), db .prepare( `SELECT b.EndUserId AS EndUserId, u.Name AS Name, u.Email AS Email, diff --git a/server/lib/validation.ts b/server/lib/validation.ts index 94561a2..a01ab09 100644 --- a/server/lib/validation.ts +++ b/server/lib/validation.ts @@ -92,7 +92,12 @@ export function isValidDuration(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 1; } -/** How a sitter collected money. Mirrors the SQL CHECK on Payments.Method — keep in lockstep. */ +/** + * How a sitter collected money. Mirrors the SQL CHECK on Payments.Method — keep in lockstep. + * Adding/removing a method here requires updating sql/schema.sql's CHECK constraint too (SQLite + * can't ALTER a CHECK, so that's a table-rebuild migration, not a plain column add), plus this + * list's frontend copy in app/shared-ui/api.ts. + */ export const PAYMENT_METHODS = [ 'cash', 'venmo', diff --git a/server/routes/admin.ts b/server/routes/admin.ts index c5c8260..118fd2c 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -814,7 +814,8 @@ export const adminRoutes = new Hono() // Guard refused: foreign, blocked, or cancelled booking (pending is deliberately allowed). if (!paymentId) return c.json({ error: 'Not found.' }, 404); const payments = await listPaymentsForBooking(c.env.PAWBOOK_DB, tenant.Id, bookingId); - const created = payments.find((p) => p.Id === paymentId)!; + const created = payments.find((p) => p.Id === paymentId); + if (!created) return c.json({ error: 'Not found.' }, 404); return c.json( { payment: { @@ -832,7 +833,12 @@ export const adminRoutes = new Hono() .get('/:slug/admin/bookings/:id/payments', async (c) => { const tenant = c.get('tenant'); - const rows = await listPaymentsForBooking(c.env.PAWBOOK_DB, tenant.Id, c.req.param('id')); + const bookingId = c.req.param('id'); + // Same existence guard as POST/DELETE: foreign booking or the 'blocked' sentinel 404s. Unlike + // POST, a cancelled booking is still viewable here — DELETE is the correction mechanism for it. + const booking = await getBookingWithCustomer(c.env.PAWBOOK_DB, tenant.Id, bookingId); + if (!booking || booking.ServiceType === 'blocked') return c.json({ error: 'Not found.' }, 404); + const rows = await listPaymentsForBooking(c.env.PAWBOOK_DB, tenant.Id, bookingId); return c.json({ payments: rows.map((p) => ({ id: p.Id, @@ -860,6 +866,7 @@ export const adminRoutes = new Hono() // here in JS from the aggregates — no extra queries, no KV caching (prototype-scale D1). .get('/:slug/admin/analytics', async (c) => { const tenant = c.get('tenant'); + await reconcileIfStale(c.env, tenant); const today = getPacificDateStr(undefined, tenant.Timezone ?? undefined); const data = await getAnalytics(c.env.PAWBOOK_DB, tenant.Id, today); const outstanding = data.outstanding.map((o) => ({ From 867b0014931ec2b791214616df41a18c34113a06 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 09:00:16 -0400 Subject: [PATCH 17/18] Show read-only payments ledger on cancelled bookings; per-action busy in panel Co-Authored-By: Claude Fable 5 --- app/admin/PaymentsPanel.tsx | 95 ++++++++++++++------------ app/admin/sections/BookingsSection.tsx | 17 +++-- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/app/admin/PaymentsPanel.tsx b/app/admin/PaymentsPanel.tsx index d234a81..667c58a 100644 --- a/app/admin/PaymentsPanel.tsx +++ b/app/admin/PaymentsPanel.tsx @@ -18,18 +18,23 @@ export function PaymentsPanel({ bookingId, onChanged, handleError, + allowRecord = true, }: { session: Session; bookingId: string; onChanged: () => void | Promise; handleError: (e: unknown) => void; + /** False for cancelled/declined bookings: the ledger is read-only (delete is the refund- + * correction mechanism), so the record-payment form is hidden. */ + allowRecord?: boolean; }) { const [payments, setPayments] = useState(null); const [amount, setAmount] = useState(''); const [method, setMethod] = useState('cash'); const [paidDate, setPaidDate] = useState(todayStr); const [note, setNote] = useState(''); - const [busy, setBusy] = useState(false); + const [busyId, setBusyId] = useState(null); + const RECORDING = '__record__'; const amountNum = Number(amount); const canSubmit = Number.isInteger(amountNum) && amountNum >= 1 && paidDate.trim() !== ''; @@ -51,11 +56,11 @@ export function PaymentsPanel({ }, [bookingId, session]); const record = async () => { - if (busy) return; - setBusy(true); + if (busyId) return; + setBusyId(RECORDING); try { await adminApi.payments.record(session.slug, session.token, bookingId, { - amount: Number(amount), + amount: amountNum, method, paidDate, ...(note.trim() ? { note: note.trim() } : {}), @@ -68,13 +73,13 @@ export function PaymentsPanel({ } catch (e) { handleError(e); } finally { - setBusy(false); + setBusyId(null); } }; const remove = async (paymentId: string) => { - if (busy) return; - setBusy(true); + if (busyId) return; + setBusyId(paymentId); try { await adminApi.payments.remove(session.slug, session.token, bookingId, paymentId); setPayments(await load()); @@ -82,16 +87,18 @@ export function PaymentsPanel({ } catch (e) { handleError(e); } finally { - setBusy(false); + setBusyId(null); } }; + if (!allowRecord && payments !== null && payments.length === 0) return null; + return (
    {payments === null ? (

    Loading…

    ) : payments.length === 0 ? ( -

    No payments recorded yet.

    + allowRecord &&

    No payments recorded yet.

    ) : (
      {payments.map((p) => ( @@ -100,46 +107,48 @@ export function PaymentsPanel({ ${p.amount} · {p.method} · {p.paidDate} {p.note ? ` — ${p.note}` : ''} - ))}
    )} -
    - - - - - -
    + {allowRecord && ( +
    + + + + + +
    + )}
    ); } diff --git a/app/admin/sections/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx index d5ff88b..20edfc5 100644 --- a/app/admin/sections/BookingsSection.tsx +++ b/app/admin/sections/BookingsSection.tsx @@ -12,6 +12,10 @@ function formatWhen(b: AdminBooking): string { const byStartDate = (a: AdminBooking, b: AdminBooking) => a.startDate.localeCompare(b.startDate); +/** True for bookings that aren't cancelled/declined — the payments ledger is fully editable for + * these; cancelled/declined rows show a read-only ledger only when they have payments to show. */ +const isActive = (b: AdminBooking) => b.status !== 'cancelled' && b.status !== 'declined'; + function chipClass(status: string): string { if (status === 'confirmed') return ' pb-chip-ok'; if (status === 'cancelled') return ' pb-chip-bad'; @@ -19,12 +23,12 @@ function chipClass(status: string): string { return ''; } -/** Payment state for a live row; null for cancelled/declined (money display would mislead) and - * for unpaid estimate-less rows. 'paid in full' covers overpayment/tips (paidTotal > estCost). */ +/** Payment state for a row; null for unpaid rows. 'paid in full' covers overpayment/tips + * (paidTotal > estCost). Shown for cancelled/declined rows too, so a sitter reviewing a refund + * case can still see the amount. */ function paidText(b: AdminBooking): string | null { - if (b.status === 'cancelled' || b.status === 'declined') return null; if (b.paidTotal === 0) return null; - if (b.estCost == null) return b.paidTotal > 0 ? `paid $${b.paidTotal}` : null; + if (b.estCost == null) return `paid $${b.paidTotal}`; return b.paidTotal >= b.estCost ? 'paid in full' : `paid $${b.paidTotal} of $${b.estCost}`; } @@ -109,7 +113,7 @@ export function BookingsSection({ Cancel )} - {b.status !== 'cancelled' && b.status !== 'declined' && ( + {(isActive(b) || b.paidTotal > 0) && ( @@ -130,12 +134,13 @@ export function BookingsSection({ {paid && <> · {paid}} {actionsFor(b)} - {b.status !== 'cancelled' && b.status !== 'declined' && openId === b.id && ( + {(isActive(b) || b.paidTotal > 0) && openId === b.id && ( setBookings(await load())} handleError={handleError} + allowRecord={isActive(b)} /> )} From 4bb317c2b9f7dbd833def3066da6521f9709e5df Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Sun, 12 Jul 2026 09:02:25 -0400 Subject: [PATCH 18/18] Polish earnings section: empty copy, truncation, chart label overflow - Change outstanding empty state to neutral "No outstanding balances." - Hoist duplicated "No payments recorded yet." to module constant NO_PAYMENTS - Add overflow: hidden with text-overflow: ellipsis to hbars grid cells and list items - Hide SVG bar chart totals when >= 5 digits to prevent overlap Co-Authored-By: Claude Fable 5 --- app/admin/admin.css | 21 +++++++++++++++++++++ app/admin/sections/EarningsSection.tsx | 20 ++++++++++++-------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/app/admin/admin.css b/app/admin/admin.css index 69ae5c9..e474b9c 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -609,6 +609,14 @@ body { grid-template-columns: 140px 1fr 60px; align-items: center; gap: 10px; + min-width: 0; +} + +.pb-hbars li > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .pb-hbar { @@ -623,3 +631,16 @@ body { height: 100%; min-width: 2px; } + +.pb-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + min-width: 0; +} + +.pb-truncate-block { + display: block; + min-width: 0; +} diff --git a/app/admin/sections/EarningsSection.tsx b/app/admin/sections/EarningsSection.tsx index 79c0441..1823701 100644 --- a/app/admin/sections/EarningsSection.tsx +++ b/app/admin/sections/EarningsSection.tsx @@ -4,6 +4,8 @@ import { IconChartBar } from '../../shared-ui/icons'; import { PaymentsPanel } from '../PaymentsPanel'; import type { Session } from '../shared.js'; +const NO_PAYMENTS = 'No payments recorded yet.'; + const MONTH_NAMES = [ 'Jan', 'Feb', @@ -44,7 +46,7 @@ function MonthlyChart({ monthly }: { monthly: AnalyticsPayload['monthly'] }) { const x = i * (barW + gap); return ( - {m.total > 0 && ( + {m.total > 0 && m.total < 10000 && ( ${m.total} @@ -149,12 +151,12 @@ export function EarningsSection({ {hasPayments ? ( ) : ( -

    No payments recorded yet.

    +

    {NO_PAYMENTS}

    )}

    By service (all-time)

    {data.byService.length === 0 ? ( -

    No payments recorded yet.

    +

    {NO_PAYMENTS}

    ) : (
      {data.byService.map((s) => ( @@ -174,12 +176,14 @@ export function EarningsSection({

      Top clients (all-time)

      {data.topClients.length === 0 ? ( -

      No payments recorded yet.

      +

      {NO_PAYMENTS}

      ) : (
        {data.topClients.map((t) => (
      • - {t.name || t.email || 'Unknown client'} + + {t.name || t.email || 'Unknown client'} + ${t.total} · {t.bookings} booking{t.bookings === 1 ? '' : 's'} @@ -190,13 +194,13 @@ export function EarningsSection({

        Outstanding balances

        {data.outstanding.length === 0 ? ( -

        Every confirmed booking is fully paid.

        +

        No outstanding balances.

        ) : (
          {data.outstanding.map((o) => (
        • - - {o.name || o.email || 'Unknown client'} — {o.serviceType} ({o.startDate}) + + {o.name || o.email || 'Unknown client'} — {o.serviceType} ({o.startDate})
          owes ${o.balance} (paid ${o.paidTotal} of ${o.estCost})