Skip to Content
Living documentation — last reviewed 2026-05-28
ScratchQA runbook — cancellation notice window (taikan#353 + taikan-mobile#93)

QA runbook — cancellation notice window (taikan#353 + taikan-mobile#93)

Hands-on local pass over wave 1. Everything below is already set up on this machine; the reset command in §2 is the only thing you need between passes.

1. What is running

PieceWhere
APIhttp://localhost:3001 — hub process api, worktree /Users/saarku/.omp/wt/353-d8ef6db (branch pr-353)
Webhttp://localhost:3000 — hub process web, same worktree
DBdocker taikan-postgres-1, taikan_dev, migrated through 0123_late_shatterstar
Mobile/Users/saarku/dev/taikan-mobile, branch pr-93 (PR #93 rebased onto its main)
QA orgTaikan DEV 46afb5b2-f804-4e49-90b7-692c03c21dd1
Staff login[email protected] (owner)

Cancellation retention, settlement, provider standing-order cancellation and form prompting are always-on. Use an isolated QA environment with test hooks enabled; no rollout overrides are required. Preserve unrelated local feature settings.

The written cancellation form requires a published membership_cancellation template. Without an available form, the response carries a null cancellationFormInstanceId and notice still succeeds. When an instance is available, the member receives the prompt and the response routes them to signing. A missing signature never delays the effective date.

Member legal acceptance is separate: current member terms use the existing /accept-terms gate, not this compliance form. Already-current privacy and fitness consent stays valid. Staff creation/invitation does not record member acceptance; members accept at first login.

CRONS_ENABLED=false, which is why the four sweeps are driven by hand (§3).

Emails really send. EmailService only buffers instead of sending when NODE_ENV=test; in dev it calls Resend for real. The escalation cron and a notice no longer mail anyone directly — Wave 1 replaced both (noticeGivenForStaff/caseEscalatedForStaff) with one daily digest (retentionDigestForStaff), addressed to the org’s owner/admins — i.e. your own inbox, which is useful here. presaleWithdrawnForStaff and presaleChargeFailedForStaff still mail immediately, but now to the case owner alone (falling back to the org’s active owner) rather than the whole staff list. Member mail goes to seed.mix+*@taikan.test, which Resend rejects and the API logs; harmless.

legal_documents must not be empty, or the mobile app dead-ends. The mobile AuthGate reads GET /legal/consents/status and counts a MISSING row per required type (terms_of_use, privacy_policy, fitness_waiver) as “consent owed”, so an empty table means every member is bounced to /onboarding/accept-terms — where accepting posts consents for documents that don’t exist, records nothing, and bounces them again. The web app doesn’t share the bug: it gates on /users/me.pendingLegalConsents, which counts missing documents and is 0 when the table is empty. taikan_dev shipped empty; three rows are now in place:

INSERT INTO legal_documents (type, version, locale, url, published_at) VALUES ('terms_of_use', '2026-01', 'he', 'https://taikan.fit/legal/terms', now() - interval '30 days'), ('privacy_policy', '2026-01', 'he', 'https://taikan.fit/legal/privacy', now() - interval '30 days'), ('fitness_waiver', '2026-01', 'he', 'https://taikan.fit/legal/waiver', now() - interval '30 days');

getConsentStatus picks one document per type (DISTINCT ON (type) ordered by published_at DESC), so one row per type is enough and extra locales would be dropped arbitrarily.

2. Seed / reset

cd /Users/saarku/.omp/wt/353-d8ef6db pnpm seed:cancellations

Idempotent: it wipes its own previous batch (everything it makes is marked — plans and the plan group by the QA Notice · name prefix, tasks by a [seed:cancellation-notice-window] marker) and recreates it, anchored to the current clock. It never touches a member or user row. Re-run it any time a scenario has been consumed. It prints the scenario table with the fresh subscription ids — keep that output next to this file.

It also creates the org’s payment-provider config (test, active) so the refund surfaces can resolve an adapter.

3. Manual cron triggers

S='x-test-seed-secret: local-qa' curl -s -X POST localhost:3001/testing/trigger-retention-escalation -H "$S" curl -s -X POST localhost:3001/testing/trigger-retention-digest -H "$S" curl -s -X POST localhost:3001/testing/trigger-cancellation-sweep -H "$S" curl -s -X POST localhost:3001/testing/trigger-refund-task-sweep -H "$S"

Verified on the seeded data:

  • escalation → {"checked":6,"escalated":3}, and a second run → escalated:0 (the audit claim + advisory lock make it idempotent) — this no longer sends mail (Wave 1); it only claims the audit row and emits the observability event the digest below reads from
  • digest → {"orgsChecked":1,"sent":1}, and a second run the same day → sent:0 (the RETENTION_DIGEST_SENT audit claim + advisory lock make it idempotent). This is the one that actually mails you now.
  • refund sweep → {"bumped":["<taskId>"]}, second run → []
  • cancellation sweep → {"cancelled":[]} until something is actually due; see scenario 12 for how to make one due

4. Staff pass (web, http://localhost:3000/en/dashboard)

Members live under Members; the ending queue is /en/dashboard/members?paymentStatus=ending. The Action Center card is on the overview.

  1. Fresh case, inside SLA — seed.mix+monthly-credits. Members → open the member → the retention panel shows an open case, unacknowledged, SLA deadline 48h out. Tasks list has an urgent cancellation_review task assigned to the owner. Press Acknowledge, then Log contact — both should stamp and the panel should reflect it without a reload.
  2. Escalation, unacknowledged — seed.mix+pack-credits (notice and case task backdated 3 days). Run the escalation trigger: escalated counts this row, no mail (Wave 1 retired the per-event escalation email), and a second run does not re-count it. Run the digest trigger next — this row shows up under “Past SLA, still unacknowledged” in the one digest email that lands in your inbox.
  3. Escalation, final week — seed.mix+pack-expired (acknowledged, effective in ~4 days, never contacted). Same escalation trigger fires the uncontacted_final_week stage (audit + event only, no mail); the digest trigger puts it under “Ending within 7 days, no contact logged”. The Action Center card (members.notices_open) should be urgent because of this row, and the English title must read “N open retention cases” / “1 open retention case” — switch the dashboard to he and ru and confirm the plural forms.
  4. Quiet case — seed.mix+pack-spent (acknowledged + contacted). Counts in the card’s total, escalates in neither stage.
  5. Paused member withdraws — seed.mix+paused. The retention panel’s Withdraw notice must be allowed on a paused subscription (this was a P0 fix) and the notice fields must clear.
  6. Externally billed refuses — seed.mix+past-due, on QA Notice · External billing. First switch the org’s active provider to external in Settings → Payments (only one config can be active, and isExternallyBilled reads the active one). Then: Withdraw notice must refuse with the externally-billed message, and so must recording a saved_* outcome — that second refusal is the review fix; before it, an outcome silently un-cancelled a subscription Taikan cannot bill. Switch the provider back to test afterwards.
  7. Money summary ignores a failed charge — seed.mix+debt: newest transaction is a failed recurring row, an older completed charge sits mid-period. Open the staff cancel dialog → the money summary must quote the completed ₪ amount and offer that as refundable. (Pre-fix it quoted the failed row — money that never arrived.)
  8. Already refunded — seed.mix+dropin-unused. Cancel dialog shows “already refunded”, nothing refundable.
  9. Second notice starts clean — seed.mix+join-never-paid carries a stale saved outcome and no live notice. File a fresh notice from the staff cancel dialog (pick an initiator + reason code). The retention panel must come up empty — no inherited acknowledge/contact timestamps, no saved outcome — and the row must appear in the ending queue and in the Action Center count. (Pre-fix the card ignored it forever.)
  10. Refund task sweep — seed.mix+no-plan owns two overdue manual_refund tasks; one is soft-deleted. Run the refund trigger: exactly one task flips to urgent, the soft-deleted one is untouched, and a second run bumps nothing.
  11. Presale opening-day failure case — seed.mix+unlimited, a scheduled row with retentionCaseKind = presale_charge_failed and an open urgent payment_follow_up. The case panel must render this shape (kind is now set on the first decline — before the fix the case only appeared once it had been written off as lost_payment).
  12. Period-end sweep — make a row due, then sweep:
psql() { docker exec taikan-postgres-1 psql -U postgres -d taikan_dev -tAc "$1"; } SUB=$(psql "select s.id from subscriptions s join memberships m on m.id=s.membership_id join users u on u.id=m.user_id where u.email='[email protected]' and s.cancel_at_period_end and s.deleted_at is null limit 1") psql "update subscriptions set cancellation_effective_at = now() - interval '1 day', current_period_end = now() - interval '1 day' where id='$SUB'" curl -s -X POST localhost:3001/testing/trigger-cancellation-sweep -H "$S" psql "select status, retention_outcome from subscriptions where id='$SUB'"
Expect `cancelled | lost_contacted` (`lost_no_contact` without logged contact). The sweep attempts the provider's standing-order cancellation under the existing provider guards; failures produce a reminder task and do not prevent cancellation.

5. Mobile pass

Branch pr-93 is checked out and rebased, .env is written with the web Clerk key and EXPO_PUBLIC_API_URL=http://localhost:3001, and pnpm install has run with its build scripts approved.

cd /Users/saarku/dev/taikan-mobile pnpm start # then `i` for the iOS simulator

Metro boots and serves exp://192.168.68.56:8081 (verified).

  • iOS simulator: http://localhost:3001 works as-is.
  • Android emulator: set EXPO_PUBLIC_API_URL=http://10.0.2.2:3001.
  • Physical device over Expo Go: http://192.168.68.56:3001 (this Mac’s LAN IP) for both EXPO_PUBLIC_API_URL and EXPO_PUBLIC_WS_URL.
  • EXPO_PUBLIC_* is inlined at bundle time — restart expo start after any .env edit. No native change in this PR, so Expo Go / the existing dev client is enough.

Two install-time gotchas, both already handled but worth knowing:

  • pnpm-workspace.yaml carried pnpm 11’s unresolved allowBuilds: scaffold ('@clerk/shared': set this to true or false, …). pnpm refuses to install while any entry is unresolved, and since every pnpm <script> runs a deps-status check first, that error blocked pnpm start, pnpm typecheck and pnpm test too — including pnpm approve-builds itself. The eight entries are now set to true (uncommitted, on pr-93); the committed file still has only the legacy onlyBuiltDependencies list, which pnpm 11 ignores. Worth its own commit on main, not this PR.
  • @desmotech registry auth: pnpm 11 refuses to expand ${GITHUB_TOKEN} from a committed project .npmrc, so export GITHUB_TOKEN=… does nothing for it. node_modules is already complete, so this only bites a cold install; if you need one, run pnpm config set "//npm.pkg.github.com/:_authToken" "$NPM_TOKEN" to put the credential in your user-level ~/.npmrc.

pnpm typecheck is red on 5 pre-existing errors in app/(auth)/sign-in.tsx (@clerk/types is imported but never installed) — identical on origin/main, unrelated to either PR. pnpm test: 708/709, the one failure (maps a no-card renew failure…, a 5s timeout) also fails on origin/main.

Two seeded accounts (the only members with a Clerk id):

A. [email protected] — active QA Notice · Monthly, mid-period, card on file, one completed charge behind it.

  1. Profile → subscription card → give notice. The sheet shows a “common reasons” chip row (7 chips). Tap one — it fills teal; tap again — it clears; only one at a time. Free text is independent and both are optional.
  2. Confirm. The banner must show the effective date the server returned. Cross-check on the web dashboard: the member now carries an urgent case and shows up in ?paymentStatus=ending with the reason code you picked.
  3. Tap Keep my subscription to withdraw. The banner clears, and the web panel records the withdrawal (staff side shows it was the member’s own act).
  4. Repeat step 1 with no chip and no text — the notice must still succeed.
  5. Switch the app to Hebrew, then Russian, and re-open the sheet: chips and the banner copy must be localized (no English fallback), and the chip row must lay out right-to-left under Hebrew.

B. [email protected] — scheduled presale on QA Notice · Presale monthly inside the QA Notice · Presale group, opening day in 21 days.

  1. Payments → the scheduled card must now offer Change plan (it only appears because the server lists change_plan in memberActions for a grouped scheduled row).
  2. The target list must contain only same-group plans — QA Notice · Presale annual and nothing else.
  3. Pick the annual variant. The summary must read “Nothing is charged now: your first charge on {opening day} will be {amount}” — no due-now amount. Confirm: success alert, back navigation, no hosted checkout page. The row keeps its id, its seat and its opening day; only the plan changed, and any negotiated desk price on it is cleared (a review fix — otherwise a ₪200 discount rode onto the ₪3000 variant).
  4. The annual variant has max_purchases = 1, so after step 8 its cap is full: a second attempt at the same swap from another member must refuse with the sold-out message rather than overselling. That refusal is now taken inside a transaction — pre-fix two concurrent swaps could both pass.
  5. Withdraw the presale from the app → the member gets the withdrawal email and a retention case opens on the staff side (presale_withdrawal kind).
  6. Refusal copy check: presale_swap_requires_same_group and plan_change_charge_in_flight must render their own localized sentences, not the generic error (their dictionary entries were missing until the review pass).

6. Settlement pass

Run the API from the intended isolated worktree using its own database and test configuration. Commands below assume http://localhost:3001 points at that API. No rollout settings are needed.

sweepDueCancellations now calls settleCancellationAtEffectiveDate on every row it matures: the same computeCancellationRefund as the preview, fed the same findPeriodCharge row, but evaluated against the row’s own cancellation_effective_at rather than the sweep’s run date. It only ever refunds money owed back — the member’s paid period must end after their effective date, otherwise it returns silently (ST5; nothing on this path can charge a member). Where the money goes is decided by RefundCapabilityService.resolveExecution(orgId) on the org’s ACTIVE provider config: 'unavailable' (external billing, or a provider with no adapter — which test is) opens a localized, owner-assigned task naming member, plan, amount and date and touches no money; anything else goes through PaymentService.refund. Before any of that it writes a subscription.cancellation_settled audit claim, under a per-subscription advisory lock, and it skips rows whose paying charge is already refund_pending/refunded — three layers so an overlapping or re-run sweep can never pay twice. The member’s “membership ended” email now carries the real refund state, and it is the only email about it.

Why the wave-1 fixtures can’t reach it, and what the harness does instead

Every wave-1 fixture (seed personas and qa:scenario due) sets cancellation_effective_at = current_period_end, so refundableInCents is 0 and settlement is silent by design. The harness now has a fifth plan, QA Notice · Annual (₪3,600 / yearly, created by the same ensureQaNoticePlans helper the other four come from — a wave-1 database gains it on the first run without duplicating the others), and four settle-* scenarios that put a member on it with a year paid 60 days ago, notice given 31 days ago and an effective date of yesterday, leaving ~10 months of paid period unused. Each prints the amount the sweep must return, computed by importing the API’s own computeCancellationRefund (never a literal):

floor(360000 × 306 / 365) = 301808 cents = 3018.08 ILS

306 = ceil((current_period_end − cancellation_effective_at) / day). The harness also prints what a sweep that prorated against its run date would say (₪3,008.21 — one day fewer); seeing that figure is the ST2 bug.

Which branches are reachable locally

Branch (resolveExecution)Org active providerLocal?How
'unavailable' → owner task, no refund (ST4)test (no adapter) or externalyessettle-delegated
'automatic' → refund really recorded (ST2)manualyessettle — PaymentService.refund routes a provider='manual' charge to ManualPaymentService.refund, which writes the refund row and flips the charge to refunded with no gateway in the loop. The credit document fails softly (document_error = “Provider ‘manual’ cannot issue documents…”) because no adapter can issue one; expected.
Refund attempted and thrown after the claim (ST10 shape)manualyessettle-failed — a card (test) charge with no provider_transaction_id under a manual org: PaymentService.refund refuses it deterministically (“Transaction has no provider transaction ID”) before any adapter is touched.
Already settled — the two idempotency guards (ST6/ST7)manual (so a failed guard would move money)yessettle-already (--layer charge = paying charge already refunded; --layer claim = a prior subscription.cancellation_settled row)
'task' → manual_refund task chased by the refund sweep (ST3)a gateway with manual refund capabilitynoneeds a real provider config; unit-covered only
'automatic' through a live gateway (Cardcom)cardcomnoneeds real credentials; unit-covered only
Two sweeps overlapping on one row (ST8, advisory lock)anyno by handthe trigger endpoint runs one sweep at a time; integration-covered

The provider switch is part of the scenario. settle, settle-already and settle-failed make manual the org’s one active config (creating the row on first use, deactivating every other — same as Settings → Payments does); settle-delegated and every wave-1 command switch back to test. state is read-only and touches nothing, so run it freely between seeding and sweeping. The STATE block prints the active provider on its second line — if it isn’t the one the scenario named, the sweep will take the wrong branch. pnpm seed:cancellations deletes and recreates only the test row; run any wave-1 harness command (e.g. clean) before re-seeding while manual is active, or you end up with two active configs and the API picks one at random.

The sweep and the provider are org-wide. Seeding a settle-* on member b while member a still holds a due row settles both in one run, under the provider the later scenario set; the harness warns when this is the case. clean the other member first for a single outcome to read.

A real bug, fixed on this branch, that settle is the proof for. At f827531b the settlement refunded as the member (actorClerkId was the member’s Clerk id), and ManualPaymentService.refund enforces payments:manage on the actor — a member has none — so on every cash/manual org the audit claim landed, the refund threw Forbidden, the failure went to a PostHog event nobody watches, and the row was marked settled forever with the money still owed. The branch now refunds as the org’s active owner (falling to the delegated task when there is none) and turns a thrown refund into a high-priority owner task that says Taikan tried and failed. settle must show the refund row; settle-failed must show that task. Pre-fix, settle shows the claim, no refund row, no task, and Settlement failed … Forbidden in the API log.

Scenarios

Two fixtures per member max (one at a time, like the rest of the harness). Each prints WEB / MOBILE / NEXT with the exact ids and amounts.

CommandLook atExpected after trigger-cancellation-sweep
pnpm qa:scenario settle --member amember’s Payments tab; state{"cancelled":["<sub>"]}; status cancelled; a NEW refund/completed manual/cash row of exactly ₪3,018.08 with note “Cancellation settlement at effective date” and document_error set; the ₪3,600 charge now refunded; one claim mode=refund execution=automatic; no open task. Sweep again: {"cancelled":[]}, state unchanged (ST6).
pnpm qa:scenario settle-delegated --member bDashboard → Tasks; statestatus cancelled; transactions untouched (charge/completed, no refund row); one claim mode=delegated execution=unavailable; ONE new general task, priority high, assigned to [email protected], Hebrew title החזירו 3,018.08 ₪ לסער עבור QA Notice · Annual (Intl he-IL puts the ₪ after the number), due in 3 days. Sweep again: nothing new.
pnpm qa:scenario settle-already --member a (--layer charge, default)state before vs afterThe ONLY differences: status=cancelled and the retention outcome. Still exactly two transactions (charge/refunded + the seeded refund/completed), still no claim, no task. A new refund row here is a double refund of real money.
pnpm qa:scenario settle-already --member a --layer claimstate before vs afterSame rule. Still one charge/completed, still exactly the one seeded claim (marked “(seeded by this harness)”), no refund row, no task — the claim alone stopped it.
pnpm qa:scenario settle-failed --member aAPI log; Dashboard → Tasks; statestatus cancelled; log line Settlement refund failed for subscription <sub>: Transaction has no provider transaction ID; one claim mode=refund execution=automatic; charge still completed, no refund row; ONE new high general task whose body says Taikan tried to refund and the attempt failed. Sweep again: no second claim, no second task — the claim is not retried, the task is the retry.
ST1 — flag OFFsee belowmembership ends, nothing else.

state --member <x> after the sweep shows every column the table names: transactions with provider/method/document_error, the claim rows with amount/mode/execution, and any task the API opened (flagged “(opened by the API, not this harness)”). The wipe at the start of every scenario now also removes those API-opened general tasks and the subscription’s cancellation_settled claims, so re-running a scenario always starts from zero.

psql proof

psql() { docker exec taikan-postgres-1 psql -U postgres -d taikan_dev -tAc "$1"; } ORG=46afb5b2-f804-4e49-90b7-692c03c21dd1 SUB=<id printed by the scenario> # The row the sweep selects, and why money is owed (both must be t): psql "select status, cancel_at_period_end, deleted_at is null as live, cancellation_effective_at <= now() as due, current_period_end > cancellation_effective_at as owes, ceil(extract(epoch from (current_period_end - cancellation_effective_at))/86400) as days_remaining from subscriptions where id='$SUB'" # The amount, the way computeCancellationRefund does it (365-day year): psql "select floor(t.amount_in_cents * ceil(extract(epoch from (s.current_period_end - s.cancellation_effective_at))/86400) / 365) from subscriptions s join payment_transactions t on t.subscription_id=s.id where s.id='$SUB' and t.type in ('charge','recurring') and t.status in ('completed','refund_pending','refunded')" # After the sweep — the refund row (settle) / its absence (every other scenario): psql "select type, status, provider, method, amount_in_cents, note, document_error from payment_transactions where subscription_id='$SUB' order by created_at" # The idempotency claim — exactly one row for settle/delegated/failed, none for settle-already --layer charge: psql "select created_at, metadata->'after'->>'amountInCents' as amount, metadata->'after'->>'mode' as mode, metadata->>'execution' as execution, metadata->>'qa' is not null as seeded from audit_events where organization_id='$ORG' and action='subscription.cancellation_settled' and target_type='subscription' and target_id='$SUB'" # The owner task (settle-delegated / settle-failed) — exactly one, localized, assigned to the owner: psql "select t.priority, t.status, t.due_date, o.email as assignee, t.title from tasks t join users o on o.id=t.assignee_id where t.organization_id='$ORG' and t.type='general' and t.source='auto' and t.linked_user_id=(select m.user_id from subscriptions s join memberships m on m.id=s.membership_id where s.id='$SUB') and t.deleted_at is null" # The sweep's own trail, for the settle-already comparison (this one row MAY appear; nothing else may): psql "select action, created_at from audit_events where organization_id='$ORG' and target_id='$SUB' order by created_at"

ST11 — exactly one member email

Emails really send in dev (§1), and both QA members are the tester’s own Gmail aliases, so the check is the inbox: after settle + sweep, [email protected] gets one “membership ended” email stating the ₪3,018.08 refund, and no “refund completed” receipt — the sweep passes notifyMember: false, and the desk-refund path sends none of its own. After settle-delegated / settle-already / settle-failed the same single email arrives with no refund block. There is no DB trace of an email; EmailService only buffers under NODE_ENV=test, which is why the integration suite, not this pass, asserts the count. Two “membership ended” emails, or any refund receipt, is the ST11 bug.

What was verified without the API, and what is handed over

Verified against taikan_dev (2026-09-05): all four scenarios run clean twice in a row on both members and leave one subscription, its transactions and one case task per run (the previous run’s rows, tasks and claims wiped); the seeded row satisfies the sweep’s predicate (active, cancel_at_period_end, live, cancellation_effective_at <= now()); current_period_end is 306 days past the effective date; the SQL proration above returns 301808 — the same cents the harness prints; the manual config exists and is the single active one after settle, test after any wave-1 command; the settle charge is provider=manual status=completed method=cash in the QA org with paid_at and recorded_by set, which is every check ManualPaymentService.refund makes before moving money. Not verified (the API was not running): the sweep’s actual outcomes in the table above — the refund row, the claim, the task, the emails, the second-run no-op. Those are the hand-over.

7. Teardown

# stop the servers # (hub processes `api` and `web` in this session) cd /Users/saarku/.omp/wt/353-d8ef6db && pnpm seed:cancellations # or leave the fixtures in place

The seed’s wipe is marker-scoped, so leaving the fixtures in taikan_dev is harmless; nothing it creates collides with the other seed:* scripts. If a settle-* scenario was the last thing run, pnpm qa:scenario clean --member a first so the org’s active provider is test again before re-seeding (§6).