STATUS (checkpoint, 2026-09-02)
Worktrees: API/web /Users/saarku/dev/taikan-notice-window (branch feat/cancellation-notice-window), mobile /Users/saarku/dev/taikan-mobile-notice-window (same branch name, based on origin/main). Test DB: taikan_notice_test on port 55432 (docker taikan-postgres_test-1), migrated through 0123_late_shatterstar (renumbered from 0122 when the branch was rebased onto main’s 0122_lead_follow_ups); .env.test in the worktree points at it. Use make test-integration-api, not bare vitest (bare vitest falls back to taikan_test).
Done and verified: Wave 0 (schema + migration + shared contract), Wave 1 a–d (P0 fixes, presale swap incl. target-variant cap check, opening-day decline task + run summary, seven emails), follow-ups (guards replaced with direct calls, presale charge-failed email copy fixed). Last full runs: make test-unit-api 230 files / 2869 green; make test-integration-api 35 / 393 green. Known pre-existing lint error: apps/api/src/automations/automation-engine.service.unit.spec.ts (prefer-const), not ours.
In progress when checkpointed: W2-a (retention case API: retention-case.service.ts, endpoints, hooks in subscriptions.service.ts, insight members.notices_open, paymentStatus=ending, escalation cron), W2-b (web dashboard + member dialog, dictionaries), W2-c (mobile). Whatever is on disk beyond Wave 1 is partial until those report.
Remaining review covers the always-on contracts, eligibility, idempotency, localization and consent evidence. No commits or validation commands are authorized by this cutover assignment.
Review notes so far: issueCreditDocument in payment.service.ts still resolves the org’s active provider (known follow-up, best-effort path). Observability props on plan_change.presale_swap are camelCase per file convention, not the plan’s snake_case.
Cancellation notice window — implementation plan (wave 1 of the product plan)
Worktree: /Users/saarku/dev/taikan-notice-window on branch feat/cancellation-notice-window (from main 84e67df7).
Mobile worktree (created later): /Users/saarku/dev/taikan-mobile-notice-window on branch feat/cancellation-notice-window.
What we are building in this branch
The product plan (“The Notice Window”) has phases P0–P5. This branch delivers:
- P0 — correctness fixes and silent paths (unflagged).
- P1 — always-on retention cases: urgent owner-assigned notice tasks with SLA, escalation and daily digest; cancellations queue; staff acknowledge/contact/outcome/withdraw-notice endpoints; case state and reason codes; appropriate presale exit and charge-failure cases.
- Presale swap within a plan group — a
scheduledsubscription can move to another plan in the sameplan_group_idon the same row (keeps seat, buyer order, token, opening day). Rides existing plan-change flags. - Observability — a
cancellation.*event family with typed names shared by API, web and native; client events on the cancel dialogs; sweep summary event. - Docs + tests for all of the above.
NOT in this branch (later waves, some counsel-gated): P2 settlement (money at effective date), P3 “Talk to us first”, P4 compliance (legal identity, public intake, cooling-off).
Domain facts every agent must know
- A cancellation is a notice, not a request. The gym may not refuse, gate, or delay it. Never add an approval step.
cancel_at_period_end=trueis set at notice; the 02:30 UTC sweep (sweepDueCancellations) ends the membership atcancellation_effective_at(one calendar month from notice, computed once inlibs/shared/src/lib/cancellation-effective-date.ts). - The staff
POST /organizations/:orgId/subscriptions/:id/cancelhas two initiators:member_request(routes intomemberCancelAtPeriodEndwithonBehalfOfMember) andbusiness(immediatecancelSubscription, refund optional, reason required). - Presale:
status='scheduled'= sold, card tokenised, first charge on opening day, no access. Member exits viaPOST …/my/:id/withdraw(free, immediate). Opening-day decline retries at 3/7/14 days (handlePresaleChargeFailureinrecurring-charge.service.ts), then cancels withpresale_first_charge_failed. - Buyer numbering (PR #325) orders by
subscriptions.created_at, one row per member; cancelling forfeits the place. A same-row plan swap preserves it. - Plan groups (
plans.plan_group_id) count seats per membership; a swap within the group is seat-neutral. - Tasks:
taskstable (type enumtask_type, prioritytaskPriority,dueDate,assigneeId,linkedUserId, no metadata column). Existing types includecancellation_review,manual_refund,payment_follow_up,contact_lead.contact_leadis createdpriority: 'high', dueDate: tomorrow, assigneeId: owner.userId— copy that pattern. - Observability:
PaymentObservabilityService.emit(stage, props)fans out to Nest log + Sentry breadcrumb + PostHog ($groups.organization) with a scrub denylist. Stage names are a string-literal union inpayment-observability.service.ts. - Feature flags:
EventTrackingService.isFeatureEnabled(key, orgId, { organization: orgId }), wrapped in.catch(() => false); unevaluable = off = today’s behaviour. Keys live inlibs/shared/src/lib/constants/feature-flags.ts. - Notifications: email only via Resend (
EmailService.send), templates inapps/api/src/notifications/templates/, cancellation emails incancellation-notifications.service.ts.loadOrgOwnersthere is dead code to revive (extend to owners + admins). - Org-level settings are columns on
organizations(e.g.cancellation_window_hours).
Ground rules (from CLAUDE.md — binding)
- Never commit. Stage nothing. Leave changes in the working tree.
- Never run
pnpm db:migrate/db:push. The ONLY allowed migration command ismake test-db-migrateinside the worktree, which targets the disposable test DBtaikan_test_notice(port 55432). Only the Wave 0 agent runspnpm db:generate. - No provenance in comments: no names, tickets, dates, “as requested”, no narration of the change. Comments state constraints the code cannot show.
- Every user-facing string goes through i18n: add keys to
libs/shared/src/lib/i18n/dictionaries/en.json,he.json,ru.jsonBEFORE referencing them. Use the Edit tool on dictionary files (never Write) — other agents edit them concurrently. - Drizzle query builder, not raw SQL, except for Postgres features Drizzle cannot express; never re-interpolate one value into several clauses.
- Tests:
data-testidonly; drivers (*.driver.tsx) ownscreen.*;vi.mockin spec files;waitFor/findBy*, never timeouts. Never change a test for behaviour you did not intend to change — if a test looks wrong, stop and report. - Remove console.log.
- Touch only the files in your unit. If you must touch another unit’s file, do the minimum and say so in your report.
- Cancellation retention, settlement, provider standing-order cancellation and form prompting are always-on; preserve eligibility, idempotency and unrelated feature controls.
- Do not add
@taikan/sharedruntime imports into mobile (pinned package); use string literals there.
Commands
cd /Users/saarku/dev/taikan-notice-window
pnpm exec nx run api:typecheck 2>/dev/null || pnpm exec tsc -p apps/api/tsconfig.app.json --noEmit
pnpm exec nx lint api # or web / shared / db
make test-unit-api # vitest.unit.config.ts
make test-integration-api # needs taikan_test_notice; run make test-db-migrate first if schema changed
make test-unit-web
make test-integration-webRun a single spec: pnpm -C apps/api exec vitest run --config vitest.integration.config.ts src/subscriptions/cancellation-flow.int.spec.ts.
Baseline logs: /private/tmp/claude-501/-Users-saarku-dev-taikan/36d82525-9af1-4c28-a2a5-116915fde2d4/scratchpad/baseline/.
Report format (end of every agent)
- Files changed (paths).
- Behaviour added/changed, one line each.
- Commands run and results (pass/fail counts, exact failing test names).
- Anything left undone or uncertain, and anything you touched outside your unit.
Wave 0 — Foundations (one agent, sequential, everything else waits)
Schema (libs/db/src/lib/schema/)
enums.ts:
cancellationReasonCodepgEnumcancellation_reason_code:relocation | financial | dissatisfaction | health | schedule | no_longer_needed | other.retentionOutcomepgEnumretention_outcome:saved | saved_change_plan | saved_paused | saved_discount | saved_quota | rebought | lost_no_contact | lost_contacted | lost_payment.retentionCaseKindpgEnumretention_case_kind:notice | presale_withdrawal | presale_charge_failed.- Fix the stale comment on
task_typecancellation_review(no more “needs owner approval”).
payments.ts (subscriptions table):
cancellationReasonCode: cancellationReasonCode('cancellation_reason_code')nullable. Separate fromcancellation_reason(free text, ALSO a control column forabandoned_checkout/plan_change/presale_withdrawn— leave it alone).retentionCaseKind,retentionCaseTaskId uuid → tasks.id(nullable),retentionOwnerId uuid → users.id,retentionAcknowledgedAt,retentionContactedAt,retentionContactAttempts integer default 0 not null,retentionOutcome,retentionOutcomeAt,retentionOutcomeNote text.- Replace index
subscriptions_cancel_at_period_end_idx(cancel_at_period_end, current_period_end)with(cancel_at_period_end, cancellation_effective_at); fix the comments at ~307-309 and ~365 that still describe the cron as keyed oncurrent_period_end.
organizations.ts: retentionSlaHours integer not null default 48.
Then pnpm db:generate, read the SQL, ensure the journal when is greater than 0121’s, then make test-db-migrate. Do NOT touch taikan_dev.
Shared (libs/shared/src/lib/)
- New
analytics/cancellation-events.tsexporting aconstregistry (and a union type) of event names — server:cancellation.notice_given,cancellation.case_acknowledged,cancellation.case_contacted,cancellation.case_escalated,cancellation.offer_made,cancellation.offer_accepted,cancellation.notice_withdrawn,cancellation.form_issued,cancellation.form_signed,cancellation.effective,cancellation.presale_withdrawn,cancellation.presale_rebought,cancellation.presale_charge_failed,cancellation.sweep_completed,plan_change.presale_swap; client:member_cancel_dialog_opened,member_cancel_dialog_abandoned,member_cancel_confirmed,member_cancel_withdrawn,staff_cancel_dialog_opened,staff_cancel_initiator_selected,staff_case_opened,staff_save_tool_applied. Export fromlibs/shared/src/index.ts. schemas/cancellation.schema.ts: add optionalreasonCode(enum) to the staff cancel schema AND a newmemberCancelAtPeriodEndSchema{ reason?: string(max 500), reasonCode?: enum }for the self-serve route;withdrawNoticeSchema{ channel: enum(phone|in_person|email|registered_mail|fax|staff), note?: string(max 500) };retentionContactSchema{ channel: enum(phone|whatsapp|email|in_person|other), note?: string(max 500) };retentionOutcomeSchema{ outcome: enum(saved|saved_change_plan|saved_paused|saved_discount|saved_quota), note?: string(max 500) }(lost outcomes are set by the system only). Extend the subscription response type withcancellationReasonCode,retention: { kind, taskId, ownerId, acknowledgedAt, contactedAt, contactAttempts, outcome, outcomeAt } | null.- Plan-change schema:
modeunion gains'presale_swap'; preview response for that mode carriesdueNowInCents: 0,openingDayChargeInCents,openingDayAt. - Member actions: add
'change_plan'to the allowed actions forscheduledin the shared type if the union is shared (checkmember-actiontypes in shared vs API). - Dictionary keys are NOT part of Wave 0.
Definition of done
pnpm exec nx run-many -t typecheck lint -p shared db clean (or the repo equivalents); migration generated with monotonic when; make test-db-migrate succeeds; make test-unit-api still green. Report the migration tag.
Wave 1 — parallel, disjoint files (after Wave 0)
W1-a — API P0 correctness (files: apps/api/src/subscriptions/subscriptions.service.ts, subscriptions.controller.ts, apps/api/src/payments/services/payment.service.ts, their specs)
previewCancellation.alreadyRefunded(~:1890) is always false because the query filtersstatus='completed'. Fetch the lastcharge|recurringtransaction regardless of status;alreadyRefunded = status in (refund_pending, refunded);refundableInCents = 0when so. Unit test instaff-cancellation.unit.spec.ts.refund()resolves the adapter from the org’s ACTIVE provider (payment.service.ts~:588); the managed-terminal branch bypasses the provider-switch guard. Resolve credentials fortxn.provider; if it differs from the active config and no config exists for it, refuse with a clear 409 (PaymentErrorCodes— add one if needed) BEFORE any adapter call. Test inrefund-flow.int.spec.ts.memberCancelAtPeriodEndupdate (~:2049) has no race guard: addeq(subscriptions.cancelAtPeriodEnd, false)to the WHERE (plus the status guard), and make the zero-rows path a 409 “already scheduled” rather than NotFound; fix the misleading comment at ~:2085. Test: two concurrent notices → one succeeds, one 409, one task, one email.- Self-serve route
POST …/my/:id/cancel-at-period-end(controller:459) has no validation. ApplymemberCancelAtPeriodEndSchema(Wave 0) via the repo’s Zod pipe pattern; passreasonCodethrough tocancellationReasonCode. Staffmember_requestbranch passesreasonCodetoo. memberResumeAtPeriodEnd(~:2218) requiresstatus==='active'; a paused/past_due/debt member who gave notice cannot withdraw it. Accept every statusmemberCancelAtPeriodEndaccepts. Test.sweepDueCancellationsnever callspaymentService.cancelRecurring;applyImmediateCanceldoes (~:3185). Extract the “stop the provider standing order, emitpayment.provider_cancel_failed+ open the external-cancel task on failure” block into one private method and call it from both. Non-blocking. Test incancellation-flow.int.spec.ts.- Notice review tasks use the urgent SLA-timed retention case; no legacy low-priority notice branch remains.
openManualRefundTask(payment.service.ts~:871): after the task is opened, call a newcancellationNotifications.refundProcessing(txnId, amountInCents)— W1-d creates that method; if it does not exist yet when you get there, add a minimal method that renders a plain text email using the existing template helpers, and say so in the report.- Overdue
refund_pending: add a daily sweep (inpaymentsmodule, reuse an existing cron service or addRefundTaskSweepService) that bumps the priority ofmanual_refundtasks pastdueDatetourgentand emitspayment.refund_task_overdue(add the stage). Unflagged.
Run: make test-unit-api, make test-integration-api, api lint + typecheck.
W1-b — Presale swap within a plan group (files: apps/api/src/subscriptions/plan-change.service.ts, plan-change.service.driver.ts, member-action.ts, the plan-change controller/DTO, subscription-display-status.ts if needed, specs)
previewChangePlan/memberChangePlan/orgChangePlan: whensub.status === 'scheduled', allow the change iffnewPlan.planGroupId && newPlan.planGroupId === sub.plan.planGroupIdandnewPlanis sellable (active, not soft-deleted). Otherwise refuse with 409PaymentErrorCodes.PRESALE_SWAP_REQUIRES_SAME_GROUP(add) and a message that a different offer means withdraw and buy again and the seat is not guaranteed.- Mode
'presale_swap': under the existing advisory lock, re-read, re-verifystatus==='scheduled'and group equality, refuse if a charge is in flight (paymentTransactions.status='pending' AND chargeStartedAt IS NOT NULLfor the sub — mirrorfindWithdrawBlockerin subscriptions.service.ts, do not import its private), thenUPDATE subscriptions SET plan_id=new, updated_at=nowon the SAME row. Never create a new subscription row; never touchcreated_at,status,nextChargeDate, payment method, or credits. AuditSUBSCRIPTION_PLAN_CHANGE_APPLIEDwithmode: 'presale_swap'. Emitplan_change.presale_swapvia observability (add the stage) withfrom_plan,to_plan,days_to_opening,actor_role. - Preview for that mode returns
dueNowInCents: 0,openingDayChargeInCents(new plan’s first-charge price; use the same price resolution the presale promotion uses — intro price if the plan has one),openingDayAt = sub.nextChargeDate,effectiveAt = now,creditsAfteruntouched. The preview is the consent surface; keep the existing rule that the client disables confirm until it resolves. - Re-send the presale confirmation email with the new plan via the existing
membership-welcome.service.tskind: 'presale'path (check its signature). member-action.ts:scheduledreturns['withdraw_scheduled', 'change_plan']— but only when the plan has aplanGroupId(a group-less presale plan has nothing to swap to). Updatemember-action.unit.spec.ts.- Eligibility:
enforceEligibilitymust not demand a card charge or an active period for this mode. - Tests: driver + unit/int specs for allowed swap, different-group refusal, charge-in-flight refusal, row identity preserved (same
id, samecreatedAt), memberActions.
W1-c — Opening-day decline task + alert (files: apps/api/src/payments/services/recurring-charge.service.ts, .driver.ts, payment-observability.service.ts (stage union only), specs)
- In
handlePresaleChargeFailure: on the FIRST failure (attempts===1) open an urgentpayment_follow_uptask assigned to the org owner (membership.role='owner', first by createdAt),linkedUserId= member,dueDate= next retry date, title/description via new dictionary-free server strings (tasks are not localized today — follow the existing task title style), and email the owner viacancellationNotifications.presaleChargeFailedForStaff(...)(W1-d creates it; if missing, add a minimal one and report). On exhaustion, setretentionOutcome='lost_payment',retentionOutcomeAt=now,retentionCaseKind='presale_charge_failed'on the subscription. Idempotent: never open a second task for the same subscription (query existing open task by type + linkedUserId + title prefix, or better store its id inretentionCaseTaskId). - Emit
cancellation.presale_charge_failedwithattempt,exhausted,decline_code(if present on the failure), and at the end of the run a per-orgday_failure_ratio= failed presale promotions ÷ attempted presale promotions for that run; emitcancellation.sweep_completed-style summarypayment.presale_promotion_runwith counts. Add every new stage to the union inpayment-observability.service.ts(touch only the union). - Unflagged. Tests in the recurring-charge specs using the driver.
W1-d — Notification templates and senders (files: apps/api/src/notifications/templates/* (new files), apps/api/src/notifications/cancellation-notifications.service.ts, notifications.module.ts if needed, specs)
Revive loadOrgOwners as loadOrgStaffRecipients(orgId, roles = ['owner','admin']). Add these methods, each localized through the existing EmailContext / template localization pattern (look at plan-change-scheduled.ts and membership-emails.ts for the pattern, and localization.unit.spec.ts for how templates are tested), all safeSend:
noticeGivenForStaff(subscriptionId, { effectiveAt, channel, reasonCode, slaHours })— to owners+admins: “{member} gave notice on {plan}; membership ends {date}; call within {slaHours}h; link to the member page”.caseEscalatedForStaff(subscriptionId, { stage: 'unacknowledged' | 'uncontacted_final_week', effectiveAt })— to owners+admins.presaleWithdrawnForStaff(subscriptionId)— to owners+admins.presaleWithdrawnForMember(subscriptionId)— to the member: “your spot was released, nothing was charged”.presaleChargeFailedForStaff(subscriptionId, { attempt, nextRetryAt })— to owners+admins, urgent tone.noticeWithdrawnByStaff(subscriptionId, { channel })— to the member: “your membership continues; you told us on {channel}”.refundProcessing(transactionId, { amountInCents })— to the member: “your refund of {amount} is being processed; within 14 days”. No callers in this unit (W1-a item 8 and W1-c call two of them; W2-a calls the rest). Unit-test each renders in en/he/ru with correct dir and no[brackets]/undefined.
Wave 2 — after Wave 1
W2-a — Retention case service, endpoints, hooks, insight, member filter, escalation cron (API)
New apps/api/src/subscriptions/retention-case.service.ts (+ retention-case.controller.ts or extend subscriptions.controller.ts), registered in the subscriptions module.
Always-on: eligible events open retention cases without organization rollout evaluation.
On notice (hook at the end of memberCancelAtPeriodEnd, replacing the createCancellationReviewTask call): retentionCase.openForNotice(sub, { source, channel }):
- Create an urgent owner-assigned
cancellation_reviewtask with SLA deadline and member link; stamp case kind, task and owner. The notice enters the daily digest rather than sending per-event staff mail. Preserve existing cancellation observability events.
On presale withdrawal (memberWithdrawScheduled / applyScheduledWithdrawal): low-priority, no-deadline owner-assigned cancellation_review record, kind presale_withdrawal; immediate presaleWithdrawnForStaff and member confirmation email; emit cancellation.presale_withdrawn with the existing payload.
On re-purchase: when a member with an open presale_withdrawal case buys a plan in the same org (hook where a subscription is created for a membership — find the single creation path in plans/purchase), close the case: task completed, retentionOutcome='rebought', emit cancellation.presale_rebought { days_to_rebuy }.
On member resume (memberResumeAtPeriodEnd): if a case is open, set retentionOutcome='saved' (or keep an existing staff-set saved_* outcome), retentionOutcomeAt, complete the task; emit cancellation.notice_withdrawn { withdrawn_by: 'member', outcome, days_before_effective } (keep payment.cancellation_resumed too).
On sweep end (sweepDueCancellations per row): set retentionOutcome = lost_contacted if retentionContactedAt else lost_no_contact; complete the task; emit cancellation.effective { outcome, contact_attempts }. At the end of the run emit cancellation.sweep_completed { due, ended, failed, duration_ms }.
Endpoints (role subscriptions:manage, org-scoped, Zod-validated with Wave 0 schemas, all audited — add AUDIT_ACTIONS: RETENTION_CASE_ACKNOWLEDGED, RETENTION_CASE_CONTACTED, RETENTION_OUTCOME_SET, SUBSCRIPTION_NOTICE_WITHDRAWN_BY_STAFF):
POST /organizations/:orgId/subscriptions/:id/retention/acknowledge→retentionAcknowledgedAt=nowif null; emitcancellation.case_acknowledged{ hours_since_notice }.POST …/retention/contact{ channel, note? }→retentionContactedAt(first),retentionContactAttempts+1; emitcancellation.case_contacted{ attempt, contact_channel, hours_to_first_contact }.POST …/retention/outcome{ outcome, note? }→ onlysaved*outcomes; sets outcome, completes task, and if the notice is still pending calls the withdraw-notice logic below; emitcancellation.offer_acceptedwhen outcome !=savedwithoffer_kindderived from the outcome.POST …/withdraw-notice{ channel, note? }→ mirror ofmemberResumeAtPeriodEndfor staff: clears the six cancellation columns, refuses when externally billed (same 409 as the member path), refuses onpending/scheduled/cancelled, sets outcomesavedunless already set, completes the task, emailsnoticeWithdrawnByStaff, audit, emitcancellation.notice_withdrawn { withdrawn_by: 'staff_on_behalf' }.- Response of every subscription read includes the new
retentionobject andcancellationReasonCode(update the response mapper and its unit spec).
Escalation cron: every eligible open live notice case is considered. Unacknowledged cases past SLA and uncontacted cases in the final week emit their stage and write RETENTION_CASE_ESCALATED. Audit claims prevent repeat stages; advisory locking prevents overlapping runs. No per-event staff email: eligible cases appear in the daily digest. See the canonical retention mechanics.
Members list: GET /organizations/:orgId/members?paymentStatus=ending → members with a live sub (active|paused|past_due|debt) and cancelAtPeriodEnd=true. Add ending to the derived billing state list ABOVE paying (a member who gave notice is still paying but ending) — check memberships.service.ts ~:555-570 state table and the shared type. Include cancellationEffectiveAt and retention summary in the member row payload if the row payload already carries subscription fields (do not widen otherwise).
Insight: rename evalUpcomingCancellations result id to members.notices_open (update insights dictionary keys and any web reference — coordinate: W2-b will consume members.notices_open), severity: urgent if any open case has cancellationEffectiveAt <= now+7d and no contact, warning if any case unacknowledged past SLA, else info; drilldownHref: '/dashboard/members?paymentStatus=ending'; meta { open, unacknowledged, finalWeekUncontacted }. Keep finance.cancellation_requests id emitted as well for one release ONLY if the web relies on it — check; otherwise replace.
Tests: cover urgent notice tasks, owner assignment, digest and presale notifications, acknowledge/contact/outcome, withdraw-notice eligibility, final outcomes and escalation idempotency. Preserve cancellation analytics assertions. No alternate rollout-state scenarios apply.
W2-b — Web dashboard + member web (files under apps/web/src/…; dictionaries via Edit only)
Contract = Wave 0 shared schemas + W2-a endpoints (build against them; use the drivers/mocks pattern).
Dashboard:
- Staff cancel dialog (
components/subscriptions/staff-cancel-dialog.tsx): re-fetch the preview whennoticeAtchanges (passnoticeAtas a query param — add it to the preview endpoint in W2-a if absent:GET …/cancellation-preview?noticeAt=; if the API does not support it yet, computenoticeEffectiveAtclient-side with the sharedcomputeCancellationEffectiveAtand say so); format dates withlang; initiator radio has NO default (submit disabled until chosen); addreasonCodechips (optional) formember_request, required-reason unchanged forbusiness; addmemberRequestedDatedate picker and animmediatecheckbox formember_request(schema already accepts them); show the issued form: after success, ifcancellationFormInstanceIdis returned, show a line “Cancellation form issued — {send signing link}” that calls the existing forms “send signing link” endpoint if one exists (grepform-signing-link), else copies the form URL; renderdaysUsed/paidAmountsummary formember_requesttoo (from the preview); move the always-zero fee line to a footnote. Rename the trigger inmember-memberships-tab.tsxfromcommon.cancelto a new keymembers.detail.endMembership(“End membership…”); delete the deadcancelMutationand unusedcancelSubConfirmstrings. Capturestaff_cancel_dialog_opened,staff_cancel_initiator_selectedviaposthog.capture(names from the shared registry). - Cancellations queue:
members-content.tsxgains apaymentStatus=endingfilter option and, when active, columns “Notice”, “Ends on”, “Case” (state pill: open / contacted / saved / lost) and “Owner”. Member row payload per W2-a. Addmembers-ribbon.tsxtile “Ending soon” linking to?paymentStatus=ending. - Case panel: in
member-memberships-tab.tsxunder the “Ends {date}” badge, aRetentionCasePanelcomponent: acknowledge (auto on open — call acknowledge once when the panel mounts and the case is unacknowledged), “Log contact” (channel select + note →/retention/contact), “Member is staying” →/withdraw-noticedialog (channel + note), “Mark outcome” (saved_change_plan / saved_paused / saved_discount / saved_quota) →/retention/outcome, and links to the existing Change plan / Freeze / Discount actions already on the tab. Same panel reachable fromtask-detail-sheet.tsxforcancellation_reviewtasks (special-case likemanual_refund, deep-link to the member page section). Capturestaff_case_opened,staff_save_tool_applied. - Insight:
action-center.tsxconsumesmembers.notices_open(title/description keys), severity-driven; drilldown as provided by the API. - Refunds:
member-payments-tab.tsxrefund → same AlertDialog confirmation astransactions-table.tsx, plus an optional partial amount input (≤ charged) on BOTH surfaces; before the confirm, a note when the org’s provider refunds via task (“This provider refunds through a task you complete in its portal”) — read/payment-config(already fetched) and the adapter capability if exposed; if capability is not in the payload, show the note for every non-managed, non-external provider. Member web: cancel-subscription-dialog.tsx: optional reason-code chips + free text; fix copy: description no longer says “Tell us why”; buttons “Keep my membership” / “Give notice” (or the closest natural phrasing) — one noun (membership) throughout; capturemember_cancel_dialog_openedon open,member_cancel_dialog_abandonedon close-without-submit,member_cancel_confirmedon success.payments/page.tsxresume →member_cancel_withdrawn. Tests: update/add int specs + drivers for every changed component (staff-cancel-dialog.int.spec.tsx, newretention-case-panel.int.spec.tsx,members-contentfilter spec,cancel-subscription-dialog.int.spec.tsx, refund confirm on member tab).make test-integration-web, web lint + typecheck.
W2-c — Mobile (/Users/saarku/dev/taikan-mobile-notice-window, branch feat/cancellation-notice-window)
app/(tabs)/profile/cancel-subscription.tsx: optional reason-code chips (same enum values as literals) sent asreasonCode; copy alignment with web (keyssubscriptions.cancelDialog.*exist in the pinned dictionary — add inline fallbacks for new keys);analytics.trackmember_cancel_dialog_opened/member_cancel_dialog_abandoned/member_cancel_confirmed;payments.tsxresume →member_cancel_withdrawn.payments.tsx+change-plan.tsx: show “Change plan” for ascheduledcard whenmemberActionsincludeschange_plan; the change-plan screen handles previewmode: 'presale_swap'(due now 0, “your first charge on {openingDayAt} will be {amount}”) and confirms via the existing endpoint; target list filtered to sameplanGroupId(plans payload carriesplanGroupId— verify; if not, rely on the API’s 409 and show its message).membership-card.tsx/payments.tsx: render the externally-billed note usingsubscriptions.externallyBilled/scheduledCancelDescExternalwhenbillingState === 'external'.- Tests: scheduled-cancel banner + resume action (currently untested), showCancel for paused/past_due/debt, change-plan on scheduled, external copy. Run the app’s test command (check package.json) + typecheck.
Wave 3 — Docs (one agent) then my review
Update docs/features/subscriptions-plans/{README,behavior,data-model,code-map,qa-plan}.md, docs/features/payments/behavior.md (refund provider resolution, overdue sweep), docs/features/notifications/README.md, docs/features/insights/behavior.md (members.notices_open), docs/features/event-tracking/README.md (typed registry), docs/features/memberships/behavior.md (ending state), docs/overview/roadmap.md (retention item), and rewrite docs/_scratch/cancellations-and-refunds-plan.md into the current engineering picture (what shipped in this branch, what P2–P4 still need, counsel Q1–Q8). Remove every remaining reference to cancellation_requests, approve/reject, and a “Cancellations tab”. Then run pnpm exec nx build docs if it exists.