decisions
Split Conversion — converting an existing single One Sub to split payment
A frozen record of a decision at the time. Superseded by a new record rather than edited.
Written by the build · 2 September 2026
Frozen. A record of a decision at a point in time, not living documentation. Do not update it — supersede it with a new record instead. For how this works today, see
docs/explanation/anddocs/generated/.
Split Conversion — converting an existing single One Sub to split payment
Today split-vs-single is a creation-time-only choice: the customer picks it during bundling
and it is provisioned by processPayment → createRunForBundul → admin triggerPaymentForRun.
This document specifies conversion — an existing, already-running One Sub on a single
payment becoming a two-payment split.
This is the engineering record — mechanism, guard rails, decisions, API contract. For the admin/ops version (what it does, who can convert, what to do when it refuses) see split-conversion.md.
Related: split-payment-fe-integration.md (the creation flow + customer read API), payment-charge-gating-plan.md, split-conversion-test-run.md (first live run).
1. Model
Unchanged from the creation-time split — same buckets, same fee rules:
- Payment 1 — services due in
[X, X+15), pulled onX. Carries the Bundul fee + the split fee. - Payment 2 — services due in
[X+15, X+30), pulled onX+15. No fees.
For a conversion, X is not re-chosen by the customer: it is the One Sub's existing pull
day, read from the live Passport recurring charge.
The timing rule
Part 2's first pull is
D + 15, whereDis the One Sub's next (uncollected) pull. Nevertoday + 15.
The previous collection at D − 1 month already funded every service through its next
occurrence. Bucket-2 services (due [D+15, D+30) in the new cycle) were covered by that
collection, so their first unfunded occurrence is 15 days after the next part-1 pull.
Anchoring anywhere else either double-charges the customer for one cycle or leaves a
funding gap where Bundul fronts.
This holds regardless of where in the cycle the customer converts — 5 days before D or
25 days before, the mechanics are identical. Only the proximity of the first changed pull
differs, which is a copy problem, not a money problem.
No money moves at conversion. Nothing is charged, no VC is re-issued, no card is re-swapped, no BundleRun is created. Conversion only reschedules already-authorized pulls.
2. Mechanism — cancel + recreate
The existing single recurring charge is cancelled and replaced by two new recurring charges, one per bucket. (The alternative — mutating part 1 in place and minting only part 2 — was rejected: it leaves the two halves asymmetric, depends on an in-place amount update landing on a live charge, and makes the future split→single reverse a different routine.)
Order: create both, verify, then cancel
Passport cancellation is irreversible. If a mint failed after the cancel, the customer would have no active collection and no way back — a silently missed month. So every failure before step 4 must leave the customer exactly as they were.
0 preflight (reads only)
fetchRecurringChargeDetails(oldChargeId) → D (nextTransactionDate), dayOfMonth, live amount
drift gate: live amount ≠ doc.fullRecurringAmount → ABORT + alert
partition subscriptions[] by persisted nextDueDate against day(D)
recompute both parts via recomputeOneSubAmounts()
1 mint part 1 createRecurringChargeForUser(bucket1 + bundulFee + splitFee, D, part 1)
2 mint part 2 createRecurringChargeForUser(bucket2 services only, D+15, part 2)
3 verify fetchRecurringChargeDetails ×2 — amounts + start dates actually landed
4 cancel old cancelRecurringCharge(oldId, updateDatabase: true, 'OTHERS',
'Bundul: converted to split payment')
5 write docs two new UserPassportSubscription docs; bucket line items carried over
VERBATIM (streamId, nextDueDate, subscriptionVCTransactionId)
6 notify confirmation email + push
| Fails at | Compensation | Customer state |
|---|---|---|
| 0, 1 | none needed | unchanged |
| 2, 3 | cancel whatever was minted | unchanged |
| 4 | cancel both new parts, abort | unchanged |
| 5 | retry save; on persistent failure raise one_sub_drift_admin_alert |
Passport correct, Mongo lagging — do not re-cancel, the old charge is already gone |
Step 5 is the only irreversible seam and it is a local DB write after every remote call has succeeded — the right place for it.
Details that are easy to get wrong
- externalIds. The cancelled charge keeps
BDL-SUB-<userId>; the new parts takesubRef(userId, 1)/subRef(userId, 2)→-P1/-P2, both unused for a previously-single customer. A retry after rollback, or a second conversion later, would collide —subRefneeds a generation suffix (BDL-SUB-<uid>-P1g2) before those paths exist. - Cancel reason.
'OTHERS'+ a comment (mandatory for OTHERS).INCORRECTLY_CREATEDwould poison the audit trail this design exists to produce. - Cancel after the new docs are saved, or
getUserOneSubPaymentDetailsbriefly sees zero active docs and the app flashes an empty One Sub. oneSubStartedAtmust be carried forward. The read path derivesstartDate/discountEndsfrom the earliest non-cancelled doc; after conversion that is the new part 1, so the fee-free window would appear to restart.- The Bundul fee transfer charge is NOT cancelled. It keeps running at
bundulFeeand re-points to the new part-1 doc. - Part 2's
dayOfMonthis clamped tomin(day, 28)so aD+15landing on the 29th–31st doesn't wobble across month lengths. (Applied in the shared anchor helper, so creation-time splits get the same fix.) - Idempotency key on the conversion, same pattern as
createRecurringSubscription— a double-tap must not mint four recurring charges.
3. Eligibility
All must hold. Evaluated read-only by previewSplitConversion, re-evaluated at commit.
| # | Gate | Blocked code |
|---|---|---|
| 1 | An active One Sub exists and is single | NO_ONE_SUB / ALREADY_SPLIT |
| 2 | ≥ splitConversionMinCycles completed collections (default 1) |
TOO_NEW |
| 3 | Next pull D is more than splitConversionCutoffDays away (default 5) |
TOO_CLOSE |
| 4 | Both buckets non-empty | SAME_WINDOW |
| 5 | At least one service has a usable due date | MISSING_DUE_DATES |
| 6 | Not past_due / disbursementPaused |
PAST_DUE |
| 7 | Passport and Mongo agree on the current amount | AMOUNT_DRIFT |
Gate 2 is measured from Passport's own advancing nextTransactionDate (months between
the charge's start date and D), not a calendar heuristic on the doc — the same signal the
free-month fee-bump cron trusts. It also removes the free-month interaction entirely: by the
time a customer is eligible, feeState is active and the fee bump has already happened.
Gate 3 protects the window where Passport may have already generated or initiated the occurrence. 5 days is a deliberate product choice; the true floor (1, 2 or 3 days) still needs a sandbox probe — see §7.
Gate 5 was relaxed after checking live data. Per-service nextDueDate coverage is
partial: services bundled before the capture existed, and utilities (which never had a Plaid
stream), carry no date. Requiring a complete set would block essentially every customer. The
rule is now:
- per-service
nextDueDatewhere present; - else, for a utility, its
CustomerUtility.nextDueDate(kept current by the email parser), joined onutilitySubId === subscriptionId; - anything still undated rides in bucket 1 — the same default the creation path uses. Bucket 1 is the safe side: it collects earlier than the service's real due date, so an undated service can never leave Bundul fronting.
MISSING_DUE_DATES now fires only when nothing is dated, i.e. we would be bucketing blind.
Both thresholds live on the pricing doc (already the admin-editable operational record
behind Settings, already home to splitPaymentFee) and are editable from the dashboard.
4. Customer flow
Entry — One Sub screen, type: 'single': a row reading "Split into two payments —
pay in two, timed to when your bills are actually due." Hidden when ineligible, except
TOO_CLOSE, which shows disabled with its reason.
Preview (previewSplitConversion) — the two buckets with real dates and amounts, plus
the line that carries the whole flow:
Nothing is charged today. Your next payment on Sep 2 becomes $312.47 instead of $409.00, and the remaining $96.53 is pulled on Sep 17. Same total, two pulls.
Confirm — one sheet: total unchanged, +$X/mo split fee called out, effective date
restated. No bank re-selection (both pulls use the existing funding account), no balance check.
Result — the One Sub screen flips to type: 'split' immediately, because the Passport
charges were minted synchronously. This is the sharpest difference from creation, where split
only appears after an admin triggers the run.
The FE echoes back the D it displayed; if the pull fired between preview and confirm, the
commit is rejected with "your dates changed, take another look" rather than converting against
a stale anchor.
API contract
query PreviewSplitConversion {
previewSplitConversion {
eligible
blockedCode # null when eligible; see the table below
blockedReason # customer-facing copy, safe to render as-is
effectiveDate # ISO — D, the pull where the split starts
eligibleFrom # ISO — for TOO_CLOSE / TOO_NEW: when to come back
currentAmount # what the single One Sub collects today
splitFee # monthly split fee added to part 1
totalAmount # currentAmount + splitFee
parts { part amount servicesTotal fees dueDate services }
}
}
mutation ConvertOneSubToSplit($expectedEffectiveDate: String) {
convertOneSubToSplit(expectedEffectiveDate: $expectedEffectiveDate) {
error # false = converted; the customer is on two payments
message # ready to show ("Your One Sub is now two payments — …")
blockedCode # set when error: true
effectiveDate
parts { part amount servicesTotal fees dueDate services }
}
}
Pass the effectiveDate from the preview back as expectedEffectiveDate. If the pull fired
in between, the mutation refuses with DATES_CHANGED instead of converting against a stale
anchor — re-run the preview and show the new dates. Both operations are JwtAuthGuard-
protected; the customer comes from the token, never an argument.
On error: false the One Sub screen flips to type: 'split' immediately — refetch
getUserOneSubPaymentDetails.
What changed on getUserOneSubPaymentDetails
No new fields and no breaking shape change — but for a split customer four existing fields now describe the WHOLE One Sub instead of one half. Single customers are byte-for-byte unchanged (verified against live accounts).
| Field | Before | Now |
|---|---|---|
amount / fullRecurringAmount |
one part's charge | both parts combined (e.g. $30.97, not $9.99) |
services |
one part's services | every service across both parts |
startDate / discountEnds |
restarted at conversion | the original One Sub start (fee-free window no longer resets) |
nextPaymentDetails |
3 × the same amount | the next 3 real pulls, interleaved: Aug 30 $20.98 · Sep 14 $9.99 · Sep 30 $20.98 |
dueDate |
the current record's | the soonest upcoming pull across the parts |
splitParts[].dueDate |
stored date | each part's live Passport pull date |
FE actions:
- Keep branching on
type('single' | 'split') — unchanged. - For split, keep rendering the halves from
splitParts; you can now also trust the top-levelamountandservicesas the combined figures (previously they were wrong). nextPaymentDetailsis now a real mixed-amount schedule — don't assume a constant amount per row.- Add the conversion entry point:
previewSplitConversionto decide whether to show it,convertOneSubToSplitto commit, then refetch this query.
Blocked-state copy
| Code | Customer sees |
|---|---|
SAME_WINDOW |
"Splitting won't help right now — all your bills land within the same two weeks." |
TOO_CLOSE |
"You're too close to your Sep 2 payment. You can switch any time after it clears." |
TOO_NEW |
"You can split your payment after your first month of bundling." |
PAST_DUE |
"Let's get your account current first." |
MISSING_DUE_DATES |
"We're still learning when your bills are due. Check back after your next payment." |
AMOUNT_DRIFT |
"We're reviewing your One Sub. Splitting will be available again shortly." |
PASSPORT_UNAVAILABLE |
"We couldn't reach your payment provider just now. Please try again in a moment." |
DATES_CHANGED |
"Your payment dates just changed. Take another look at the split before confirming." |
IN_PROGRESS |
"We're already setting up your split payment. Give it a moment." |
PROVIDER_ERROR |
"We couldn't set up your split payment just now. Nothing changed — please try again." |
The last four are commit-only. PROVIDER_ERROR is the compensated path: charges minted for
a failed conversion are cancelled and the original is untouched, so "nothing changed" is
literally true. If a rollback cancel itself fails, ops is paged
(split_conversion_rollback_failed) because an orphaned charge would pull real money.
5. Downstream code that must become split-aware — DONE
These were pre-existing gaps that conversion makes common (they already affected fresh splits). All of them shared one root cause: "the newest active doc" is not the One Sub. On a split it is always part 2, and on a converted One Sub both new docs postdate the cancelled original.
src/payment/records/one-sub-selection.util.ts is now the single place that answers it:
| Helper | Answers |
|---|---|
pickPrimaryOneSub(docs) |
the identity record — part 1 for a split (fees, fee-transfer schedule, carried history), else the newest |
aggregateOneSub(docs) |
the customer's WHOLE position — combined total, every service, original start date, soonest pull, worst delinquency state |
matchOneSubByAmount(docs, amount) |
which half a given collection belongs to |
Call sites converted:
getOneSubPaymentDetails— top-level fields now come from the aggregate;startDatefromoneSubStartedAt(so a conversion can't re-grant the free-month window);nextPaymentDetailslists the next 3 actual pulls, interleaved across the parts with each part's own amount; every part's live Passport date is read, feedingsplitPartstoo.- Addition routing — new services are partitioned on part 1's pull day and applied to each affected record (its own charge bump, its own history entry), instead of landing on whatever doc sorted newest.
- Dunning (
payment-retry.service) — a failure is recorded against the part that collects that amount; a recovery clears that part and only lifts the disbursement gate once no part is still delinquent. The webhook now passesamounton success so the right half is identified. - Ledger categorization — a
ONE SUB PAYMENTentry's service breakdown is the part that collects that amount, not the newest record. - Transaction timeline — one expected-collection row per part, labelled with its payment number.
- Admin financials + addition-outcome email — delinquency and "updated monthly total" are aggregated across the parts.
6. Phases
- Shared partition util + read-only preview + admin knobs — DONE
src/common/utils/split-buckets.util.ts(pure window math) + unit tests;getSplitPaymentOptionrefactored onto it (verified behaviour-identical against live data withscripts/test-split-payment.js, before and after)previewSplitConversionquery +SplitConversionService.previewConversion; exercised on live customers viascripts/test-split-conversion-preview.jssplitConversionCutoffDays(5) /splitConversionMinCycles(1) on the pricing doc, read + written byGET/PUT /admin/pricing(still need the two fields on the admin Settings page)
convertOneSubToSplit— DONESplitConversionService.convertToSplit: shared plan resolver (commit mode demands LIVE Passport state), create→verify→cancel→persist, compensation, per-user-per-pull idempotency,DATES_CHANGEDguard against a stale previewsubRef(userId, part, generation)+createRecurringChargeForUser(..., generation)oneSubStartedAt/convertedFromSubId/splitGenerationon the One Sub schema,split_conversionhistory type- 14 unit tests covering ordering, rollback, both refusal paths and idempotency
- Split-awareness fixes — DONE (see §5):
one-sub-selection.util.ts+ every call site converted; 21 unit tests. The rewritten read path was diffed against live customers before/after (scripts/test-onesub-read.js) — byte-identical for single One Subs. - Comms + ops — DONE
- customer email (generic
addition_outcometemplate — per-part rows, monthly total, "nothing was charged today") + push built throughNotificationCatalog.inAppMessagewith the One Sub deeplink. Best-effort: a failed send never turns a completed conversion into an error split_conversion_completed_admin_alertto ops on every conversiondescribePlannedConversiondry run +GET /admin/payment/split-conversion-planandGET /admin/payment/split-conversions(see §8)scripts/test-split-conversion-dryrun.js
- customer email (generic
Later, not scheduled: split→single reverse (the mirror of the same engine), and a monthly rebalance for services whose due date drifts out of their bucket — fresh splits have that exposure today too.
7. Ops surfaces
Before the first real conversion on any account, dry-run it.
npm run build && node scripts/test-split-conversion-dryrun.js <userId|email>
Prints the charge that would be cancelled and the two that would be minted — externalId, amount, start date, services — from LIVE Passport state, the same state the commit reads. Creates, cancels and writes nothing. Example:
── customer@example.com (69cc80b0…) ───────────────────────────
effective Tue, 01 Sep 2026 externalId generation 1
CANCEL 769 (currently collects $65.14)
MINT part 1 BDL-SUB-69cc80b0…-P1 $32.18 starting Tue, 01 Sep 2026 [1] Netflix
MINT part 2 BDL-SUB-69cc80b0…-P2 $34.96 starting Wed, 16 Sep 2026 [2] Adobe, Spotify
Same data over HTTP for the dashboard:
| Endpoint | Returns |
|---|---|
GET /admin/payment/split-conversion-plan?email= |
the dry run above, as JSON |
GET /admin/payment/split-conversions?limit=50 |
every converted One Sub, newest first: both parts, the charge each replaced, the monthly total, when it happened |
In the dashboard (bundul-admin)
- Settings → Split conversion — the two guard rails (
splitConversionCutoffDays,splitConversionMinCycles), each with what it protects. Saves independently of the Pricing card (the API applies only the keys it is sent). - Customer 360 → Payments → Split conversion — the dry run per customer, loaded on demand (it reads live Passport state): eligible → cancel + both mints with externalIds, amounts, first pulls and services; ineligible → the blocked code and the reason the customer would see.
- Payments list — a
payment 1/2badge on each half (a split customer legitimately has two active rows), plus a Converted to split panel that appears only once conversions exist, so the first ones are easy to confirm.
Every conversion also emails ops (split_conversion_completed_admin_alert) with both
parts and the replaced charge id.
8. Open items
- No conversion has run end-to-end against Passport yet — the commit path is covered by unit tests and the dry run only. The first one should be a test account: dry-run it, run it, then confirm on Passport that the old charge is cancelled and the two new ones carry the right amounts and dates.
- Sandbox probe for the cutoff floor. Set a recurring due in 1, 2, 3 days; update its
amount; read back
nextTransactionDate+ amount; determine which cycle takes the change. The 5-day default should sit above the measured floor, not above a guess. - Whether the CTA gate should move onto
getUserOneSubPaymentDetailsas a lightweight field to save the preview round trip. - Reverse (split → single) and the monthly bucket rebalance — both deliberately unbuilt.
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.