Bundul
Internal
Browse docs
Waiting for review

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/ and docs/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 processPaymentcreateRunForBundul → 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 on X. Carries the Bundul fee + the split fee.
  • Payment 2 — services due in [X+15, X+30), pulled on X+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, where D is the One Sub's next (uncollected) pull. Never today + 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 take subRef(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 — subRef needs a generation suffix (BDL-SUB-<uid>-P1g2) before those paths exist.
  • Cancel reason. 'OTHERS' + a comment (mandatory for OTHERS). INCORRECTLY_CREATED would poison the audit trail this design exists to produce.
  • Cancel after the new docs are saved, or getUserOneSubPaymentDetails briefly sees zero active docs and the app flashes an empty One Sub.
  • oneSubStartedAt must be carried forward. The read path derives startDate / discountEnds from 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 bundulFee and re-points to the new part-1 doc.
  • Part 2's dayOfMonth is clamped to min(day, 28) so a D+15 landing 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:

  1. per-service nextDueDate where present;
  2. else, for a utility, its CustomerUtility.nextDueDate (kept current by the email parser), joined on utilitySubId === subscriptionId;
  3. 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:

  1. Keep branching on type ('single' | 'split') — unchanged.
  2. For split, keep rendering the halves from splitParts; you can now also trust the top-level amount and services as the combined figures (previously they were wrong).
  3. nextPaymentDetails is now a real mixed-amount schedule — don't assume a constant amount per row.
  4. Add the conversion entry point: previewSplitConversion to decide whether to show it, convertOneSubToSplit to 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; startDate from oneSubStartedAt (so a conversion can't re-grant the free-month window); nextPaymentDetails lists the next 3 actual pulls, interleaved across the parts with each part's own amount; every part's live Passport date is read, feeding splitParts too.
  • 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 passes amount on success so the right half is identified.
  • Ledger categorization — a ONE SUB PAYMENT entry'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

  1. Shared partition util + read-only preview + admin knobs — DONE
    • src/common/utils/split-buckets.util.ts (pure window math) + unit tests; getSplitPaymentOption refactored onto it (verified behaviour-identical against live data with scripts/test-split-payment.js, before and after)
    • previewSplitConversion query + SplitConversionService.previewConversion; exercised on live customers via scripts/test-split-conversion-preview.js
    • splitConversionCutoffDays (5) / splitConversionMinCycles (1) on the pricing doc, read + written by GET/PUT /admin/pricing (still need the two fields on the admin Settings page)
  2. convertOneSubToSplit — DONE
    • SplitConversionService.convertToSplit: shared plan resolver (commit mode demands LIVE Passport state), create→verify→cancel→persist, compensation, per-user-per-pull idempotency, DATES_CHANGED guard against a stale preview
    • subRef(userId, part, generation) + createRecurringChargeForUser(..., generation)
    • oneSubStartedAt / convertedFromSubId / splitGeneration on the One Sub schema, split_conversion history type
    • 14 unit tests covering ordering, rollback, both refusal paths and idempotency
  3. 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.
  4. Comms + ops — DONE
    • customer email (generic addition_outcome template — per-part rows, monthly total, "nothing was charged today") + push built through NotificationCatalog.inAppMessage with the One Sub deeplink. Best-effort: a failed send never turns a completed conversion into an error
    • split_conversion_completed_admin_alert to ops on every conversion
    • describePlannedConversion dry run + GET /admin/payment/split-conversion-plan and GET /admin/payment/split-conversions (see §8)
    • scripts/test-split-conversion-dryrun.js

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/2 badge 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 getUserOneSubPaymentDetails as 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.