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
| Piece | Where |
|---|---|
| API | http://localhost:3001 — hub process api, worktree /Users/saarku/.omp/wt/353-d8ef6db (branch pr-353) |
| Web | http://localhost:3000 — hub process web, same worktree |
| DB | docker 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 org | Taikan 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:cancellationsIdempotent: 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(theRETENTION_DIGEST_SENTaudit 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.
- 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 urgentcancellation_reviewtask assigned to the owner. Press Acknowledge, then Log contact — both should stamp and the panel should reflect it without a reload. - Escalation, unacknowledged —
seed.mix+pack-credits(notice and case task backdated 3 days). Run the escalation trigger:escalatedcounts 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. - Escalation, final week —
seed.mix+pack-expired(acknowledged, effective in ~4 days, never contacted). Same escalation trigger fires theuncontacted_final_weekstage (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 toheandruand confirm the plural forms. - Quiet case —
seed.mix+pack-spent(acknowledged + contacted). Counts in the card’s total, escalates in neither stage. - 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. - Externally billed refuses —
seed.mix+past-due, onQA Notice · External billing. First switch the org’s active provider toexternalin Settings → Payments (only one config can be active, andisExternallyBilledreads the active one). Then: Withdraw notice must refuse with the externally-billed message, and so must recording asaved_*outcome — that second refusal is the review fix; before it, an outcome silently un-cancelled a subscription Taikan cannot bill. Switch the provider back totestafterwards. - Money summary ignores a failed charge —
seed.mix+debt: newest transaction is afailedrecurring row, an oldercompletedcharge 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.) - Already refunded —
seed.mix+dropin-unused. Cancel dialog shows “already refunded”, nothing refundable. - Second notice starts clean —
seed.mix+join-never-paidcarries a stalesavedoutcome 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, nosavedoutcome — and the row must appear in the ending queue and in the Action Center count. (Pre-fix the card ignored it forever.) - Refund task sweep —
seed.mix+no-planowns two overduemanual_refundtasks; 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. - Presale opening-day failure case —
seed.mix+unlimited, ascheduledrow withretentionCaseKind = presale_charge_failedand an open urgentpayment_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 aslost_payment). - 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 simulatorMetro boots and serves exp://192.168.68.56:8081 (verified).
- iOS simulator:
http://localhost:3001works 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 bothEXPO_PUBLIC_API_URLandEXPO_PUBLIC_WS_URL. EXPO_PUBLIC_*is inlined at bundle time — restartexpo startafter any.envedit. 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.yamlcarried pnpm 11’s unresolvedallowBuilds:scaffold ('@clerk/shared': set this to true or false, …). pnpm refuses to install while any entry is unresolved, and since everypnpm <script>runs a deps-status check first, that error blockedpnpm start,pnpm typecheckandpnpm testtoo — includingpnpm approve-buildsitself. The eight entries are now set totrue(uncommitted, onpr-93); the committed file still has only the legacyonlyBuiltDependencieslist, which pnpm 11 ignores. Worth its own commit onmain, not this PR.@desmotechregistry auth: pnpm 11 refuses to expand${GITHUB_TOKEN}from a committed project.npmrc, soexport GITHUB_TOKEN=…does nothing for it.node_modulesis already complete, so this only bites a cold install; if you need one, runpnpm 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.
- 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.
- 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=endingwith the reason code you picked. - 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).
- Repeat step 1 with no chip and no text — the notice must still succeed.
- 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.
- Payments → the scheduled card must now offer Change plan (it only
appears because the server lists
change_planinmemberActionsfor a grouped scheduled row). - The target list must contain only same-group plans —
QA Notice · Presale annualand nothing else. - 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).
- 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. - Withdraw the presale from the app → the member gets the withdrawal email
and a retention case opens on the staff side (
presale_withdrawalkind). - Refusal copy check:
presale_swap_requires_same_groupandplan_change_charge_in_flightmust 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 ILS306 = 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 provider | Local? | How |
|---|---|---|---|
'unavailable' → owner task, no refund (ST4) | test (no adapter) or external | yes | settle-delegated |
'automatic' → refund really recorded (ST2) | manual | yes | settle — 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) | manual | yes | settle-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) | yes | settle-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 capability | no | needs a real provider config; unit-covered only |
'automatic' through a live gateway (Cardcom) | cardcom | no | needs real credentials; unit-covered only |
| Two sweeps overlapping on one row (ST8, advisory lock) | any | no by hand | the 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.
| Command | Look at | Expected after trigger-cancellation-sweep |
|---|---|---|
pnpm qa:scenario settle --member a | member’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 b | Dashboard → Tasks; state | status 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 after | The 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 claim | state before vs after | Same 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 a | API log; Dashboard → Tasks; state | status 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 OFF | see below | membership 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 placeThe 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).