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
proddatabase 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== storedfullRecurringAmount. 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 newBDL-SUB-<uid>-P1/-P2references 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 isvirtualcardledgerentries, 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:
- 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.- The VC ledger is an unused due-date source.
buildServiceScheduleMapreads 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 onMISSING_DUE_DATESinstead 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:
amountis the combined $30.97, not one half — the old "newest doc wins" bug would have shown $9.99.serviceslists both halves' services.startDateis Jul 30, the original One Sub start carried viaoneSubStartedAt, so the fee-free window did not restart (discountEndsstayed Aug 30).nextPaymentDetailsis 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:
- Cancel 851 and 852 on Passport.
- Mint one recurring for $28.97 on day 30 with a fresh reference —
BDL-SUB-<uid>g2(the-P1/-P2refs are burnt). - Mark both split docs cancelled and write one single-payment doc carrying
oneSubStartedAtand the history. - 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).
virtualcardledgerentriesrecords when each service was actually charged — the only source that keeps working after a service moves to the card. Adding it tobuildServiceScheduleMap/ the conversion's due-day resolution would replace seeding with real data for the accounts currently refused asMISSING_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.comis 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.