Skip to content

feat: full backend + apps build (Logistics v1.1, auth, onboarding, observability) - #1

Open
Banyel3 wants to merge 100 commits into
mainfrom
feat/api-express-lite-slice
Open

feat: full backend + apps build (Logistics v1.1, auth, onboarding, observability)#1
Banyel3 wants to merge 100 commits into
mainfrom
feat/api-express-lite-slice

Conversation

@Banyel3

@Banyel3 Banyel3 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

The full backend + app build for Wash & Go. main currently only has the landing page; this branch carries everything else — the API, all four apps' features, auth, observability, and deploy tooling — across ~20 shipped checkpoints.

Merging triggers full CI (DB-free unit tests on API/admin/portal/rider + API Docker image build) and deploys the apps.

Highlights

Logistics v1.1 (3 services / 2 tiers)

  • P0 Express weight ceiling via load categories (S/M/L)
  • P1/P1.5 Scheduled (Tier 1) create path + booking UI
  • P4b multi-factor shop match (capacity → distance → turnaround → rating)
  • P4a admin-gated auto-dispatch (auto-assign least-loaded rider)

Money + correctness hardening

  • Idempotency on POST /orders + rider-cash deposits (double-tap / double-count fixes)
  • Coordinate validation; order cancellation (customer early-window + admin)

Onboarding + admin

  • Admin user directory (grant roles / enable-disable)
  • Shop onboarding CRUD (shops, priced services from catalog, staff)
  • Shop-facing remittance (payout batches in the portal)

Auth

  • Real Firebase login on admin, portal, rider (customer already had it); dev
    x-dev-uid fallback preserved for local/e2e (prod build requires real login)

Customer/rider app wins

  • Saved-address management, orders pagination + code search, ratings/reviews,
    in-app notifications, re-order ("book again"), rider job polling, rider cash view

Observability (Tier 1)

  • Env-gated Sentry (@sentry/node, ships inert, LIVE + verified on prod DSN)
  • Structured JSON logging in prod (pretty in dev)
  • Per-endpoint rate limits, /health/ready readiness probe

CI/CD + deploy

  • API Dockerfile + Railway/Vercel config + DEPLOY.md runbook
  • CI runs all app unit tests + validates the API image on PR

Testing

  • 247 API unit tests; domain/api-client/app unit suites green
  • Playwright browser smokes green (customer booking, rider jobs, admin dispatch,
    users, shops, zones, rider cash)
  • Sentry delivery verified end-to-end (EU ingest 200)

Notes

  • Firebase web config is public by design (committed); the service-account
    JSON is the only secret and is env-only.
  • Pre-prod ops still owed (not code): real rate card, pay model, Firebase account
    creation + role grants, optional Redis/Sentry DSN in prod env.

🤖 Generated with Claude Code

Banyel3 and others added 30 commits July 18, 2026 11:53
Test-first express order lifecycle: book -> manual dispatch -> weigh ->
status walk -> DELIVERED -> remittance. 61 tests green (unit + real-Postgres
concurrency), build + type-check clean, HTTP smoke verified.

- Pricing engine: pure, Prisma.Decimal end-to-end. Remittance by subtraction
  (remittance + commission == wash, property-tested). Config-driven fees.
- Coverage gate: hardcoded Zamboanga polygon, point-in-polygon before create.
- Orders domain (ADR-003 seam): OrdersRepository is the only Prisma toucher;
  OrdersService owns every tx and threads tx into the repo.
- Capacity: pg_advisory_xact_lock(shopId, Manila-day) before count; count
  excludes CANCELLED. Proven: concurrent creates on a 1-slot shop -> one wins.
- Status machine: hand-rolled legal-transition map, role-gated per edge, row
  locked FOR UPDATE. OrderEvent written same-tx (S1).
- Remittance: RemittanceLine written same-tx as DELIVERED (S2), unique(orderId)
  idempotent. Proven: concurrent DELIVERED -> one line.
- Order code: sequence created in migration (C1), nextval inside create tx.
- RolesGuard + @roles + per-order ownership checks.

Fix: start script pointed at dist/main.js; nest emits dist/src/main.js.

ADR-003 acceptance evidence complete; flip Proposed->Accepted pending CEO.
Rates provisional/refundable (CEO D1). AUTH_DEV_BYPASS still on until mobile
mints Firebase tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed packages

Backend endpoints and shared TS packages the customer app needs, all test-first.
Depends on nothing native — Lane B (Expo + Firebase) builds on top.

- GET /shops (CUSTOMER-gated): active shops + active services, single-query
  include (no N+1). Response is shaped — commissionPct/expressSlotsPerDay never
  leave the server (no margin leak to clients).
- POST /orders/preview: read-only price estimate reusing the pricing engine, so
  the preview total always matches what create will charge for the same inputs.
- packages/domain: OrderStatus/ServiceType mirrored from Prisma (dependency-free
  for Metro), humanized status map + timeline, wire types. Prisma-parity test.
- packages/api-client: hand-written typed client with an injected TokenProvider
  (zero Firebase dependency), 401 -> refresh -> retry, typed error mapping.

Tests: api 65, domain 5, api-client 7. pnpm -r type-check clean.
Reviews: eng + design plan reviews cleared (scope-reduced: map picker deferred,
server price preview, weight-bucket UX, full state coverage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expo Router app (SDK 57) runnable in Expo Go today. Full express flow against
the live API: shops list -> book (load buckets + GPS/text pickup + server price
preview) -> confirm -> order tracking. Trimmed the template to Expo-Go-safe
modules (dropped @expo/ui / expo-glass-effect dev-build natives).

- Auth: dev-bypass stub (x-dev-uid=dev-customer) since Expo Go can't run native
  @react-native-firebase. Real phone OTP is a dev-build follow-up; ApiClient
  gained a devUid option for this.
- Design (folded from the design review): load-size buckets not a kg field,
  3-state coverage per screen (loading/empty/error), out-of-coverage message,
  price shown as estimate with a weigh-in caveat, status timeline with the
  current step as hero, 44px targets, brand palette.
- pnpm monorepo Metro config (watch workspace root, hoisted resolution);
  api-client now declares its @wash-and-go/domain dependency.

Verified: expo export bundles for iOS (1111 modules, no errors); app type-check
clean; logic tests (peso formatter, load buckets) green. Monorepo: 82 tests
pass, pnpm -r type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…read

Second Expo Go app: a rider signs in (dev-rider-1 stub), sees assigned jobs
triaged (needs-action first), drives each status forward, and records cash —
all against the live API.

Backend (test-first):
- Shaped GET /orders/:id (+list): per-actor relations (shop drop-off + customer
  contact) + availableActions computed from the state machine for the actor's
  role+ownership. Ownership logic refactored into one ownsTransition() shared by
  the enforce path and the compute path. Seed gains a dev-admin.
- api-client: transition() + payCash(); the client only offers actions the
  shaped read says are available.

Shared UI (DRY, 2nd-app trigger):
- Extracted packages/ui — Screen/Card/PrimaryButton/States/Pill/StatusTimeline,
  theme, peso, plus SlideToConfirm (PanResponder, Expo Go safe). customer-mobile
  refactored to import it; still bundles.

Rider design (from the design review):
- Slide-to-confirm on Delivered + Record cash only (irreversible/money); plain
  big taps on mid-steps. Tap-to-call + tap-to-navigate. Triage: needs-action
  loudest. One-handed targets.

Verified: api 70, api-client 10, domain 5, ui 3, customer 3, rider 7 — all green;
pnpm -r type-check clean (9 projects); both apps expo-export bundle for iOS; live
shaped read returns availableActions + shop + customer phone to the assigned rider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend enablers for the web portals (test-first):
- GET /riders (admin-only, direct-Prisma): active RIDER users, minimal
  projection. Verified: 200 for admin, 403 for shop-owner.
- Seed: dev-shop-owner + a ShopMember on Tetuan so the laundry-portal has
  scoped data; dev-admin already added.
- api-client: getRiders(), assignRider(), weigh() so the admin + shop portals
  drive dispatch and weigh-in without curl.

Tests: api 71, api-client 13. pnpm -r type-check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…url gap

Two Next.js (App Router) ops apps over the shipped endpoints — the full
BOOKED→DELIVERED loop is now demoable without curl:
- admin-dashboard: orders board (status filter, color-coded), assign-rider
  picker on BOOKED rows (GET /riders). Port 3001, stub dev-admin.
- laundry-portal: shop queue; weigh-in is preview→confirm (design D2: reuse
  POST /orders/preview to show old→new total before the bill changes), then
  drive PROCESSING/READY. Port 3002, stub dev-shop-owner (Tetuan member).
- Both: TanStack Query (5s poll), api-client + domain reused, action buttons
  driven by the shaped read's availableActions (no client transition logic),
  status color from the domain tone.

Shared:
- peso moved to packages/domain (platform-free); packages/ui re-exports it so
  mobile is unchanged and the web portals get it without pulling React Native.
- OrderView gains shopServiceId (already in the shaped payload) for weigh preview.

Verified: domain 8, api 71, admin 2, laundry 2 (+ prior suites) green;
pnpm -r type-check clean (9 projects); both portals `next build` succeed;
customer app still bundles after the peso move. Notion: "[v1] Web Admin + Shop
Portal" page created under the Engineering Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Customer app now signs in with real Firebase auth; rider app + portals keep the
dev-bypass stub. Both coexist under one flag.

Backend:
- Auth resolution now prefers a real bearer token (verified via Admin SDK),
  falling back to x-dev-uid only when no bearer is present — so a real-auth
  client and stub clients work simultaneously with AUTH_DEV_BYPASS=1.
- FirebaseService initializes the Admin SDK whenever credentials exist (even
  under dev bypass); only pure stub mode (bypass + no creds) skips it.
- Fix: api-client.postSession sends the ID token in the body ({ idToken }) to
  match the public /auth/session contract (was header-only → 400).

Customer app (Firebase JS SDK, runs in Expo Go — no dev build):
- firebase init with AsyncStorage persistence (getReactNativePersistence
  accessed dynamically; missing from firebase v12 types, present at runtime,
  firebase-js-sdk#8332).
- Email/password login screen (sign in / create account), auth gate in the root
  layout, FirebaseTokenProvider wired into the api-client seam.
- Public web config committed (public-safe by Firebase design; not a secret).

Verified end-to-end: Firebase signup -> POST /auth/session creates the Postgres
user (roles [CUSTOMER]) -> GET /shops with the real token = 200; x-dev-uid path
still 200. api 72, api-client 14 green; type-check clean; customer app bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the customer app from a bare stack to a persistent bottom tab bar:
- (tabs) group with Home (shops + book), My Orders, and a new Profile page.
- Profile: shows the signed-in email + Sign out (Firebase signOut; the auth gate
  then redirects to /login). Confirm before sign-out (window.confirm on web,
  Alert on native). This is the app's first sign-out affordance.
- book + orders/[id] stay full-screen pushes (no tab bar on booking/tracking).
- Ionicons tab icons (@expo/vector-icons, Expo Go safe); brand active / muted
  inactive, safe-area inset.

Verified: type-check clean; expo export bundles (1211 modules, tab route tree).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…web)

Storing the global fetch and calling it as this.fetchFn(...) threw
"Illegal invocation" in the browser (fetch requires window as its `this`).
Wrap it in an arrow that calls fetch as a free function — web-safe, and native
RN unaffected. Surfaced running the customer app on Expo web.

Also: ignore Expo's local .expo/ cache at the repo root.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port P1 of the v5 design: packages/ui theme remapped to the landing-page palette
— navy #004375 (structure/primary), terracotta #d07a29 (action), slate #3d5975,
warm neutrals. Existing brand*/text* keys kept as aliases so both apps re-skin
without churn; added navy/terra/slate tokens + tints. PrimaryButton gains a
`tone="terra"` variant for action/CTA buttons.

Both mobile apps type-check + bundle clean on the new palette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delivery fee is no longer flat. delivery = base + max(0, roundTripKm − freeKm) ×
perKm, capped; roundTripKm = 2 × haversine(customer, shop) × roadFactor.
Interim uses haversine × 1.3 as a driving-distance proxy — swap for a routing API
when the maps vendor (D10) lands; the formula stays.

- pricing/distance.ts: haversineKm + computeDeliveryFee (pure, 7 tests).
- PricingConfig: delivery params (base 40 / freeKm 2 / perKm 8 / max 150 /
  roadFactor 1.3), env-overridable — later the admin dynamic-config store.
- OrdersService.price() takes one-way km and computes the fee; preview + create +
  weigh all pass haversine(pickup, shop). Preview gains optional pickup coords
  (omitted → base fee).

Tests updated for the new model (pickup == shop → 0km → base ₱40; golden total
150+40+7 = 197). API suite 79 green; pnpm -r type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend for the shop-auto-match flow (customer no longer picks the laundry):
- POST /orders/quote { pickupLat, pickupLng, weightKg, shopServiceId? } resolves
  the nearest active shop (haversine, within a max radius) — or the override —
  and returns the shop + distanceKm + full price breakdown (distance delivery).
- OrdersRepository.findActiveShopServices; OrdersService.resolveNearest +
  quoteOrder. Capacity stays a create-time gate; live-GPS/dynamic-radius later.
- GET /shops?lat&lng annotates distanceKm + sorts nearest-first (change-laundry
  chooser).
- api-client: quoteOrder() + getShops(loc). domain: OrderQuote, QuoteOrderBody,
  ShopView.distanceKm.

API 82 tests, api-client 14, domain 8 green; pnpm -r type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, Change)

Rework the customer app to the shop-auto-match model (customer no longer picks
the laundry):
- Home is now a dashboard, not a shop picker: greeting + terracotta "Book a
  wash" CTA + quick actions + live active-order card (with empty state).
- Book: pick load + pickup only (no shop), then Continue → Checkout.
- Checkout (new): POST /orders/quote resolves the nearest shop, shows it with a
  "Closest" badge + Change link + the distance-based price breakdown; Confirm
  creates the order with the resolved shopServiceId.
- Change-laundry (new): GET /shops?lat&lng nearest-first chooser; picking one
  re-quotes checkout with that shop as an override.

Disabled expo-router typedRoutes (regenerated types fought tsc/CI). Type-check +
logic test green; app bundles (1213 modules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the ported palette: jobs needing action (Pill + next-step hint) now use
terracotta/terraDark instead of navy, so the rider's "do this next" cue pops
against the navy chrome. Rider otherwise re-skins automatically via packages/ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Platform-level rules (service fee, distance-delivery params, match radius, plus
placeholders for the coming weight-cutoff / price-floor / platform-fee) move from
env/hardcoded into a DB-backed, admin-editable store — change a fee without a
redeploy. Per-shop wash rates stay in the laundry portal.

- Prisma: PlatformConfig (typed singleton, id=1) + ConfigAudit (append-only,
  one row per changed field: actor, old→new, timestamp).
- PlatformConfigService: seed-on-first-read from env defaults (DB wins after),
  getValues() for the pricing/matching engines, update() that validates
  (finite ≥ 0), writes only changed columns, and audits each change in one tx.
- ConfigController: GET/PUT /admin/config + GET /admin/config/audit (ADMIN only).
- orders.service now sources delivery/serviceFee/maxResolveKm from the store
  (deletes the env-only PricingConfig + the hardcoded MAX_RESOLVE_KM). Order
  specs re-mocked; integration spec runs the real config service on Postgres.
- domain + api-client: PlatformConfigView/Patch/ConfigAuditEntry +
  getConfig/updateConfig/getConfigAudit.
- admin /config: grouped rules editor (Fees · Delivery · Matching), diff-only
  save, inline validation, recent-changes log. Placeholder group is flagged
  "not yet applied" so no one is misled that inert fields do anything.

Tests: api 88 pass (new config service spec + integration), admin logic 6 pass;
all type-checks clean. Config endpoints verified live (seed, patch-diff, audit,
revert, negative-rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild the admin dashboard chrome around a proper app shell — no behavior
change, same GET/PUT/audit flow. Makes the internal ops console look/feel
production-grade instead of plain inline-styled pages.

- App shell: dark-navy sticky sidebar (Dispatch · Business rules) with active
  state via usePathname; content area max-width'd. Collapses to a top bar under
  820px.
- Tokenized theme: chrome colors move to CSS custom properties with a light +
  dark palette (prefers-color-scheme). lib/theme.ts chrome colors reference the
  vars; brand + semantic stay literal (used in badge alpha math).
- Config editor: page-head with eyebrow/title/sub, carded rule groups, .field-
  input controls, tabular-nums on the audit diff column, and a floating "Rules
  updated" save toast. Placeholder group still flagged "not yet applied".
- Dispatch page: same shell treatment — page-head, carded table, tnum total.

Type-check + logic tests green; next build clean (both routes prerender).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… throttler

Tier A of the backend finish pass. No behavior change to the money path; closes
the prod-readiness gaps an audit surfaced.

- [BLOCKER] AUTH_DEV_BYPASS fail-safe: the bypass is now allowed ONLY when
  NODE_ENV is explicitly 'development'/'test'. Previously it only blocked when
  NODE_ENV==='production', so an unset NODE_ENV (the real deploy state) left
  x-dev-uid impersonation live. auth-config.ts + spec (undefined/staging now
  refuse boot).
- Env schema (Joi) at ConfigModule.forRoot: NODE_ENV required (default
  development), DATABASE_URL required, AUTH_DEV_BYPASS default 0 — typo'd/missing
  vars fail fast. NODE_ENV added to .env / .env.example.
- Global exception filter: Prisma P2002→409 / P2025→404 / P2003→409, sanitized
  5xx (no stack leak), correlation id from the Fastify request id.
- Global ThrottlerGuard (60 req/min default; in-memory store, Redis-swappable).
- Global RolesGuard as a second APP_GUARD so a controller that forgets
  @UseGuards(RolesGuard) still gets role-gated (no-ops without @roles).
- main.ts: @fastify/helmet, ValidationPipe forbidNonWhitelisted, CORS allow-list
  from CORS_ORIGINS (was origin:true+credentials), Swagger gated to non-prod,
  enableShutdownHooks() for clean SIGTERM drain.
- Health: split liveness (/health) from readiness (/health/ready runs SELECT 1).
- prisma:migrate:deploy script for controlled prod migrations.

Verified live: boots clean, /health/ready reports db:up, helmet headers present,
unknown DTO field → 400, swagger 200 in dev. 91 tests pass (+3 auth-config).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-pickup

Tier B of the backend finish pass. Test-first; fixes two latent correctness
holes an audit surfaced, and covers the untested money paths.

Fixes:
- Delivery-fee misconfig guard: assertConfigInvariants rejects a merged config
  where the cap < base (would cap every fee below base) or roadFactor < 1 (would
  under-count distance). Checked on the MERGED patch-over-current, not per-field.
- weigh() null-pickup: when pickup coords are absent, charge base delivery (km=0)
  instead of computing distance to (0,0) mid-Atlantic, which capped the fee at
  max and inflated the total. Latent today (express always has coords); guards a
  future no-coords order.

Tests added (+32, suite 91 → 123):
- manila-time: day-bucketing boundary, month/year rollover, the 23:30-UTC →
  next-Manila-day case, 24h/contains-now invariants (was fully untested — keys
  the capacity advisory lock).
- payCash: idempotency, rider-ownership forbid, admin-any, 404 (was zero tests).
- listOrders scoping: CUSTOMER/RIDER/SHOP/ADMIN + the no-role __none__ sentinel
  (cross-tenant data-leak surface, was zero tests).
- gate branches: assignRider-from-non-BOOKED, DELIVERED-with-null-shopId,
  preview-inactive-shop, quote-inactive-override, resolveNearest-beyond-radius.
- route-roles: reflect @roles off every controller method and assert the exact
  role matrix, so a mis-scoped endpoint fails CI.
- platform-config: cap<base and roadFactor<1 rejection + merged-invariant.

Split test scripts: test:unit (no Docker) / test:int (integration only); `test`
still runs both. Full suite 123 pass, type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First Tier-C feature module (PLAN §3.2, Phase 4). Extends the RemittanceLine
already written per DELIVERED order into batched, trackable shop payouts.

Backend (ADR-003 repo → service → controller, TDD):
- Schema: RemittanceBatch (shop, period, totalPhp, lineCount, status, reference,
  paidAt) + RemittanceStatus enum + RemittanceLine.batchId. Migration applied.
- RemittanceService.closeBatch(shop, period): sums the unbatched lines in
  [start,end), creates one batch, links the lines — all in a tx. closeAllShops()
  closes every shop with lines. markPaid(id, ref) records the external transfer,
  idempotent (already-PAID is a no-op). Empty period → null (no empty batches).
- Repo assignLinesToBatch guards on batchId IS NULL so a concurrent close can't
  steal already-claimed lines.
- Admin API: POST /admin/remittance/close, GET /admin/remittance/batches,
  POST /admin/remittance/batches/:id/mark-paid — ADMIN only (added to the
  route-role matrix spec).
- Payout transfer stays external at launch; weekly BullMQ automation + realtime
  emit deferred (noted).

Shared + UI:
- domain RemittanceBatchView/CloseRemittanceBody; api-client get/close/markPaid.
- admin /remittance page: owed-summary stats, status filter, "close last week"
  action, per-batch mark-paid with transfer-ref input. New "Payouts" nav item.

Tests: service spec (sum/link, empty, period guard, per-shop, mark-paid
idempotency) + integration spec (real Postgres: ₱145.50 summed, lines linked,
no double-batch, idempotent pay) + admin logic spec. Suite 123 → 134; admin
6 → 9; type-checks + next build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second Tier-C module (PLAN §2 users/addresses). Self-contained, no external deps.

Backend (ADR-003 repo → service → controller, TDD):
- Address model (userId, label?, line, lat?, lng?, isDefault) + User.addresses.
  Migration applied. Order still snapshots its own pickup at create, so deleting
  an address never mutates a past order.
- AddressService CRUD, every op user-scoped: a missing OR not-owned id is a 404
  (no existence leak). Invariant: at most one default per user, cleared in the
  same tx as the write that sets it.
- /me/addresses GET/POST/PATCH/DELETE — CUSTOMER only (added to the route-role
  matrix spec).

Shared + customer app:
- domain AddressView/CreateAddressBody/UpdateAddressBody; api-client
  get/create/update/deleteAddress.
- Book screen: loads the address book, shows saved pickups as tappable cards
  that prefill address + coords (Default badge), plus a "Save this pickup for
  next time" toggle that best-effort-saves a new address on Continue.

Tests: service spec (create default-clearing, ownership 404s, promote-to-default,
plain-edit no-clear, delete) — suite 134 → 143. Verified live: full CRUD, the
one-default invariant (2×isDefault → 1 default), and the role gate
(RIDER/ADMIN → 403, CUSTOMER → 200). Type-checks clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iates)

Completes the money model locked in the plan review: the platform intermediates.
A rider collects the full customerTotal COD at delivery, so that cash belongs to
the platform; this tracks what each rider still owes = collected − deposited. The
shipped remittance module (platform → shop payout) stays correct; this is the
missing rider side.

- RiderCashDeposit model (riderId, amountPhp, reference, note, recordedByUid) +
  User.cashDeposits. Migration applied.
- RiderCashService: balance(rider) = SUM(customerTotalPhp of the rider's paid-cash
  orders) − SUM(deposits); summary() lists all riders who took COD, sorted by
  outstanding; recordDeposit() validates positive amount + rider role.
- Money-domain repo (ADR-003): aggregate sums, per-rider groupBy for the summary.
- Admin API: GET /admin/riders/cash (summary), GET /admin/riders/:id/cash
  (balance + deposits), POST /admin/riders/:id/cash/deposit — ADMIN only (added
  to the route-role matrix spec).

Tests: service spec (outstanding math incl. zero + over-deposit-negative, summary
join+sort, deposit validation) — suite 137 → 146 unit. Verified live: summary
200, deposit 201, balance computes. Type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an `e2e/` workspace with Playwright browser smokes that prove the web
surfaces actually work (buttons + flows end-to-end), not just that logic units
pass. First smoke = the laundry-portal weigh flow, the launch-critical path.

Harness:
- e2e/ workspace: @playwright/test, config auto-starts the portal (:3002),
  reuses a running API (:4000) + Docker Postgres.
- lib/seed.ts seeds through the API (x-dev-uid stubs): creates an order and
  drives it BOOKED→ASSIGNED→PICKED_UP→AT_SHOP (admin drives all edges), returns
  the code; cancelOrder tidies after.
- tests/laundry-portal-weigh.spec.ts: scoped to the seeded order's card (queue
  may hold others), enters weight → Preview price → Confirm weigh-in → asserts
  "Weighed 7kg" → Mark Washing → asserts status advanced.

Bug the smoke caught + fixed:
- POST /orders/preview was @roles('CUSTOMER') only, so the portal (SHOP_OWNER)
  got 403 and the weigh price-preview never rendered — the portal's preview had
  never worked. Widened to CUSTOMER + SHOP_OWNER + SHOP_STAFF + ADMIN (read-only
  price calc, a shop seeing its own order's economics is fine). Route-role matrix
  spec updated.
- Added data-testid=`order-${code}` to the portal OrderCard so smokes can scope
  to a specific order.

Smoke green (863ms); API 146 unit pass; portal type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds the maps foundation (Phase C1). One interface, swappable adapters, so
geocoding + driving distance can leave the haversine proxy for a real vendor
without touching callers. Starting with TomTom.

- packages/maps: MapsProvider interface + GeoPoint/GeocodeResult/RouteResult
  types (the D10 boundary; consumed type-only so no runtime coupling).
- apps/api/src/maps: TomTomProvider (Search API fuzzy geocode + Routing API
  driving distance; reverse-geocode via the reverseGeocode endpoint that degrades
  to null when that product is off). HaversineProvider keyless fallback (distance
  only). MapsModule picks the adapter from MAPS_PROVIDER + key presence; global,
  so zones/pricing can inject it. Google adapter slots in here later.
- Env: MAPS_PROVIDER / TOMTOM_API_KEY / GOOGLE_MAPS_API_KEY in .env(.example) +
  Joi schema (keys optional; fallback covers no-key).
- scripts/maps-spike.ts: the D10 spike runner (geocode N addresses + a route).
- Fixed .gitignore: /node_modules → node_modules/ (nested app node_modules
  weren't ignored).

TDD: tomtom.provider.spec (geocode map, no-result null, 403 degrade, route
meters→km, route error throws, reverse-geocode) — suite 146 → 154 unit,
type-check clean.

Live spike (real key) validated: geocode 6/6 Zamboanga addresses (barangay-level
accurate, POI-level rough — flag for the Google A/B), route Tetuan→KCC 3.14km,
reverse-geocode degrades cleanly (product not enabled).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second half of Phase C1. Formalizes the hardcoded orders/coverage.ts polygon
into admin-managed DB zones; a pickup is covered when it falls inside any active
zone.

- Zone model (name, active, polygon JSON of {lat,lng} vertices) + migration.
- geo.ts: ray-cast pointInPolygon (moved from coverage.ts) + the pilot Zamboanga
  ring as the seedless fallback.
- ZonesService: isCovered(point) — any active zone contains it, else falls back
  to the pilot ring (empty table stays covered, no seed needed, launch-safe);
  resolve(point) → containing zone or null; create() validates the ring (≥3
  vertices, finite lat/lng); setActive().
- Admin API: GET/POST /admin/zones, PATCH /admin/zones/:id, GET
  /admin/zones/resolve?lat&lng — ADMIN only (in the route-role matrix).
- orders.service now gates coverage via ZonesService.isCovered (async), not the
  hardcoded polygon. coverage.ts + its spec deleted; order specs use a real
  ZonesService (empty zones → pilot ring) so coverage behavior is unmocked.

TDD: geo.spec + zones.service.spec (fallback, in/out zone, resolve, validation)
— suite 154 → 168 (incl integration on real Postgres). Type-check clean. Live:
create zone 201, resolve central-ZC → zone, Manila → null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Puts the validated TomTom Search API to work: customers can type a pickup and
get it pinned, no GPS needed. Completes Phase C1's geocoding half.

- GET /geocode?q= (CUSTOMER + ADMIN) backed by the active MapsProvider; returns
  the best hit or null (app falls back to a manual pin), 400 on <2-char query.
  Token moved to maps.constants.ts to break the controller↔module circular
  import (caught by boot: undefined-dependency DI error).
- domain GeocodeHit + api-client.geocode(query).
- Customer Book screen: "🔎 Find this address" geocodes the typed address →
  sets coords + normalizes the label. Complements GPS, saved addresses, manual.

Tests: geocode.controller.spec (delegate/trim, <2-char 400, null passthrough) +
route-role matrix. e2e geocode-api smoke (real TomTom through our stack:
Zamboanga address → in-range coords, 400 on short query, rider 403). API 166
unit, all type-checks clean, 4/4 e2e green (geocode + portal weigh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the rider-cash backend: ops sees what each rider owes and records
deposits. Plus a real bug the browser smoke caught.

- Admin /rider-cash page (new "Rider cash" nav): outstanding-per-rider table
  (collected − deposited, rider names joined from /riders), total-owed stat, and
  an inline record-deposit form. lib/rider-cash helpers (totalOutstanding, owing)
  + spec.
- domain RiderCashBalance/DepositView/Detail + RecordDepositBody; api-client
  getRiderCashSummary / getRiderCashDetail / recordRiderDeposit.

Bug the smoke caught + fixed:
- The api-client set content-type: application/json on EVERY request, so a
  no-body POST (pay-cash) sent an empty json body → Fastify 500 "Body cannot be
  empty". pay-cash from the apps would have 500'd. Now content-type is only set
  when there's a body. Same fix in the e2e seed helper. Client spec updated (GET
  no content-type; body-POST has it).

- e2e: admin-dashboard added to the Playwright webServer set; seedRiderCollected
  Cash (create → assign → pay-cash); admin-rider-cash browser smoke drives the
  real deposit flow (row shows outstanding → record ₱50 → outstanding drops 50).

Tests: admin logic 9 → 11, api-client 14, all type-checks + next build clean.
5/5 e2e green (geocode, portal weigh, rider-cash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admin zone management (v1) so ops can define coverage without code. Plus a real
CORS bug the browser smoke caught.

- Admin /zones page (new "Zones" nav): create a zone by entering boundary points
  (lat,lng per line) with a live SVG shape preview, list zones with an SVG
  thumbnail + point count, activate/deactivate toggle. lib/zones (parseVertices,
  polygonSvgPoints) + spec. A full map-tile draw editor is the follow-up; this
  form-based v1 is fully testable now.
- DELETE /admin/zones/:id (ops can remove a bad zone; also smoke cleanup).
- domain ZoneView/ZoneVertex/CreateZoneBody; api-client getZones/createZone/
  setZoneActive.

Bug the smoke caught + fixed:
- Fastify's default CORS preflight omits PATCH/DELETE, so every browser PATCH
  (zone toggle, address edit) and DELETE (address/zone delete) was CORS-blocked
  ("Method PATCH is not allowed"). main.ts enableCors now lists methods
  explicitly (GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS). POST was unaffected, which
  is why earlier mutations passed.

Tests: admin logic 11 → 17, api 166 unit; e2e admin-zones smoke (create from
points → appears Active → toggle Inactive). Type-checks + next build clean, 6/6
e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigated the 3 browser-smoke-caught bug classes (role-gating, empty-body,
CORS) with parallel auditors. Verdict: all 4 original bugs are correctly fixed
and NO live siblings remain. Applied defense-in-depth so each class can't recur.

- Empty-body (P1): the 500 fires in Fastify's json parser BEFORE Nest, so the
  client fix was discipline-only. Override the json content-type parser to treat
  an empty body as undefined (create with bodyParser:false so ours is the only
  one). Closes the class server-side — any client (curl, third-party) sending
  content-type:json with no body now works; malformed json still 400s.
- CORS (P2): pin allowedHeaders (authorization, content-type, x-dev-uid,
  idempotency-key) explicitly — it worked only via @fastify/cors reflection, so
  a future header lockdown could silently re-break PATCH/DELETE. Guard the
  origin:'*' + credentials foot-gun (never echo * with credentials).
- Role-gating (P3): widen the 2 latent narrow gates (same shape as the preview
  bug, no caller yet): GET /shops (CUSTOMER-only → any-authenticated catalog),
  GET /geocode (CUSTOMER+ADMIN → any-authenticated utility). Non-sensitive reads;
  route-role matrix + e2e updated.
- DI (P4): no code change needed — token already isolated in maps.constants.ts;
  convention documented there.

Regression: e2e empty-body.spec (explicit content-type:json + empty body on
pay-cash → not 500) guards the SERVER fix independent of the client. Verified
live: empty-body 404 not 500, malformed 400, shops/geocode any-auth 200,
preflight allows all methods+headers. 166 unit, 7/7 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unlocks browser-smoking the customer app (Expo web + real Firebase auth), the
piece the harness was missing. Now all four web surfaces are smoked.

- Customer app added to the Playwright webServer set (Expo web on :3000, long
  first-bundle timeout).
- lib/customer.customerLogin: signs in with the seeded Firebase email/password
  account (works headless — email/password needs no reCAPTCHA) and lands on the
  dashboard.
- customer-book.spec: login → dashboard → Book → pick a load (Medium) → type a
  pickup → "Find this address" (TomTom geocode) → Continue → Checkout resolves
  the nearest shop, shows the Closest badge + a peso total. The whole book→quote
  path, end to end in a real browser.
- ui Card now forwards testID → data-testid on web (helps all smokes); book
  buckets tagged bucket-S/M/L.

8/8 e2e green across customer / portal / admin / API. Type-checks clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last web surface still on the old plain-blue styling. Brings it to the same
tokenized quality as the admin dashboard — no behavior change (weigh smoke still
green).

- Tokenized globals.css: light + dark palette via CSS custom properties, plus
  the shared card / page-head / field-input / toast / tnum classes.
- Top-bar shell (single-view console, so a header not a sidebar): dark-navy
  brand bar + role chip, centered max-width content.
- theme.ts chrome colors → CSS vars (brand + semantic stay literal for alpha).
- page.tsx: page-head, carded orders, tabular-nums money, tokenized weigh form +
  status buttons. All smoke-critical text + data-testid preserved (Preview price,
  Confirm weigh-in, Mark <status>, Weighed Nkg, order-<code>).

Type-check + logic tests + next build clean; portal weigh e2e green on the
redesign.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the /review of the separated slice PRs. (The NODE_ENV, remittance-double-pay,
and idempotency-scope findings the reviewers raised were on frozen historical
slices — already fixed at HEAD.)

P2:
- assignRider (manual admin dispatch) now gates on a VERIFIED rider profile, not
  just the RIDER role — parity with auto-dispatch, so a draft/rejected rider who
  hasn't proven license/ID is never handed platform cash. (repo.isRiderVerified)
- zones.isCovered fails CLOSED when zones exist but all are deactivated (was
  falling back to the full pilot ring, silently reopening coverage). Distinguishes
  empty-table (bootstrap → pilot ring) from all-off (admin closed → no coverage).

P3:
- orders ?status query validated via ParseEnumPipe (garbage → 400, not Prisma 500).
- onboarding proof keys (shop permit/photos, rider license/id) must live under the
  caller's uploads/<uid>/ prefix — a crafted key can no longer point the admin's
  presigned-GET at another user's private object. (shared assertOwnedKey)
- rider backfill migration: existing RIDER users get a VERIFIED profile (idempotent)
  so the new dispatch gate doesn't strand pre-existing riders.
- shop-facing remittance strips paidByUid (an admin's Firebase UID).
- admin-shops addService/addMember map the concurrent-dup P2002 to 409, not 500.
- /geocode/search throttled 20/min (billable TomTom call, was global-only).
- platform-config envNum accepts a deliberate 0 (presence check, not !== 0).

Noted, not fixed (dev-only / negligible): auth bearer-before-bypass dev 500,
capacity-race returns 409, pagination cursor positional side-channel.

290 api unit tests. Integration + backfill migration validated by CI (local Docker
unavailable this session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to appealing-perfection / production July 25, 2026 13:28 Inactive
@vercel
vercel Bot temporarily deployed to Preview – wash-and-go-landing July 25, 2026 13:28 Inactive
@vercel
vercel Bot temporarily deployed to Production – wash-and-go-admin July 25, 2026 13:28 Inactive
Non-scroll screens (login) were a plain View with no keyboard dismissal — tapping
outside an input left the keyboard stuck. Wrap non-scroll bodies in
TouchableWithoutFeedback(Keyboard.dismiss) (buttons still handle their own touch),
and add keyboardDismissMode=on-drag to scroll screens. Fixes it everywhere the
shared Screen is used.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to appealing-perfection / production July 31, 2026 06:43 Inactive
@vercel
vercel Bot temporarily deployed to Production – wash-and-go-admin July 31, 2026 06:43 Inactive
@vercel
vercel Bot temporarily deployed to Preview – wash-and-go-landing July 31, 2026 06:43 Inactive
Replaces the ad-hoc inline red error `<Text>` scattered across the apps with one
shared toast: green = success, red = error, slide-in from the top, tap or
auto-dismiss.

- packages/ui: ToastProvider + useToast() (toast.success/error/info), animated,
  safe-area aware. Wired into both apps' root _layout.
- customer app: login, checkout, book (+ the geocode "could not search" error),
  change-laundry, addresses, profile, notifications, order detail (cancel/rate)
  — every transient error → toast.error; success paths (address saved/removed,
  booking cancelled, rating thanks, address pinned, all-caught-up) → toast.success.
- rider app: login, job detail (status transition + cash) → toasts.
- Full-screen load-failure ErrorState (with retry) intentionally kept — those
  aren't transient alerts. Sign-out confirm Alert kept (it's a confirmation).
- type-clean: ui + both apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to appealing-perfection / production July 31, 2026 06:54 Inactive
@vercel
vercel Bot temporarily deployed to Preview – wash-and-go-landing July 31, 2026 06:54 Inactive
@vercel
vercel Bot temporarily deployed to Production – wash-and-go-admin July 31, 2026 06:54 Inactive
- auth: _layout now calls postSession whenever Firebase restores a session, not
  only on the login-form submit. An account whose first postSession failed (e.g.
  wrong API URL during setup) was left with a Firebase account but no Postgres
  row → "User not found" 401 on every screen. Idempotent upsert fixes it on the
  next reload.
- empty states: notifications now uses the friendly EmptyState (🔔) and the
  saved-addresses empty gets a centered 📍 block, matching the orders "No orders
  yet" screen — so a brand-new account reads as "nothing here yet", not an error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to appealing-perfection / production July 31, 2026 06:59 Inactive
@vercel
vercel Bot temporarily deployed to Production – wash-and-go-admin July 31, 2026 06:59 Inactive
@vercel
vercel Bot temporarily deployed to Preview – wash-and-go-landing July 31, 2026 06:59 Inactive
Banyel3 and others added 10 commits July 31, 2026 15:01
Tapping "Use my current location" with location denied showed a passive error
toast. Now it prompts: if iOS can still ask, a simple heads-up; if it was
permanently denied, an "Open Settings" action (the only way back). Treats a
missing permission as "turn it on", not "you did something wrong".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k title

- the "Book a wash" empty-state action went to home (router.replace('/')); now it
  pushes /book directly, same as home's Book-a-wash CTA.
- header back button was showing the group route name "(tabs)" as its title;
  set headerBackButtonDisplayMode 'minimal' (chevron only) across the stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /geocode/reverse?lat&lng -> { label } via the TomTom provider's reverseGeocode
(already existed, just needed a route). Any-authenticated, throttled 30/min
(billed TomTom call), validates coords -> 400. api-client.reverseGeocode(). Backs
the "drop a pin -> show its address" map picker. 26 geocode tests; curl-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t address

Typing an address for a GPS pickup service was unreliable and produced two
conflicting buttons ("Use my location" vs "Find this address" set different
points). Replaced with one map picker:

- MapPicker (react-native-webview + Leaflet + TomTom raster tiles, EXPO_PUBLIC_
  TOMTOM_MAP_KEY): Grab-style centre pin, opens on device GPS (falls back to
  Zamboanga), pan to place the pin, debounced reverse-geocode (/geocode/reverse)
  labels it, "Use this location" returns { lat, lng, address }. Reuses the TomTom
  key — no Google Maps key, cross-platform, matches the admin map's tile approach.
- book.tsx: dropped the address TextInput + the two location buttons for a single
  "Set pickup on map"; shows the pinned address in a card.
- addresses.tsx: the add form now pins the location on the map (and finally SAVES
  lat/lng — the old text-only form saved addresses with no coordinates, useless for
  a pickup service).

NOTE: react-native-webview is a native module → run `npx expo run:ios --device`
once to pick it up (JS reload won't include it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se, fix flicker

Investigation (/plan-eng-review): the two mobile apps differ by role, so parity
means sharing the UX *primitives*, not the screens. The map was customer-only and
duplicated; rider navigated to a fuzzy text address instead of the exact point the
customer pinned.

packages/ui — one map stack, two interactions:
- leaflet-map.ts: shared Leaflet-in-WebView html + pluggable tile presets
  (mapbox @2x, maptiler @2x, tomtom, osm) resolved from env via resolveTiles().
  Tiles are the only HD lever; provider swaps with one env var, no code change.
- MapPicker: refactored to take an injected `tiles` + `reverseGeocode` (api-
  agnostic). FIX: memoise the WebView `source` — an inline source object was a
  new ref every render, so RN WebView reloaded the map, which re-posted its
  centre, which re-rendered... the endless "Locating… <-> address" flicker.
- MapView (new): read-only pin + coord Navigate, for the rider.
- 11 unit tests for the tile resolver + html builder.

rider-mobile:
- orders/[id]: embeds MapView on the pickup, and Navigate now uses the exact
  pickupLat/Lng (falls back to text only for older orders with no pin). A text
  address can resolve blocks away — the whole point of the map picker was the
  coords, and the rider was throwing them away.
- added react-native-webview + expo-location.

customer-mobile:
- components/MapPicker is now a thin wrapper injecting api.reverseGeocode + the
  env tile provider into the shared UI picker.

api — reverseGeocode gains a keyless OSM Nominatim fallback: the TomTom dev key
has Maps (tiles) scope but not Search, so reverse always returned null and the
picker showed raw lat/lng. Nominatim is keyless AND permits storing the result
(Mapbox geocoding forbids storage — we persist the label), so it's the correct
provider for our use. TomTom stays primary when its Search scope is enabled.

Tile provider (opt-in, no card needed for the free HD path):
  EXPO_PUBLIC_MAP_TILE_PROVIDER=maptiler|mapbox|tomtom|osm
  EXPO_PUBLIC_MAPTILER_KEY / EXPO_PUBLIC_MAPBOX_TOKEN / EXPO_PUBLIC_TOMTOM_MAP_KEY

NOTE: react-native-webview + expo-location are native — rebuild both apps
(`npx expo run:ios --device`) to pick them up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flicker: the memoised source wasn't the whole story — Leaflet fires `moveend`
repeatedly with the SAME centre during the modal slide-in and tile settle, and
each one flipped the label to "Locating…" and re-fired the reverse-geocode.
Dedupe on the rounded centre (5dp ~1m): identical centres are ignored, so no
setState, no re-render, no flicker. Reset the dedupe key when the sheet reopens.

HD: added keyless CARTO Voyager tiles (free, no signup) that serve @2x retina
via Leaflet's {r} token — noticeably sharper than OSM/TomTom basic. resolveTiles
now defaults to CARTO and only uses TomTom when explicitly asked (provider=tomtom),
so a stray EXPO_PUBLIC_TOMTOM_MAP_KEY no longer pins the map to low-res. Paid
@2x keys (MapTiler/Mapbox) still win when set.

12 ui tests (was 11), type-clean.

NOTE: JS-only (no native change) — just reload Metro to pick this up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…slop jobs

/design-review found the rider app read "empty" next to the customer app. Root
causes: no bottom navigation (customer has Home/Orders/Profile tabs; rider was a
flat stack), and a duplicated "My jobs" title (native header + an in-content H1)
that ate the top third of the screen.

- (tabs)/_layout.tsx: bottom tab bar mirroring the customer app — Jobs
  (briefcase), Cash (wallet), Profile (person). Job detail stays a stacked screen
  pushed from Jobs, so it's out of the bar.
- Moved index.tsx + cash.tsx into (tabs)/. Cash was a tiny top-right text link
  (sub-44px target, easy to miss) — now a real tab.
- (tabs)/profile.tsx (new): the rider had NO sign-out anywhere and no identity
  screen. Profile shows the signed-in rider, a verification badge (VERIFIED /
  pending / rejected from /rider/onboarding), and Sign out.
- Jobs screen: dropped the redundant in-content "My jobs" title (the tab header
  already says it); leads with the live count instead. Kills the empty gap.

Verified live in the iOS simulator (dev-rider-1): tab bar renders, jobs screen
no longer double-titled. Type-clean, 7 rider tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A DELIVERED or CANCELLED job is read-only, but the detail screen still showed
Call, Navigate, and the pickup map — a rider could ring a customer or route to a
cancelled pickup. Gate those affordances on !isTerminal(status); keep the
addresses visible for reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Riders had no on-app explanation of the COD money flow. Added a "How payments
work" card: you hold the customer's cash for the platform, deposit it back
(GCash / ops) which lowers what you owe, delivery earnings settle weekly, and
new jobs pause if the owed balance climbs too high (the debt cap, enforced next).
No self-deposit endpoint — deposits stay ops-recorded until the cap lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…much

Prevents a rider accumulating unbounded platform cash (COD they collected but
haven't deposited) and absconding. New platform-config field riderCodCapPhp
(default ₱1,500, env RIDER_COD_CAP_PHP): once a rider's outstanding COD
(collected − deposited) reaches the cap, they stop receiving new jobs until
they deposit.

- platform-config: riderCodCapPhp added to CONFIG_FIELDS, defaults, and the
  grouped values; migration adds the column (default 1500).
- orders.repository: riderOutstandingCod(rider) for the manual gate;
  pickAutoDispatchRider now filters over-cap riders (batched two-groupBy, no
  per-rider round-trip) so auto-dispatch skips them too.
- orders.service.assignRider: rejects an over-cap rider (parity with the
  auto-dispatch filter) — BadRequest naming the limit.
- rider-cash: /me/cash now returns capPhp; the Cash tab shows "new jobs pause
  once you owe ₱X" and flips to "over the limit — deposit to resume" when hit.

Note: ₱100 (as floated) would block a rider after one order — a single wash's
COD exceeds it. ₱1,500 (~4-7 orders) bounds abscond loss while allowing a normal
batch. The ~₱100 "carry allowance" (auto-net small debt vs weekly earnings) is a
separate remittance-netting feature, deferred.

New tests: over-cap assign rejected, just-under-cap allowed, balance carries the
cap, config default. 136 api unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant