Bundul
Internal
Browse docs
Waiting for review

archive

Split conversion — first live run (betatesting@bundul.io, 2026-08-17)

A frozen record of a decision at the time. Superseded by a new record rather than edited.

Written by the build · 2 September 2026

Split conversion — first live run (betatesting@bundul.io, 2026-08-17)

The first end-to-end split conversion against real Passport. This is the log of what was checked, what was changed, what broke, and what the result looks like on every surface.

Account: betatesting@bundul.io · 6974184bece6ed6b55ba2b23 (internal beta account) Outcome: converted. One Sub now collects $20.98 on the 30th and $9.99 on the 14th.

⚠️ This ran against PRODUCTION — the prod database and live Passport (charge ids 648 / 851 / 852 are real Passport records). The account is Bundul's own beta account, but the schedule change is real: it now pulls twice a month, next on Aug 30 ($20.98) and Sep 14 ($9.99), from the linked bank account. See §8 to undo.


1. Pre-state — Passport and DB agreed

node scripts/inspect-one-sub-state.js 6974184bece6ed6b55ba2b23

ONE SUB RECORD (1, ACTIVE)
  recurringChargeId : 648        feeChargeId: 649
  servicesTotal     : $19.98     bundulFee $8.99   splitFee $0.00
  fullRecurring     : $28.97     feeState active
  start / next due  : Thu, 30 Jul 2026  /  Thu, 30 Jul 2026
  services          : Spotify $9.99 (nextDueDate —, streamId —)
                      Adobe   $9.99 (nextDueDate —, streamId —)

LIVE PASSPORT
  main recurring 648 : ACTIVE  28.97  ACH   start 07/30/2026  next 08/30/2026  day [30]
                       externalId BUNDUL-SUB-6974184bece6ed6b55ba2b23   4631755 → 4870441
  bundul fee     649 : ACTIVE   8.99  BOOK  start 09/04/2026  next 09/04/2026  day [4]

Checks that mattered:

  • No drift. Live Passport amount 28.97 == stored fullRecurringAmount. The conversion's drift gate would have refused otherwise.
  • One cycle completed. Charge start 07/30, next 08/30 → 1 collection settled, meeting splitConversionMinCycles: 1.
  • Outside the cutoff. Next pull Aug 30, run on Aug 17 → 13 days, over the 5-day gate.
  • Legacy externalId. The existing charge is BUNDUL-SUB-<uid> (pre-BDL scheme), so the new BDL-SUB-<uid>-P1/-P2 references could not collide.

2. Dry run — refused, correctly

node scripts/test-split-conversion-dryrun.js 6974184bece6ed6b55ba2b23

WOULD REFUSE  [MISSING_DUE_DATES]  We're still learning when your bills are due.

Neither service had a due date in any source the code reads:

Source Read by the code? Result
per-service nextDueDate yes absent on both
Plaid recurring streams (19 on this user) yes none match Spotify or Adobe
CustomerUtility records yes none (not utilities)
virtual-card ledger no 37 entries — including a real Adobe charge (see below)

So the refusal is the system working as written: with no due dates both services fall in bucket 1, and a split with an empty half is refused by design.

Correction (found after the run). During the run this table said "virtual-card charge history: empty". That was wrong — the check queried virtualcardledgers, a collection that does not exist. The real collection is virtualcardledgerentries, and this user has 37 entries: fundings, top-ups, an authorization hold, and a genuine Adobe merchant charge on Jul 3 2026 (hold Jun 30). Two consequences, both live:

  1. Adobe's seeded date contradicts its real one. §3 seeded Adobe to the 20th; its actual billing day is ~the 3rd. Anchored on the 30th, a truthful bucketing would have put Adobe in bucket 1 next to Spotify → SAME_WINDOW, and the split would have been refused. So this account's split is real in mechanism but its bucket assignment does not match its real billing: part 2 collects on the 14th while Adobe's card is charged around the 3rd — Bundul fronts ~$9.99 for about eleven days each cycle. Small here, and it is a test account, but it is exactly the failure mode the design exists to prevent.
  2. The VC ledger is an unused due-date source. buildServiceScheduleMap reads Plaid streams only — but Plaid goes blind the moment a service moves to the virtual card, which is precisely when the VC ledger starts recording the real charge dates. Wiring it in as a fallback would give real dates to the customers currently stuck on MISSING_DUE_DATES instead of needing seeds. Logged as a follow-up in §9; not built.

3. Test seed (the one thing we fabricated)

To exercise the conversion at all, the two services needed due dates. There was no real signal, so they were written explicitly as a labelled test step — not inferred, not back-doored through the eligibility gate:

node scripts/seed-onesub-due-dates.js 6974184bece6ed6b55ba2b23 \
  6a23317c257e4bfad19db021=2026-09-05 \
  6a23319d257e4bfad19db035=2026-09-20 --apply

Spotify → the 5th (offset 5 from the 30th → bucket 1); Adobe → the 20th (offset 20 → bucket 2). The script is dry by default and has --undo.

These two dates are invented. They only drive bucketing and the calendar; they are the reason this account could be split at all, and the reason a real customer with the same data shape still cannot be. (Adobe's real charge day is ~the 3rd, not the 20th — see the correction in §2. Spotify has no charge history at all, so it has no real day.)

4. Dry run again — eligible

effective Sun, 30 Aug 2026   externalId generation 1
CANCEL  648  (currently collects $28.97)
MINT    part 1  BDL-SUB-6974184bece6ed6b55ba2b23-P1  $20.98  starting Sun, 30 Aug 2026  [Spotify]
MINT    part 2  BDL-SUB-6974184bece6ed6b55ba2b23-P2   $9.99  starting Mon, 14 Sep 2026  [Adobe]

Arithmetic: part 1 = Spotify $9.99 + Bundul fee $8.99 + split fee $2.00 = $20.98; part 2 = Adobe $9.99, fee-free. Total $30.97 = the old $28.97 plus the $2 split fee. Part 2 anchors at Aug 30 + 15 — not "today + 15" — because the cycle to Aug 30 is already funded.

5. First execution attempt — a real bug

error   : true
message : We're already setting up your split payment. Give it a moment.
code    : IN_PROGRESS

Wrong: nothing was in progress. Root cause — IdempotencyKey.response is required: true on the schema, and the conversion inserted the key with response: null. The insert failed validation, and the catch treated any insert failure as "lost the race", so every conversion would have been refused outright. Nothing had been minted at that point (the guard runs before the first Passport write), so the account was untouched.

Fix (both halves):

  • insert response: {}, satisfying the schema;
  • on insert failure, re-read the key — only an existing key means a genuine race; otherwise log and proceed, matching the proven pattern in createRecurringSubscription.

Regression test added: "still converts when the idempotency key cannot be recorded (not a race)". Suite: 19 passing.

This is exactly what the live run was for — no unit test would have caught a schema constraint mismatch.

6. Execution

node scripts/run-split-conversion.js 6974184bece6ed6b55ba2b23 --apply

error   : false
message : Your One Sub is now two payments — $20.98 on Aug 30 and $9.99 on Sep 14.
part 1: $20.98 (services $9.99 + fees $10.99) first pull Sun, 30 Aug 2026  [Spotify]
part 2:  $9.99 (services $9.99 + fees  $0.00) first pull Mon, 14 Sep 2026  [Adobe]

Order executed: mint part 1 → mint part 2 → read both back and verify the amounts landed → cancel 648 (OTHERS, "Bundul: converted to split payment") → write both records → notify. Customer email + push and the ops alert fired at the end; no send failures surfaced.

7. Verification — all four surfaces

node scripts/verify-split-conversion.js 6974184bece6ed6b55ba2b23

One Sub records — the old doc cancelled, two new ones, history carried:

CANCELLED single  charge 648  $28.97  fee-charge 649   services: Spotify, Adobe
ACTIVE    part 1  charge 851  $20.98  fee-charge 649   services: Spotify
          oneSubStartedAt Thu, 30 Jul 2026  convertedFrom 6a2335…d1a9  generation 1
          history: correction → $28.97 … | split_conversion → $20.98 (replaced charge 648)
ACTIVE    part 2  charge 852   $9.99  fee-charge —     services: Adobe
          history: split_conversion → $9.99 (split out of charge 648)

Live Passport — the authoritative check:

charge 648  status CANCELLED  amount 28.97  next —           ref BUNDUL-SUB-6974184b…
charge 851  status ACTIVE     amount 20.98  next 08/30/2026  day [30]  ref BDL-SUB-6974184b…-P1
charge 852  status ACTIVE     amount  9.99  next 09/14/2026  day [14]  ref BDL-SUB-6974184b…-P2

Admin endpoints (called through the real controller):

GET /admin/payment/split-conversions
  betatesting@bundul.io · converted Mon, 17 Aug 2026 · replaced 6a2335…d1a9 · total $30.97
    part 1: charge 851 $20.98 next Sun, 30 Aug 2026 [Spotify]
    part 2: charge 852  $9.99 next Mon, 14 Sep 2026 [Adobe]

GET /admin/payment/split-conversion-plan
  eligible=false [ALREADY_SPLIT] Your One Sub is already split into two payments.

Customer read API (getOneSubPaymentDetails):

type split   amount $30.97   dueDate Sun, 30 Aug 2026   status ACTIVE
services: Spotify, Adobe
startDate Thu, 30 Jul 2026   discountEnds Sun, 30 Aug 2026
splitParts[1]: $20.98 on Sun, 30 Aug 2026  [Spotify]
splitParts[2]:  $9.99 on Mon, 14 Sep 2026  [Adobe]
nextPaymentDetails: Aug 30 $20.98 · Sep 14 $9.99 · Sep 30 $20.98

Four things worth calling out in that payload, all Phase 3 behaviour proven on real data:

  • amount is the combined $30.97, not one half — the old "newest doc wins" bug would have shown $9.99.
  • services lists both halves' services.
  • startDate is Jul 30, the original One Sub start carried via oneSubStartedAt, so the fee-free window did not restart (discountEnds stayed Aug 30).
  • nextPaymentDetails is the real interleaved schedule — two different amounts on two different dates — instead of three identical charges of a total that never gets pulled.

The Bundul fee charge (649, BOOK $8.99 on the 4th) was left running and now belongs to part 1, exactly as designed.

8. Undo, if this account should go back to a single payment

Passport cancellation is irreversible, so "undo" means minting a fresh single charge, not restoring 648:

  1. Cancel 851 and 852 on Passport.
  2. Mint one recurring for $28.97 on day 30 with a fresh reference — BDL-SUB-<uid>g2 (the -P1/-P2 refs are burnt).
  3. Mark both split docs cancelled and write one single-payment doc carrying oneSubStartedAt and the history.
  4. Clear the seeded dates: node scripts/seed-onesub-due-dates.js <uid> --undo --apply.

There is no automated reverse yet — split → single is the deliberate not-built item in split-conversion-plan.md §8. If this account should not stay split, that reverse is the thing to build next.

9. What this run proves — and what it doesn't

Proved: the whole chain works against real Passport — eligibility gates, bucketing from persisted due dates, mint-verify-cancel ordering, the fee-charge hand-off, record writing with carried history and start date, the admin endpoints, and the customer payload.

Follow-ups this run produced:

  • Read the VC ledger for due dates (§2 correction). virtualcardledgerentries records when each service was actually charged — the only source that keeps working after a service moves to the card. Adding it to buildServiceScheduleMap / the conversion's due-day resolution would replace seeding with real data for the accounts currently refused as MISSING_DUE_DATES. Not built.
  • This account's buckets are wrong on purpose. Adobe belongs in bucket 1 by its real billing day. Either re-seed it and accept the split then collapses to "same window", or build the split → single reverse and put this account back. Not done.

Not proved:

  • No collection has happened yet. The first real proof is Aug 30 pulling $20.98 and Sep 14 pulling $9.99. Watch those two dates.
  • Rollback and drift paths are still unit-test-only — no live failure was induced.
  • Bucketing on real due dates wasn't exercised here; this account's dates were seeded, and one of them contradicts the account's real billing day (§2). teoadetayo@gmail.com is genuinely eligible off real data (Netflix → payment 1; Adobe + Spotify → payment 2) if a second test on real bucketing is wanted — but that is a real customer, so it needs their consent.
  • The customer-facing mutation was driven service-side (scripts/run-split-conversion.js); the app's GraphQL call path itself hasn't been exercised by a client.

This is a record of a decision at the time. It is not edited — write a new record that supersedes it.