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/PaymentsPanel.tsx b/app/admin/PaymentsPanel.tsx new file mode 100644 index 0000000..667c58a --- /dev/null +++ b/app/admin/PaymentsPanel.tsx @@ -0,0 +1,154 @@ +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, + 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 [busyId, setBusyId] = useState(null); + const RECORDING = '__record__'; + + const amountNum = Number(amount); + const canSubmit = Number.isInteger(amountNum) && amountNum >= 1 && paidDate.trim() !== ''; + + 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 (busyId) return; + setBusyId(RECORDING); + try { + await adminApi.payments.record(session.slug, session.token, bookingId, { + amount: amountNum, + method, + paidDate, + ...(note.trim() ? { note: note.trim() } : {}), + }); + setAmount(''); + setNote(''); + setPaidDate(todayStr()); + setPayments(await load()); + await onChanged(); + } catch (e) { + handleError(e); + } finally { + setBusyId(null); + } + }; + + const remove = async (paymentId: string) => { + if (busyId) return; + setBusyId(paymentId); + try { + await adminApi.payments.remove(session.slug, session.token, bookingId, paymentId); + setPayments(await load()); + await onChanged(); + } catch (e) { + handleError(e); + } finally { + setBusyId(null); + } + }; + + if (!allowRecord && payments !== null && payments.length === 0) return null; + + return ( +
+ {payments === null ? ( +

Loading…

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

No payments recorded yet.

+ ) : ( +
    + {payments.map((p) => ( +
  • + + ${p.amount} · {p.method} · {p.paidDate} + {p.note ? ` — ${p.note}` : ''} + + +
  • + ))} +
+ )} + {allowRecord && ( +
+ + + + + +
+ )} +
+ ); +} diff --git a/app/admin/admin.css b/app/admin/admin.css index 10d1612..e474b9c 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; @@ -551,3 +563,84 @@ 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; + min-width: 0; +} + +.pb-hbars li > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.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; +} + +.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/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx index 3c21886..20edfc5 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. */ @@ -11,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'; @@ -18,6 +23,15 @@ function chipClass(status: string): string { return ''; } +/** 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.paidTotal === 0) return null; + if (b.estCost == null) return `paid $${b.paidTotal}`; + return b.paidTotal >= b.estCost ? 'paid in full' : `paid $${b.paidTotal} of $${b.estCost}`; +} + export function BookingsSection({ session, handleError, @@ -29,6 +43,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 +113,39 @@ export function BookingsSection({ Cancel )} + {(isActive(b) || b.paidTotal > 0) && ( + + )} ); - 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)} + {(isActive(b) || b.paidTotal > 0) && openId === b.id && ( + setBookings(await load())} + handleError={handleError} + allowRecord={isActive(b)} + /> + )} +
  • + ); + }; const pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate); const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate); diff --git a/app/admin/sections/EarningsSection.tsx b/app/admin/sections/EarningsSection.tsx new file mode 100644 index 0000000..1823701 --- /dev/null +++ b/app/admin/sections/EarningsSection.tsx @@ -0,0 +1,224 @@ +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'; +import type { Session } from '../shared.js'; + +const NO_PAYMENTS = 'No payments recorded yet.'; + +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 < 10000 && ( + + ${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 alive = useRef(true); + + 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]); + + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + + const reload = async () => { + clearError(); + try { + const result = await load(); + if (alive.current) { + setData(result); + } + } catch (e) { + if (alive.current) { + 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}

    + )} + +

    By service (all-time)

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

    {NO_PAYMENTS}

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

    Top clients (all-time)

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

    {NO_PAYMENTS}

    + ) : ( +
      + {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 ? ( +

    No outstanding balances.

    + ) : ( +
      + {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/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 8b0c5e2..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; @@ -94,10 +93,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 +281,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`, { 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 ( 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..1cea751 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-earnings-analytics-design.md @@ -0,0 +1,250 @@ +# Earnings analytics: payment tracking + dashboard — design + +**Date:** 2026-07-11 +**Status:** Revised after two-subagent review (fact-check vs codebase + design review); pending 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. +- **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 + 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. 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` + 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. +- `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 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` — 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 +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. 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` 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.list/record/remove`; + `AdminBooking` gains `paidTotal`. +- **`app/admin/App.tsx`** — `'earnings'` added to the `SectionKey` union, + `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. + +## 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: 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 + +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 + 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 + 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). +- 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. 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__/analytics.test.ts b/server/__tests__/analytics.test.ts new file mode 100644 index 0000000..9be5229 --- /dev/null +++ b/server/__tests__/analytics.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import { + getAnalytics, + insertBookingRequest, + insertInvitedCustomer, + insertPayment, + 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. +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(); + // 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 }); + 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); + expect(topClients).toEqual([ + { EndUserId: jess.Id, Name: 'Jess Demo', 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']); + }); +}); + +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(); + // 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(); + 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 Demo', email: 'jess@example.com', total: 160, bookings: 1 }, + ]); + expect(body.outstanding).toEqual([ + { + bookingId, + name: 'Jess Demo', + email: 'jess@example.com', + serviceType: 'boarding', + startDate: '2030-01-01', + estCost: 300, + paidTotal: 160, + balance: 140, + }, + ]); + }); +}); 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/__tests__/payments-routes.test.ts b/server/__tests__/payments-routes.test.ts new file mode 100644 index 0000000..91314d8 --- /dev/null +++ b/server/__tests__/payments-routes.test.ts @@ -0,0 +1,192 @@ +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('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); + 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/db/repo.ts b/server/db/repo.ts index 1102dd7..e2ff66c 100644 --- a/server/db/repo.ts +++ b/server/db/repo.ts @@ -1,7 +1,9 @@ import type { + AnalyticsData, BookingRow, EndUser, EndUserPet, + PaymentRow, PetType, ProviderConnection, ProviderConnectionWithTokens, @@ -12,6 +14,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 +314,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 +387,174 @@ 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; +} + +/** + * 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`; + // 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 >= ? AND PaidDate < ? + GROUP BY Month`, + ) + .bind(tenantId, windowStart, windowEnd) + .all(), + 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(), + 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/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 diff --git a/server/lib/validation.ts b/server/lib/validation.ts index 64563df..a01ab09 100644 --- a/server/lib/validation.ts +++ b/server/lib/validation.ts @@ -91,3 +91,25 @@ 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. + * 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', + '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/routes/admin.ts b/server/routes/admin.ts index 54b4916..118fd2c 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -7,19 +7,23 @@ import { countBookingsForService, countBookingsForUser, createService, + getAnalytics, getBookingWithCustomer, getEndUserById, getEndUserByEmail, deleteBlockedRange, deleteCustomer, + deletePayment, deleteService, getProviderConnection, insertBookingRequest, insertInvitedCustomer, + insertPayment, listAllEndUserPetsByTenant, listBlockedRanges, listBookingsForTenant, listCustomers, + listPaymentsForBooking, listPetTypes, listProviderConnections, listServiceOptions, @@ -58,12 +62,14 @@ import { DEFENSIVE_MAX_PET_COUNT, EMAIL_RE, isNullableLimit, + isPaymentMethod, isRealDate, isValidDuration, isValidRate, } 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}$/; @@ -744,6 +750,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 +790,115 @@ 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); + if (!created) return c.json({ error: 'Not found.' }, 404); + 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 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, + 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); + }) + + // 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'); + 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) => ({ + 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, + }); }); diff --git a/server/types.ts b/server/types.ts index d5eb260..7409e2a 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,40 @@ 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; +}; + +/** 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; 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),