Skip to content

Roadmap

1Built, not live

Failed payments under 5%

Track 1 · Payments · due Oct 2026

2Built, not live

Lapsed members recovered

Track 2 · Retention · due Oct 2026

3Built, not live

Member revenue +15 to 20%

Both tracks · due Nov 2026

4Live

Newsletter archive for paid members

Track 2 · Retention · due Oct 2026

5Built, not live

Institutional invoicing pathway

Track 1 · Payments · due Sept 2026 slipping

JunJulAugSeptOctNovBuild: payments, console, giving, SCAInstitutional invoicing, email, archiveOperator session,archive deploy, decisionsTrack 2 email layer + code owedRehearsal B/C + dress rehearsalCutover window + backfills + campaignFinal reporting and handover today 8 Nov
Cutover window + backfills + campaign · 6 Nov to 21 Nov · Planned, 13 days left
donenow or nextplannedlategrant window (Aug to Nov)Phase states as recorded on 1 Oct.

Next, in order

  1. 1Go-live operator session: re-provision webhooks, live-fire incl. a team invoiceowner + dev#1#5
  2. 2Decision board: colleagues answer 18–34 and confirm the calls already taken (cutover date first); Thomas takes MIG-4, security picks, rehearsal hostowners#1#2#4#5
  3. 3Track 2 email layer built 2026-09-29; switch on and send the win-back campaign after cutoverowner + dev#2
  4. 4Code owed before cutover, then rehearsal Phase B/C and dress rehearsaldev#1
  5. 5Cutover, backfills, first measured renewal cycleowner + dev#1#3#5
Full record: docs/ROADMAP.md

Roadmap & Sprints — Frontier Membership System (FMS) ​

Assumptions: solo developer + Claude Code; ~2-week sprints; Stripe test-mode TH + SG accounts for spikes; reliability + correctness prioritised over feature breadth.


Grant frame (JX Fund consultancy, August–November 2026) ​

FMS is the "own backend membership solution" of the JX Fund engagement. Every item below and in "Where we are" should trace to one of the two tracks and one of the five success metrics. The grant text itself is not in this repo; this table is the working summary.

#Success metric (grant §4.7)TrackWhat FMS contributesState 2026-09-23
1Failed-payment rate below 5%1 · Payment infrastructureMulti-account routing, structural reroute, 3DS/SCA on every surface, renewal SCA, dunning grace, dead-letter replay, Stripe dunning left ONBuilt, unmeasured. Baseline and live rate need the Stripe import + cutover; webhooks not yet re-provisioned
2Recover lapsed subscribers via a reactivation campaign2 · Acquisition & retentionGrace state, expiry sweep, outreach queue, discount-code catalog, SMTP relay + branded mail as the building blocksBuilt 2026-09-29, not yet on. Ends-soon / ended / cancelled / welcome emails, the fms:lapsed segment, the code= checkout link and the Dashboard's Won back (MIG-2 = B). Needs a target number (§2 M7) and the campaign sent after cutover
3Member revenue +15–20% by November 2026 (USD 15k → 20k/month)bothReports (net per settlement currency, period control, ledger, CSV), Dashboard growth/churn, subscriber listInstrumented. Baseline comes from the live import
4Newsletter archive open to every active paid member by October 20262archive/ sub-project gated on archive_access, granted per plan in the console (DECISIONS #13, spec 10), plugin 0.38+ hand-off, nginx site config, security review SEC-11/12Live 2026-09-24 at archive.frontiermyanmar.net, back catalogue restored from 2020. Out of the box Fanatic and team plans include it; "every active paid member" holds once Friend and Follower are ticked on Plans (an owner setting since 2026-09-27)
5One new institutional subscription pathway live by September 20261Team invoice checkout (bank/Wise or hosted Stripe invoice, verified email, annual renewals, seat upgrades)Built 2026-09-12, not live. Goes live with cutover, so the September date slips to the cutover date

Grant timeline vs. reality: August audit + build ✅; September renewal-sequence audit in Mailchimp, reactivation launch, archive build — archive ✅ (live 2026-09-24), the two email items built 2026-09-29 (switched on and sent after cutover); October first automated renewal cycle + payment-success measurement — depends on cutover; November final reporting + handover docs (this roadmap, CHANGELOG.md, DECISIONS.md, the staff guide). Continuation clause (§4.8): everything must run without external support after November.

Where we are (updated 2026-10-27) ​

  • Sprints 0–3: done. Reliability backbone, standalone-keys routing rework, and plans/checkout/newsletter tags are built and verified (see archive/progress/). The payment-failure policy was revised 2026-06-30: Stripe dunning/Smart Retries stay ON and own transient-decline recovery; FMS intervenes only on structural unsupported-method failures, decided from per-account capability config — never from decline codes alone.

  • Sprint 5 largely done ahead of order: the React admin console (members/teams CRUD, plans, connections, importer, consolidation + duplicate detection, seed gallery, backup/restore, sandbox environments, runtime env switching) shipped 2026-06-27→30. Statistics and the service-app/key Developer UI remain.

  • Sprint 7 partially done: internal health monitor + durable system_alerts + admin Health page (2026-07-02). Periodic integration probes, debounce, and outbox-emailed alerts remain.

  • Sprint 8 done (2026-08-12): multi-stage Dockerfile + compose app service, in-app backup/restore (pg_dump/pg_restore), and now the scheduled backup service (cron → pg_dump | gzip → rclone → R2, pruned to 7 daily + 12 weekly), ops/backup.sh, deploy.sh, restore.sh and ops/RUNBOOK.md. A restore drill against a local rclone filesystem remote reproduced the source row counts exactly. Still an operator task: create the R2 bucket + token and the lifecycle-rule backstop, then confirm a real snapshot lands.

  • A #[sqlx::test] money-path integration suite (tests/, 44 suites / 467 tests) covers comp/webhook/reroute/team-pricing/outbox/grace/giving/SCA invariants.

  • Sprint 4 (access API + WordPress plugin) and the revised failure policy both landed out of order — entitlements + GET /me/access + service keys (tests/access_api.rs), the WP plugin with identity mapping and handoff (tests/wp_identity.rs, tests/handoff.rs), and the grace state + expiry sweep + outreach queue + outbox email (tests/grace.rs).

  • Landed 2026-08-08 → 13 outside the sprint map: email sync Stripe→FMS→plugin (c8a74b2); team admins can convert out of their team plan (3d41255); gift newsletters to another address (391534a); grant bookkeeping dates starts_at/collected_at (daa5440, migration 0031); the live-mode Stripe write brake with an explicit arming override (a967940); a per-IP rate limit on the public discount-quote endpoint (039136d); admin cancel now lands the member on free Community + connect stores the real acct_… id (0ff4b28); one-off PaymentIntents fixed to name their card method set (42678fe).

  • Live Stripe write paths were exercised end-to-end 2026-08-13 against real TH+SG sandbox accounts (live-fire rehearsal suite 11/12 pass + follow-up session): orchestrated create per brand, seat amend, cancel, silent recreate, test-clock renewals, one-off donation/gift charges (after fixing a blocking PaymentIntent param bug), both discount injections. See progress/2026-08-13.md.

  • 3DS/SCA challenge completion shipped 2026-08-13 (4908bb6) on /checkout, /donate and /gift: park-and-resume with per-surface finalize endpoints that re-read the verdict from Stripe (migration 0032_pending_payments, tests/sca.rs), live-verified through Stripe's real challenge iframes.

  • Gift → paying conversion made whole (2026-08-14). A member converting a gift used to be billed from the day they converted (paying again for up to 14 days the gift already covered) and kept their comp row alongside the new paid one — two live memberships, then a zombie Community row once the sweep downgraded the stale one. Any paid checkout by a member holding a live gift now anchors the first charge to the gift's end date and retires the comp row (06-user-account FR-12f, tests/gifts.rs, 20 suites).

  • Renewal-time SCA handled (2026-08-15). invoice.payment_action_required is registered and captured in member_invoices at action_required; the member gets a "Confirm payment" banner pointing at Stripe's hosted invoice page (the only thing that can clear an SCA hold — retries never can), a bounded sca_hold_until access window so they don't lose the paywall before seeing the prompt, and staff get a shared renewal_sca health alert. Not routed to the reroute worker, by design. Migration 0033; tests/webhooks.rs + tests/health_sca.rs + tests/membership.rs + tests/sca.rs. Closes RISK FIN-13.

  • Risk-register hardening pass (committed 2026-08-25, c7b6df3; documented 2026-08-26).FIN-1 closed: a plan's local price can't be edited once any account has a mapped Stripe price (assert_price_editable), and Consolidation's Plan prices ↔ Stripe panel reports local-vs-mapped drift. FIN-2 code-complete: admin cancel refuses instead of half-cancelling; the reroute worker, its unwind and both checkout cleanups enqueue a durable EnsureStripeCancel outbox order when an inline cancel fails. FIN-4 replay surface: Health → Dead letters lists dead outbox events with payloads and replays one or all. SEC-8's raw-error bodies are opaque now; MIG-10's report interval is documented; the container runs non-root with Postgres and the app port on loopback only; a member email change strips the vacated address's tags. See progress/2026-08-25.md.

  • Member notes, named team grants, team rename, net revenue (2026-09-02 — in the working tree; the commit is the next action). member_notes (migration 0034) with a Note field on New member / Grant a subscription and a Notes card on the member page, the first place the acting admin is recorded; a team-plan grant now provisions the team + owner seat inside the grant transaction (closes the orphaned-team repair loop); team Rename. Net revenue: the payment_settlements ledger (migration 0035) reads each charge's BalanceTransaction through a FetchSettlement outbox event — gross, Stripe fee, net, settlement currency, FX rate — and external grants carry what was charged and what reached the bank; the admin Reports tab shows net / gross / fees per settlement currency (never combined) over 12 months, with a backfill for history. Churn/acquisition landed the same day (Dashboard → Growth, migration 0037 ended_at, definitions confirmed and recorded in 05-Q1), so MIG-11 is closed. What Finance still needs to audit from FMS is written up as DECISIONS.md §7 (recommendation: ledger page + CSV, void-not-delete). See progress/2026-09-02.md.

  • 2026-09-10 → 23, all committed (623e4ce): the institutional pathway — team invoice checkout (bank/Wise or hosted Stripe invoice, verified requester email or admin Mark email verified, annual invoiced renewals, payment-gated seat upgrades; docs/team-invoice-checkout.md); outgoing email from the console (SMTP relay in email_settings, FMS delivers the member mail queue itself; DECISIONS §9) and branded transactional mail (src/mailbrand.rs); off-site R2 backups configured from Settings (spec 08 FR-17); partial refunds on the settlements ledger; two admin-panel testing rounds (member names, Tier filter, Stop/Renew on the row, renew_grant, plan edit resyncs members, authorized_by, team billing_email); Reports period control, ledger, subscriber list, CSV and print (closes DECISIONS §7 G1/G2); the newsletter archive built end to end (sync → article → gated reading site → WordPress hand-off, plugin 0.33–0.35, security review SEC-11–16, nginx config, first-boot holding page); and the live-migration hardening of 2026-09-23 (resumable Stripe import job MIG-15, past_due → dunning grace, two outbox lanes, HTTP timeouts, Mailchimp 400 permanence + unsyncable marks, never-create-on-resync, plugin 0.36 binding rows; DECISIONS §11).

  • 2026-10-03 — Claude reads FMS (DECISIONS #43, RISK SEC-17, spec 05 FR-23, migration 0071). An optional, off-by-default connector: staff add FMS to Claude and read members, teams and reports as themselves, read-only, through thirteen fixed tools (two read the staff guide). Built; the Business owner and Legal confirm before it is switched on in production. A password leak found alongside it (SEC-18, the environments list) is closed. Not on the cutover path. docs/progress/2026-10-03.md.

  • 2026-10-10 — Change plan from the member page (DECISIONS #45, spec 05 FR-12c, migration 0077). Finance powers can upgrade or downgrade a member from the console: a card upgrade is charged to the saved card or sent as an invoice, and applies only when Stripe says it is paid; grants move freely; monthly ↔ yearly restarts the cycle. Built and tested against the Stripe mock; three Stripe behaviours wait for the sandbox live-fire (ops/RUNBOOK.md §6b.2). docs/progress/2026-10-10.md.

  • 2026-10-10 — Move to Stripe billing by invoice (DECISIONS #46, spec 05 FR-12d, migration 0079). Phase 2 of the above: a free or bank-paid member is moved onto a Stripe subscription billed by invoice — one invoice FMS emails, nothing changes until it is paid, then Stripe invoices each period and FMS emails each one; bank transfers are marked paid from the member page. Built and tested against the Stripe mock; the first payment, the bank path, a renewal on a test clock and Stop wait for the sandbox live-fire (ops/RUNBOOK.md §6b.3). docs/progress/2026-10-10.md.

  • 2026-10-13 — Opn Payments (Omise Thailand) as a second provider, test integration (DECISIONS #48, migration 0083). An Omise account connects beside the Stripe accounts and routes by capability and a per-account routing_priority (Opn at 10 = first). Donations, gifts and individual memberships work on it end to end: Omise.js card entry, 3-D Secure redirect and return, signed webhooks, a GET /events reconcile, THB settlements in Reports, and FMS-driven renewals with FMS's own retries (period end, +3 d, +7 d, then dunning grace). Built and tested against an Omise mock (eleven opn_* suites, 91 tests); no real Omise account yet; separate brake ALLOW_LIVE_OPN_WRITES. Not on the cutover path. Risks FIN-14, FIN-15, SEC-19, OPS-3. docs/progress/2026-10-13.md.

  • 2026-10-14 — Member billing page (DECISIONS #49, migration 0084, plugin 0.48.0). The owner's rule: the provider's own portal first, FMS's own page only where none exists. A Stripe-billed member keeps Stripe's Customer Portal; an Opn-billed member gets FMS's billing page — what is true now in one line, change card (an overdue renewal is charged on the new card at once), change plan from the next renewal, cancel at the period end with Resume, printable receipts. Staff see a booked Opn plan change on the member page. A member's cancel now ends at the period end, then free Community unless another membership (the owner's rule, both providers, plugin 0.48.1, Resume until then). Suites member_billing (8), opn_member_billing (9), account_pages (6), plus webhooks / opn_renewals cases. One question left on Decision board card 49 (a switch to free from the plan list). docs/progress/2026-10-14.md (WP7).

  • 2026-10-15 — Segment-by-segment migration (DECISIONS #52, migration 0088, Member Gate 0.51.0). The owner asked to move members from PMPro a segment at a time instead of one flip. Members an importer creates are held: FMS keeps their copy current and acts on nothing for them until staff hand over a segment (teams, Fanatics, Followers, Friends, Community, no membership), a team or a member on the console's new Migration page; Hand back is the per-segment rollback. While PMPro is active the plugin serves only handed-over members, a New members join through FMS switch moves new sign-ups, and PMPro cannot be deactivated while anyone is held. The same day's audit of the import, export and verification path found eighteen issues: #3 and #18 are fixed by the hold, a parallel importer and export pass takes most of the rest, #15 (PMPro gift and discount codes) is MIG-18. New risks MIG-17 and MIG-18; runbook Phase S. Suite migration_segments. Not yet run on the rig or a real WordPress. progress/2026-10-15-segment-migration.md.

  • 2026-10-16 — Go-live sweep and deploy-day fixes. Membership emails are decided per payment connection (DECISIONS #53, migration 0090), and Opn members get FMS's own receipts, renewal reminders and failed-payment emails. Emails and member pages carry the real logo. CI and the guide deploy are green again. Migration is a Settings tab. The go-live sweep found three deploy-day defects, now fixed: docker-compose.yml passes every variable the app reads (a lib test keeps it so), the nginx file ships Cloudflare's real-IP lines, and .env.example and the runbook say what first boot needs. A plan price edit now reaches Stripe (FIN-1: a new price for new members, existing members keep theirs). Consolidation can add the paying members Mailchimp does not hold to the audience (DECISIONS §11 A follow-up). Nothing on the code-owed list (Next, item 5) is left. progress/2026-10-16-production-env.md.

  • 2026-10-24 — Environment binding and per-environment workers (DECISIONS #55, migration 0092, Member Gate 0.55.0). A request reaches the environment its credential or link names, never the console's switch; production's background work always runs and a sandbox's only while an owner has started it, so a tester can no longer park production (MIG-16's M4) and a cloned sandbox can no longer take production's webhooks (SEC-20). A staging WordPress runs on its own sandbox key. docs/progress/2026-10-24-environment-binding.md.

  • 2026-10-27 — Everyone moves on one morning (DECISIONS #52, note 2026-10-27). The Business owner decided the move from PMPro is all at once, not segment by segment: the earlier assumption that some members would stay on PMPro for weeks while others had moved was a mistake. Nothing was built or changed in the code: the hold, the Migration page, Hand over everyone…, Hand back… and the plugin's two switches stay as they are. The plan is re-cut: load and verify with everyone held, then one morning (delta pass, Stripe brake armed, master switch on, Hand over everyone…, new sign-ups through FMS, checks), a two-to-three-day soak with PMPro still active, then PMPro off. The segment, team and member tools are now the rollback and straggler tools. The runbook gains a T-relative day-by-day table and the hosts as they are (WordPress and FMS on different servers; the archive on the FMS server). Code check for the day: while PMPro is active the website opens the archive only for members FMS serves, so the archive hand-off is checked after the hand-over, not before. MIG-17 now lasts the soak's days. The same day the owner answered two open items: there are no outstanding PMPro gift or discount codes (MIG-18 closed, the count and settle steps removed), and a member whose renewal fails keeps reading for up to 30 days while Stripe retries (DECISIONS #21 = B; dunning grace on the Stripe renewal webhook is code owed, item 5). progress/2026-10-27-all-at-once-cutover.md. Built the same day: dunning grace for a failed Stripe renewal (#21 B: up to 30 days of access while Stripe retries; closes FIN-3; progress/2026-10-27-dunning-grace.md), and Member Gate 0.57.0, which keeps each member's last answer for up to 24 hours when FMS does not answer (#20 = C, decided that day; a site left on the PMPro fallback behaves the same once PMPro is off; MIG-12's outage half; rig check owed; progress/2026-10-27-outage-last-answer.md). The console's Migration page gained the red Hand back everyone… card for the soak's rollback.

  • Not done since the 2026-09-02 plan: the go-live operator session (webhook re-provisioning + live-fire), rehearsal rig Phase B/C, the VPS dress rehearsal, the real R2 bucket + drill, the archive's VPS deploy, and the code owed under item 4 below except Reports. The Oct 6 cutover window in the old table is therefore not reachable; the re-cut sequence is below.

  • Next (updated 2026-09-23 — re-cut against the grant frame; order is the recommendation):

    1. Go-live operator session (owner at the Stripe dashboard, ~half a day). Re-provision every Stripe account's webhook endpoint (ops/RUNBOOK.md §6b; reconciliation flags webhook_stale until done) so invoice.payment_action_required is delivered, then the live-fire script in progress/2026-08-26.md "Go-live session plan": renewal SCA on a test clock (4000 0027 6000 3184; scenario written, awaiting its first run: the live-fire rehearsal's renewal_sca, progress/2026-10-12.md), gift → paying conversion, one Customer Portal session, and now a team invoice end to end. Gate for metric 1 and 5.
    2. Deploy the newsletter archive to the VPS Done 2026-09-24: archive.frontiermyanmar.net is live with the back catalogue from 2020 (metric 4 ✅, a month early).
    3. Decisions, owned per role since 2026-10-01: colleagues answer from DECISION-BOARD.md (cutover date #18, member comms #19, outage policy #20 (2026-10-27 = C, 24 h, built), past-due access #21 (2026-10-27 = B, built), seat prices #25, legacy prices #34 and the rest of 18–34, plus please confirm on the calls Thomas took alone); Thomas keeps the technical ones. Earlier list, from DECISIONS.md: MIG-2 member emails (§2, decided 2026-09-29 = B; still set a number for metric 2); MIG-4 Mailchimp at cutover (§3, updated 2026-09-23 — option A still needs the ALLOW_LIVE_MAILCHIMP_WRITES brake); MIG-5 category restrictions (§4, 2026-10-15 = A, automated: the restriction sync flattens category rules onto their posts; still to run on the rig and the live site); §10 security hardening picks; R8 dress-rehearsal host + hostnames (§5).
    4. Track 2 email layer (metric 2) Built 2026-09-29 (MIG-2 = B): FMS sends ends soon (14 and 3 days, comp/external grants), has ended, is cancelled and welcome through the relay, each off until switched on per environment on Settings → Email; the win-back campaign is Mailchimp's, sent to the fms:lapsed tag with a code= checkout link (plugin 0.44.0); the Dashboard counts Won back. Left for the owner: a number for metric 2 (§2 M7), the end-date check before ends soon goes on, and sending the campaign after cutover. progress/2026-09-29.md.
    5. Code owed before cutover: FIN-2 integration test for the durable EnsureStripeCancel path (dispatcher 2026-10-02, all four enqueue sites 2026-10-15); FIN-4 critical, re-alerting dead-letter signal (re-fires on every growth, email + webhook, 2026-10-02; webhook proven 2026-10-15 — not a public-probe 503 by decision); MIG-7 wp_user_conflict rebind (2026-10-02); plan price propagation to Stripe (replaces the FIN-1 refuse-the-edit guard) (2026-10-16, team rungs still go through the ladder); the Mailchimp brake if MIG-4 = A (ALLOW_LIVE_MAILCHIMP_WRITES); a one-click add to audience for members Consolidation reports absent (DECISIONS §11 A follow-up) (2026-10-16); SEC-13 production refuses the compose default admin password (2026-10-02; sign-in refused outright since 2026-10-03, re-audited 2026-10-15). MIG-9 is closable by the B1 parallel-run decision ticked on §5 2026-10-01. FIN-3 / #21 B, dunning grace for a Stripe renewal gone past due (open again on 2026-10-27 when #21 was decided, built the same day: progress/2026-10-27-dunning-grace.md); nothing in this item is open. Follow-ups, not blocking: the cutover importer's dunning deadline still uses the period end, and the console member page does not show the grace end date. The plain-language list of what still blocks cutover is RISK-BOARD.md. progress/2026-10-15-cutover-code-owed.md.
    6. Rehearsal rig Phase B → C, then the VPS dress rehearsal (decision R6): fresh Stripe dumps via migration/live-profile/fetch.sh, mirror the live subscriptions onto the sandbox books (three un-mappable legacy prices need the owner), console assert / journeys steps. Record the pre-project failed-payment baseline from the imported Stripe history so October's measurement has a comparator (metric 1). 6a. Size the day (MIG-16, code landed 2026-10-12): time Mailchimp reads and writes on the test VPS (measure-throughput.md), then take the production numbers a week ahead and set DB_MAX_CONNECTIONS=30, the nginx real-IP lines and no active sandbox for the window (runbook Phase P step 5). Ship the console's job-based Consolidation screen with the backend. progress/2026-10-12.md. 6b. All-at-once cutover (DECISIONS #52, built 2026-10-15 for segments; plan re-cut 2026-10-27): rehearse the all-at-once path on the rig or the test VPS (load, verify, switch on, Hand over everyone, new sign-ups, PMPro off, the Mailchimp clean-up against the stub, the archive hand-off), deploy FMS with the 27 Oct build (dunning grace, #21 B) and install Member Gate 0.57.0 (outage policy Keep each member's last answer, #20 C), then load and verify with everyone held, hand everyone over on one morning, soak two to three days with PMPro active, PMPro off last. The segment tools are the rollback. The T-relative plan and a proposed calendar are the table below, for the owner (#18). progress/2026-10-27-all-at-once-cutover.md, runbook migration-runbooks/README.md The day, step by step.
    7. Post-cutover: invoice backfill → settlement backfill until Reports' "awaiting fee data" is zero; Mailchimp re-sync per MIG-4; Sprint 9 pilot metrics. (The real R2 bucket + drill moved to the prerequisites, OPS-1 "deploy day"; the Stripe brake is armed at Phase S0, on the morning of the hand-over.) November: final grant report from Reports + Dashboard growth, and the handover pack (this roadmap, CHANGELOG.md, DECISIONS.md, ops/RUNBOOK.md, the staff guide's Project status section). 7a. Account page, post-cutover (DECISIONS §15 AC5): self-cancel at period end + Resume (Community enrolment moves to the customer.subscription.deleted webhook); then the team invite email (spec 09 §11 gap 2) and one render path for the account panels. 2026-10-14: the cancel part is done (DECISIONS #49): every member cancel ends at the period end with Resume, and Community follows at the end from the customer.subscription.deleted webhook and the Opn sweep. Left: the owner's yes/no on a switch to free from the plan list (card 49). 7b. Opn Payments (DECISIONS #48; not on the cutover path, after it): (1) send Omise the questions — answered 2026-10-14 (ops/RUNBOOK.md §10.2: dynamic 3-D Secure, so renewals are not challenged; ref_id duplicate protection, now sent; no webhook retries; refund fee back on a void only; AMEX on for test, Visa/Mastercard still to confirm); (2) sandbox live-fire with real Omise test keys, including opn_ref_id_dedupe and opn_renewal_no_3ds (then the reconcile may re-send a lost answer with its ref_id); (3) the Business owner decides Opn primary or secondary (Decision board card 48; primary for memberships means accepting FIN-16, the dispute liability of renewals without 3-D Secure); (4) Opn's risk team approves dynamic 3-D Secure on the live account; (5) live keys in production, with ALLOW_LIVE_OPN_WRITES=true only for a deliberate window. Also owed: DECISIONS #51 (what an Omise-paying member is shown and charged — stored renewal terms now, the honest baht display before Opn is primary, docs/superpowers/specs/2026-10-15-opn-currency-display-design.md), a console button for the reconcile, an Opn ↔ FMS Consolidation check (OPS-3), regenerated guide screenshots (the billing page's too, taken from a harness). Later (DECISIONS #49): an Opn upgrade now with a prorated charge; deleting old Opn cards; an emailed receipt for every Opn payment; paying a Stripe open invoice from FMS after a card change.
    8. Still parked: team external-grant amounts on the Teams form; FIN-3 past_due decision (2026-10-27, #21 B: built the same day, item 5); porting mailbrand to the plugin's own mails. MIG-8 external-integration health (fixed 2026-10-02: the Health screen shows each connection). (Admin roles shipped 2026-10-01, five since 2026-10-10: Owner / Membership manager / Finance / Support / Viewer, DECISIONS #37.)
  • Proposed sequence to cutover (re-cut 2026-10-27 for the all-at-once move, DECISIONS #52 note 2026-10-27; T is the morning of the hand-over, a date the Business owner sets, #18; one developer). The plan is written in days relative to T; the right-hand column is one proposed calendar for the owner, every date a proposal that the owner can move. It holds only if the rehearsal of the all-at-once path and the go-live operator session have happened first. The step-by-step version, with where each step happens and who does it, is the runbook's The day, step by step (migration-runbooks/README.md).

    WhenProposed date (for the owner)StepGate at the end
    T-14 → T-7Tue 28 Oct – Tue 4 NovPrerequisites. Rehearse the all-at-once path on the rig or test VPS (load, verify, switch on, Hand over everyone, new sign-ups, PMPro off, the Mailchimp clean-up against the stub, the archive hand-off); the go-live operator session (each Stripe account's webhook endpoint, live-fire); FMS deployed with the 27 Oct build; Member Gate 0.57.0 installed; outage policy set to Keep each member's last answer (#20 C, 24 h; rig check of the outage behaviour); each Stripe account retrying for 30 days, then cancelling, with GRACE_PERIOD_DAYS=30 (#21 B); the owner confirms the date (#18) and the soak length; member comms drafted (#19)The rehearsal proves nobody is charged twice and nobody loses access; T confirmed
    T-7Tue 4 NovSize the day (P5: measure, DB_MAX_CONNECTIONS=30, nginx real-IP lines, no sandbox); the split-host pre-flight (certificate, connectivity proof from the WordPress host, service key in wp-config.php, nginx allow-list of the WordPress host's address, the Cloudflare skip clauses of ops/RUNBOOK.md §1); the real R2 bucket and a restore drill (OPS-1); WordPress pages created and mapped; Member Gate ≥ 0.57.0 installed, switch OFFNumbers in the plan; readiness fails only on its two blocking rows
    T-2Thu 6 – Fri 7 Nov (before the weekend)Load (Phase D): each Stripe account's import, the invoice backfill, the member export → Bulk import, opt-outs, restriction sync. Every imported member arrives On PMProImports complete; Migration page shows everyone held
    T-1Mon 10 NovVerify (Phase V): Verify on the latest export (0 problems, absent members reconciled), Consolidation complete, a member of each shape on the test restore. In the evening: full backups of the FMS and WordPress databases; membership emails stay offPhase V gates all pass; both backups taken
    TTue 11 Nov 2026, morning Bangkok timeThe hand-over (Phase S): production badge and Dead letters; delta pass; S0 (Stripe accounts writable, ALLOW_LIVE_STRIPE_WRITES=true); master switch ON; Hand over everyone…; New members join through FMS; the first-hour checks (each kind of member signs in, Join opens FMS's sign-up, the archive hand-off, Run sweep, Health, Stripe webhook deliveries, the PMPro changed … list, support mail); Marketing sends the member email (#19)Still on PMPro 0; every check passes, or a rollback
    T+1 → T+3Wed 12 – Fri 14 NovThe soak — PMPro still active; the only stretch where rollback is a click (untick the switch; Hand back… a segment, team or member). Members changed in the FMS console only; stragglers handed over from their member pageNo unexplained PMPro changed … rows; Dead letters clear; pending_crm back to 0
    T+3Fri 14 NovPMPro off (Phase C): readiness green, swap the member pages, re-sync restrictions, deactivate PMPro, retire PMPro's Stripe webhooks, URL sweep; then the Mailchimp clean-up window the same dayPost-deactivation URL sweep clean; zero drift; MIG-17 ends
    T+10 →week of 24 NovMembership emails on after the end-date check (card members follow each connection's switches, #53); win-back campaign; invoice backfill → settlement backfill to "awaiting fee data" = 0; first renewal cycle measured; final reporting and handoverGrant §4.7 metrics reported from Reports/Dashboard; §4.8 handover pack complete

    The 2026-10-15 re-cut moved one segment at a time from 22 October to 2 November; the owner reversed that on 2026-10-27, before any of it was run. The re-cut before that (2026-09-23) had one flip in the 20–31 October window; its earlier rows still open (operator session, code owed, rehearsal B/C) are folded into the prerequisites above; the Track 2 email layer is built.


Sprint 0 — Validate & de-risk · mostly done ​

Goal: prove the bet and lock the core technique.

  • ✅ Connect topology → standalone accounts; ✅ SG accepts AMEX recurring; ✅ routing strategy → hybrid pre-emptive + reactive (PRD §7).
  • ⬜ Remaining data: currency/FX (PRD Q1), AMEX-failure baseline (PRD Q9), existing-subscriber count (PRD Q10).

Exit: the remaining three data points gathered; everything else resolved.

Sprint 1 — Foundation · done ​

Goal: reliability + data backbone fed by real (test-mode) webhooks. Scaffold, schema (users, stripe_accounts, subscriptions, user_stripe_mappings, outbox_events, processed_webhooks), JWT auth, webhook ingestion → outbox → unified subscriptions, outbox worker (backoff, dead-letter, reaper). Exit met.

Sprint 2 — Routing rework (the core bet) · done 2026-06-28 ​

Goal: AMEX members subscribe on Singapore, transparently, with zero dunning emails. Spec: 02-payment-routing.

⚠️ Policy revision (2026-06-30): the "zero dunning emails" framing below is superseded. Stripe dunning + Smart Retries stay ON and own transient-decline recovery; FMS stops collection only on structural unsupported-method failures (decided from stripe_accounts.supported_payment_methods + card brand, never from decline codes alone), followed by a grace period + admin outreach. Dunning suppression (void/cancel) applies only to structurally doomed subscriptions.

  • Replace Stripe-Account header with a per-account keyed client registry; remove cross-account cloning.
  • Failure classifier (pure, unit-tested) — structural vs transient decline_code.
  • Hybrid routing: pre-emptive (known-unsupported → backup, no main attempt) + reactive (sync decline / invoice.payment_failed → classify → reroute).
  • Dunning suppression: disable Smart Retries + failed-payment emails on accounts; on structural failure, void/cancel on main before recreating on backup.
  • payment_reroute_events audit + pre-charge assertion.

Exit: an AMEX test card subscribes on SG with no visible switch and no Stripe email; a Visa card stays on TH; the wrong-account test passes; classifier taxonomy tests are green.

Sprint 3 — Plans, checkout & newsletter tags · done 2026-06-28 ​

Goal: real plan catalog + reliable, reconcilable tagging. Specs: 01-checkout-and-pricing, 03-mailchimp-and-tags.

  • plans / plan_prices; product create-or-reuse per account; hosted checkout by price-id query string; individual tiers + team seat ladder (min 3).
  • mailchimp_tags catalog (title + description); plan→tag link; subscribe → add tag / cancel → remove tag via the outbox; newsletter_preferences.

Exit: tiered + team checkout completes end-to-end; subscribing adds the right tag and cancelling removes it; the three-way reconciliation shows no drift.

Sprint 4 — Access API + WordPress plugin · done (landed out of order, July 2026) ​

Goal: content gating live, decoupled from billing. Specs: 04-access-can-do, 07-wordpress-plugin (+ service apps/keys from 05).

  • entitlements + plan→entitlement mapping; GET /me/access, GET /access/can.
  • service_apps / service_keys (IP/domain allowlist, revocation); member-JWT minting (POST /auth/token).
  • WP plugin: login → mint JWT → cache per session; shortcode/block gating with subscribe-CTA fallback.

Exit: WordPress correctly gates members-only content for paid, comp, and team-seat members alike; revoked keys can't mint tokens.

Sprint 5 — Admin dashboard + reconciliation · largely done 2026-06-27→30 (statistics + service-app UI remain) ​

Goal: operate the system from one pane of glass. Spec: 05-admin-dashboard. (Prereq: admin identity/role decision — PRD Q6, resolved 2026-10-01 as DECISIONS #37.)

  • Statistics (churn / acquisition / gross volume, unified, from local state) — shipped 2026-09-02. The Reports tab serves gross / Stripe fee / net per settlement currency by month and source, from the payment_settlements ledger (money collected outside Stripe included). Dashboard → Growth serves acquisition, retention and churn per UTC month, counted per member, from the subscriptions cache (migration 0037 ended_at); definitions confirmed and recorded in 05 Q1.
  • Developer (Stripe + Mailchimp connection status; connect/disconnect; service-app/key UI).
  • Plan Management (create plan + define tag; create custom/comp subscriptions).
  • Data Consolidation (unified Stripe + Mailchimp views; three-way drift check; CSV export; scheduled report emailed via the outbox).

Exit: admin sees unified stats; the three-way check surfaces drift; CSV export works; the scheduled consolidation emails a report.

Sprint 6 — Account self-service: billing, newsletters, membership, donations, teams · donations + gifts + discount codes done 2026-08-10 ​

Goal: deflect support load with member self-service. Spec: 06-user-account.

  • Account-scoped Customer Portal (opens the correct account); newsletter-preference UI; membership upgrade/downgrade.
  • Donation page ✅ (/donate, predefined + custom amount, guests welcome). Brand-routed rather than TH-only — Thailand can't charge AMEX; see 06 FR-10.
  • Gift memberships ✅ (/gift, one-off purchase → end-dated comp membership + continuation offer) and the internal discount-code catalog ✅ (once / forever spans) landed with it.
  • Team management: invite/add/remove members; prorated seat upgrades/downgrades.

Exit: members self-serve billing, newsletters, membership, donations; team admins change seats with correct proration; a custom donation is charged on whichever account can take the card and recorded against it.

Sprint 7 — Health & ops hardening · partially done 2026-07-02 (internal monitor + alerts shipped) ​

Goal: keep integrations healthy and failures visible. Spec: 08-health-and-ops.

  • Public GET /health + status probe; webhook reachability (Stripe + Mailchimp); per-account Stripe liveness; Mailchimp liveness; health_status read model; alerts emailed via the outbox + system log. (Dunning stays ON per the 2026-06-30 policy revision — the old "verify Smart Retries are OFF" check is obsolete.)

Exit: a monitor-friendly health endpoint; a failed integration emails the admin.

Sprint 8 — Packaging, deployment & disaster recovery · done 2026-08-12 ​

Goal: ship as a one-command, single-origin Docker monolith with a tested restore path. Blueprint: ARCHITECTURE §8–9 (PRD §6 NFRs).

  • Single-origin monolith: axum serves /api, the admin SPA's static dist/, the member-facing flows (/checkout, /account, /donate), and /webhooks from one process — no CORS. Multi-stage Dockerfile (cargo build + vite build → slim runtime).
  • docker compose with app + postgres + a scheduled backup container; migrations run on boot; app is stateless (config via env, no local disk state) for later scale-out.
  • Daily backup to Cloudflare R2: pg_dump | gzip at 03:00 via in-container cron, uploaded via rclone/aws-cli; prune to 7 daily + 12 weekly = 19 snapshots (mirrored by an R2 lifecycle rule as a backstop). Secrets are never in R2.
  • Scripts + runbook: deploy.sh (pull → migrate → up), restore.sh (fetch latest R2 dump → restore), ops/backup.sh, ops/RUNBOOK.md (rent server → restore → compose up → repoint DNS), .env.example.

Exit: docker compose up boots the full stack on a fresh host; a backup lands in R2 and old snapshots prune to 19; a restore drill rebuilds a working instance from the latest dump on a clean server (RPO ≤ 24h verified).

Status 2026-08-12: built and drilled. ops/backup.sh + the compose backup service (busybox cron; pg_dump --format=custom | gzip; rclone upload; retention applied to both the local directory and the remote), restore.sh (fetch-latest or --file, typed confirmation before any overwrite), deploy.sh (pull → build → up → /health + /health/ready gate), and ops/RUNBOOK.md. The drill ran the real scripts against a local rclone filesystem remote (no R2 account yet) and restored into a scratch DB: 31 migrations / 23,041 users / 23,021 subscriptions / 106 plans / 12 outbox events, identical to the source; retention pruned both streams as configured. Two things stay on the operator: create the R2 bucket + scoped token (then re-run the drill once against the real bucket), and add the lifecycle-rule backstop. The same session also landed the live-mode write brake (a967940 — mutating Stripe calls to live accounts refuse unless ALLOW_LIVE_STRIPE_WRITES=true; tests/live_write_brake.rs) and the per-IP rate limit on POST /checkout/discount (039136d; TRUST_PROXY required behind a proxy; tests/rate_limit.rs).

Sprint 9 — Pilot & measure ​

Goal: prove the hypothesis in production, safely.

  • Feature-flagged / percentage rollout of AMEX routing; instrument AMEX success rate, recovered MRR, incidents; runbook + alerts (outbox backlog, dead-letter, AMEX-on-TH declines).

Exit: real data on recovered AMEX revenue with zero wrong-account and zero reroute-caused dunning incidents → evidence-based decision on further investment.


Risks to keep visible ​

RiskMitigation
A structurally impossible charge keeps dunning (misleading "please retry" emails to a member who can never pay here)Capability-based structural stop (void + cancel) → grace period + admin outreach; transient declines are deliberately left to Stripe dunning/Smart Retries (policy revised 2026-06-30) (02).
Wrong-account charge (Sev-1)Per-account keyed client + pre-charge assertion + explicit tests (02); since 2026-08-12 the live-mode write brake additionally refuses every mutating call to a live account unless explicitly armed.
SCA-blocked renewal produces no local signalClosed 2026-08-15: invoice.payment_action_required is handled — member_invoices.action_required carrying the hosted_invoice_url, a member "Confirm payment" banner, a bounded sca_hold_until window and a renewal_sca alert (RISK FIN-13). Inert in production until every account's webhook endpoint is re-provisioned (ops/RUNBOOK.md §6b) — the first go-live operator task.
Decline misclassification (false reroute / missed reroute)Pure, unit-tested classifier; ambiguous codes default to transient (never silently move money).
Unpaid users keep members-only newslettersTagging via outbox + three-way reconciliation in Data Consolidation (03, 05).
Currency/FX mismatch (THB vs SGD) on reroutePer-account plan_prices; resolve PRD Q1 before pilot.
Admin auth undefined (no role/scope in JWT)Resolved 2026-10-01: staff accounts with roles behind one fail-closed gate, five since 2026-10-10 (DECISIONS #37, spec 05 FR-20).
Breaking existing subscribersBackfill is read-model only; no migration of live billing.
A second provider (Opn / Omise) that cannot renew unattended, or loses a charge answerFMS drives Opn renewals and retries itself; an opn_charge_attempts row before every charge + a GET /events reconcile (FIN-14); a renewal answered with 3-D Secure becomes an SCA hold (FIN-15; Opn confirmed 2026-10-14 that renewals sent without return_uri are not challenged, leaving the dispute liability FIN-16 for the Business owner); separate ALLOW_LIVE_OPN_WRITES brake; THB reported on its own (OPS-3). DECISIONS #48.
AMEX volume too low to matterSprint 0 baseline gate before heavy pilot investment.
Single server is a SPOF; data loss on host failureBuilt 2026-08-12: nightly pg_dump → Cloudflare R2 (19-snapshot retention) via the compose backup service; scripted cold rent-and-restore runbook (ops/RUNBOOK.md). Remaining: create the real R2 bucket + token and re-run the drill against it.
Untested backups (restore fails when it matters)Drill ran 2026-08-12 against a local rclone remote — 32 tables restored row-count-identical. Repeat quarterly (ops/RUNBOOK.md §5.1) and once against real R2.

North Star (beyond the current scope) ​

Pulled in only as needed: additional regions/accounts and methods (PromptPay, redirect-based methods via SetupIntent probe); admin custom per-user pricing; stacking discount codes; gifting team plans; recurring donations; richer team admin (ownership transfer); finer-grained resource-level access. (The internal coupon engine and gift memberships shipped 2026-08-10.) See each spec's Out of scope / future section.

Internal staff guide. Screenshots come from the rehearsal rig and never show real members. Project status (for developers and admins).