Skip to Content
Living documentation — last reviewed 2026-05-28
ScratchSTATUS (checkpoint, 2026-09-02)

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 scheduled subscription can move to another plan in the same plan_group_id on 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=true is set at notice; the 02:30 UTC sweep (sweepDueCancellations) ends the membership at cancellation_effective_at (one calendar month from notice, computed once in libs/shared/src/lib/cancellation-effective-date.ts).
  • The staff POST /organizations/:orgId/subscriptions/:id/cancel has two initiators: member_request (routes into memberCancelAtPeriodEnd with onBehalfOfMember) and business (immediate cancelSubscription, refund optional, reason required).
  • Presale: status='scheduled' = sold, card tokenised, first charge on opening day, no access. Member exits via POST …/my/:id/withdraw (free, immediate). Opening-day decline retries at 3/7/14 days (handlePresaleChargeFailure in recurring-charge.service.ts), then cancels with presale_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: tasks table (type enum task_type, priority taskPriority, dueDate, assigneeId, linkedUserId, no metadata column). Existing types include cancellation_review, manual_refund, payment_follow_up, contact_lead. contact_lead is created priority: '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 in payment-observability.service.ts.
  • Feature flags: EventTrackingService.isFeatureEnabled(key, orgId, { organization: orgId }), wrapped in .catch(() => false); unevaluable = off = today’s behaviour. Keys live in libs/shared/src/lib/constants/feature-flags.ts.
  • Notifications: email only via Resend (EmailService.send), templates in apps/api/src/notifications/templates/, cancellation emails in cancellation-notifications.service.ts. loadOrgOwners there 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)

  1. Never commit. Stage nothing. Leave changes in the working tree.
  2. Never run pnpm db:migrate / db:push. The ONLY allowed migration command is make test-db-migrate inside the worktree, which targets the disposable test DB taikan_test_notice (port 55432). Only the Wave 0 agent runs pnpm db:generate.
  3. No provenance in comments: no names, tickets, dates, “as requested”, no narration of the change. Comments state constraints the code cannot show.
  4. Every user-facing string goes through i18n: add keys to libs/shared/src/lib/i18n/dictionaries/en.json, he.json, ru.json BEFORE referencing them. Use the Edit tool on dictionary files (never Write) — other agents edit them concurrently.
  5. Drizzle query builder, not raw SQL, except for Postgres features Drizzle cannot express; never re-interpolate one value into several clauses.
  6. Tests: data-testid only; drivers (*.driver.tsx) own screen.*; vi.mock in 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.
  7. Remove console.log.
  8. Touch only the files in your unit. If you must touch another unit’s file, do the minimum and say so in your report.
  9. Cancellation retention, settlement, provider standing-order cancellation and form prompting are always-on; preserve eligibility, idempotency and unrelated feature controls.
  10. Do not add @taikan/shared runtime 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-web

Run 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)

  1. Files changed (paths).
  2. Behaviour added/changed, one line each.
  3. Commands run and results (pass/fail counts, exact failing test names).
  4. 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:

  • cancellationReasonCode pgEnum cancellation_reason_code: relocation | financial | dissatisfaction | health | schedule | no_longer_needed | other.
  • retentionOutcome pgEnum retention_outcome: saved | saved_change_plan | saved_paused | saved_discount | saved_quota | rebought | lost_no_contact | lost_contacted | lost_payment.
  • retentionCaseKind pgEnum retention_case_kind: notice | presale_withdrawal | presale_charge_failed.
  • Fix the stale comment on task_type cancellation_review (no more “needs owner approval”).

payments.ts (subscriptions table):

  • cancellationReasonCode: cancellationReasonCode('cancellation_reason_code') nullable. Separate from cancellation_reason (free text, ALSO a control column for abandoned_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 on current_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.ts exporting a const registry (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 from libs/shared/src/index.ts.
  • schemas/cancellation.schema.ts: add optional reasonCode (enum) to the staff cancel schema AND a new memberCancelAtPeriodEndSchema { 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 with cancellationReasonCode, retention: { kind, taskId, ownerId, acknowledgedAt, contactedAt, contactAttempts, outcome, outcomeAt } | null.
  • Plan-change schema: mode union gains 'presale_swap'; preview response for that mode carries dueNowInCents: 0, openingDayChargeInCents, openingDayAt.
  • Member actions: add 'change_plan' to the allowed actions for scheduled in the shared type if the union is shared (check member-action types 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)

  1. previewCancellation.alreadyRefunded (~:1890) is always false because the query filters status='completed'. Fetch the last charge|recurring transaction regardless of status; alreadyRefunded = status in (refund_pending, refunded); refundableInCents = 0 when so. Unit test in staff-cancellation.unit.spec.ts.
  2. 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 for txn.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 in refund-flow.int.spec.ts.
  3. memberCancelAtPeriodEnd update (~:2049) has no race guard: add eq(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.
  4. Self-serve route POST …/my/:id/cancel-at-period-end (controller:459) has no validation. Apply memberCancelAtPeriodEndSchema (Wave 0) via the repo’s Zod pipe pattern; pass reasonCode through to cancellationReasonCode. Staff member_request branch passes reasonCode too.
  5. memberResumeAtPeriodEnd (~:2218) requires status==='active'; a paused/past_due/debt member who gave notice cannot withdraw it. Accept every status memberCancelAtPeriodEnd accepts. Test.
  6. sweepDueCancellations never calls paymentService.cancelRecurring; applyImmediateCancel does (~:3185). Extract the “stop the provider standing order, emit payment.provider_cancel_failed + open the external-cancel task on failure” block into one private method and call it from both. Non-blocking. Test in cancellation-flow.int.spec.ts.
  7. Notice review tasks use the urgent SLA-timed retention case; no legacy low-priority notice branch remains.
  8. openManualRefundTask (payment.service.ts ~:871): after the task is opened, call a new cancellationNotifications.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.
  9. Overdue refund_pending: add a daily sweep (in payments module, reuse an existing cron service or add RefundTaskSweepService) that bumps the priority of manual_refund tasks past dueDate to urgent and emits payment.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: when sub.status === 'scheduled', allow the change iff newPlan.planGroupId && newPlan.planGroupId === sub.plan.planGroupId and newPlan is sellable (active, not soft-deleted). Otherwise refuse with 409 PaymentErrorCodes.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-verify status==='scheduled' and group equality, refuse if a charge is in flight (paymentTransactions.status='pending' AND chargeStartedAt IS NOT NULL for the sub — mirror findWithdrawBlocker in subscriptions.service.ts, do not import its private), then UPDATE subscriptions SET plan_id=new, updated_at=now on the SAME row. Never create a new subscription row; never touch created_at, status, nextChargeDate, payment method, or credits. Audit SUBSCRIPTION_PLAN_CHANGE_APPLIED with mode: 'presale_swap'. Emit plan_change.presale_swap via observability (add the stage) with from_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, creditsAfter untouched. 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.ts kind: 'presale' path (check its signature).
  • member-action.ts: scheduled returns ['withdraw_scheduled', 'change_plan'] — but only when the plan has a planGroupId (a group-less presale plan has nothing to swap to). Update member-action.unit.spec.ts.
  • Eligibility: enforceEligibility must 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, same createdAt), 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 urgent payment_follow_up task 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 via cancellationNotifications.presaleChargeFailedForStaff(...) (W1-d creates it; if missing, add a minimal one and report). On exhaustion, set retentionOutcome='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 in retentionCaseTaskId).
  • Emit cancellation.presale_charge_failed with attempt, exhausted, decline_code (if present on the failure), and at the end of the run a per-org day_failure_ratio = failed presale promotions ÷ attempted presale promotions for that run; emit cancellation.sweep_completed-style summary payment.presale_promotion_run with counts. Add every new stage to the union in payment-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:

  1. 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”.
  2. caseEscalatedForStaff(subscriptionId, { stage: 'unacknowledged' | 'uncontacted_final_week', effectiveAt }) — to owners+admins.
  3. presaleWithdrawnForStaff(subscriptionId) — to owners+admins.
  4. presaleWithdrawnForMember(subscriptionId) — to the member: “your spot was released, nothing was charged”.
  5. presaleChargeFailedForStaff(subscriptionId, { attempt, nextRetryAt }) — to owners+admins, urgent tone.
  6. noticeWithdrawnByStaff(subscriptionId, { channel }) — to the member: “your membership continues; you told us on {channel}”.
  7. 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_review task 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=now if null; emit cancellation.case_acknowledged { hours_since_notice }.
  • POST …/retention/contact { channel, note? } → retentionContactedAt (first), retentionContactAttempts+1; emit cancellation.case_contacted { attempt, contact_channel, hours_to_first_contact }.
  • POST …/retention/outcome { outcome, note? } → only saved* outcomes; sets outcome, completes task, and if the notice is still pending calls the withdraw-notice logic below; emit cancellation.offer_accepted when outcome != saved with offer_kind derived from the outcome.
  • POST …/withdraw-notice { channel, note? } → mirror of memberResumeAtPeriodEnd for staff: clears the six cancellation columns, refuses when externally billed (same 409 as the member path), refuses on pending/scheduled/cancelled, sets outcome saved unless already set, completes the task, emails noticeWithdrawnByStaff, audit, emit cancellation.notice_withdrawn { withdrawn_by: 'staff_on_behalf' }.
  • Response of every subscription read includes the new retention object and cancellationReasonCode (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:

  1. Staff cancel dialog (components/subscriptions/staff-cancel-dialog.tsx): re-fetch the preview when noticeAt changes (pass noticeAt as 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, compute noticeEffectiveAt client-side with the shared computeCancellationEffectiveAt and say so); format dates with lang; initiator radio has NO default (submit disabled until chosen); add reasonCode chips (optional) for member_request, required-reason unchanged for business; add memberRequestedDate date picker and an immediate checkbox for member_request (schema already accepts them); show the issued form: after success, if cancellationFormInstanceId is returned, show a line “Cancellation form issued — {send signing link}” that calls the existing forms “send signing link” endpoint if one exists (grep form-signing-link), else copies the form URL; render daysUsed/paidAmount summary for member_request too (from the preview); move the always-zero fee line to a footnote. Rename the trigger in member-memberships-tab.tsx from common.cancel to a new key members.detail.endMembership (“End membership…”); delete the dead cancelMutation and unused cancelSubConfirm strings. Capture staff_cancel_dialog_opened, staff_cancel_initiator_selected via posthog.capture (names from the shared registry).
  2. Cancellations queue: members-content.tsx gains a paymentStatus=ending filter option and, when active, columns “Notice”, “Ends on”, “Case” (state pill: open / contacted / saved / lost) and “Owner”. Member row payload per W2-a. Add members-ribbon.tsx tile “Ending soon” linking to ?paymentStatus=ending.
  3. Case panel: in member-memberships-tab.tsx under the “Ends {date}” badge, a RetentionCasePanel component: 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-notice dialog (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 from task-detail-sheet.tsx for cancellation_review tasks (special-case like manual_refund, deep-link to the member page section). Capture staff_case_opened, staff_save_tool_applied.
  4. Insight: action-center.tsx consumes members.notices_open (title/description keys), severity-driven; drilldown as provided by the API.
  5. Refunds: member-payments-tab.tsx refund → same AlertDialog confirmation as transactions-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:
  6. 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; capture member_cancel_dialog_opened on open, member_cancel_dialog_abandoned on close-without-submit, member_cancel_confirmed on success. payments/page.tsx resume → member_cancel_withdrawn. Tests: update/add int specs + drivers for every changed component (staff-cancel-dialog.int.spec.tsx, new retention-case-panel.int.spec.tsx, members-content filter 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)

  1. app/(tabs)/profile/cancel-subscription.tsx: optional reason-code chips (same enum values as literals) sent as reasonCode; copy alignment with web (keys subscriptions.cancelDialog.* exist in the pinned dictionary — add inline fallbacks for new keys); analytics.track member_cancel_dialog_opened / member_cancel_dialog_abandoned / member_cancel_confirmed; payments.tsx resume → member_cancel_withdrawn.
  2. payments.tsx + change-plan.tsx: show “Change plan” for a scheduled card when memberActions includes change_plan; the change-plan screen handles preview mode: 'presale_swap' (due now 0, “your first charge on {openingDayAt} will be {amount}”) and confirms via the existing endpoint; target list filtered to same planGroupId (plans payload carries planGroupId — verify; if not, rely on the API’s 409 and show its message).
  3. membership-card.tsx / payments.tsx: render the externally-billed note using subscriptions.externallyBilled / scheduledCancelDescExternal when billingState === 'external'.
  4. 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.