how-to
Split conversion — admin guide
Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-01
Split conversion — admin guide
A customer on one monthly payment can switch to two, timed to when their bills are actually due. This is what it does, who can use it, and what you do about it.
What it does
| Payment 1 | Bills due in the first half of their cycle. Carries the Bundul fee + the split fee. |
| Payment 2 | Bills due in the second half. Pulled 15 days later. No fees. |
Same services, same total, plus the split fee. Nothing is charged when they convert — it only changes when money is collected, starting from their next payment date.
Behind the scenes it cancels their single recurring charge and creates one per half. The Bundul fee charge is untouched.
Who can convert
All of these must be true. The app hides or disables the option otherwise.
- They've completed at least 1 monthly payment (configurable).
- Their next payment is more than 5 days away (configurable).
- Their bills genuinely fall in two different halves of the cycle.
- We know when at least one of their bills is due.
- They're not past due and not paused.
- Their charge amount matches our records.
Both settings live in Settings → Split conversion.
Why a customer can't convert
What they see, and what it actually means:
| They see | It means |
|---|---|
| "All your bills land within the same two weeks" | Splitting wouldn't help — nothing to move to payment 2. |
| "You're too close to your next payment" | Inside the cutoff. They can convert once that payment clears. |
| "You can split after your first month" | Too new — hasn't completed a cycle yet. |
| "Let's get your account current first" | Past due. Fix collections first. |
| "We're still learning when your bills are due" | We have no due dates for their services. Usually clears after their next payment. |
| "We're reviewing your One Sub" | Our records and the payment provider disagree on the amount — this one needs engineering. |
What you can do
Check a customer — Customer 360 → Payments → Split conversion → Check conversion. Shows exactly what converting them would do (or why it would refuse). It reads live data and changes nothing. Safe to run any time.
See who has converted — Payments page, Converted to split panel at the top. Shows both halves, what each collects, and when. It only appears once someone has converted.
Spot a split customer — they have two active rows on the Payments list, badged
payment 1/2 and payment 2/2. That's normal, not duplicate data.
Change the rules — Settings → Split conversion (cutoff days, cycles required).
Customers convert themselves from the app. There is no admin button to convert someone, by design.
What to expect afterwards
- The customer's One Sub screen shows two payments immediately.
- They get an email and a push confirming both amounts and dates.
- Ops gets an alert email for every conversion.
- Their first split collection is their normal next payment date — then the second half 15 days later, and every month after that.
When something looks wrong
| Alert / symptom | What it means | Do |
|---|---|---|
Split conversion Passport/DB drift |
The charges changed but our records didn't save. | Engineering, same day. Do not cancel the new charges — they're the customer's only active collection. |
Split conversion rollback failed |
A conversion failed and a leftover charge couldn't be cancelled. | Engineering — that charge will pull money if left. |
| Customer says the amount looks wrong | Payment 1 carries both fees, payment 2 carries none. The two should add to their old amount + the split fee. | Compare against the Check conversion panel. |
| Customer wants to go back to one payment | Not built yet. | Engineering — it's a manual fix today. |
For the app team
Two new operations. Both take the customer from their token — never pass a userId.
# 1. Should we offer it, and what would they get?
query { previewSplitConversion {
eligible
blockedCode blockedReason # why not, + copy that is safe to show as-is
effectiveDate # when the split starts (their next payment date)
eligibleFrom # for "too close" / "too new": when to come back
currentAmount splitFee totalAmount
parts { part amount servicesTotal fees dueDate services }
} }
# 2. Do it. Pass back the effectiveDate the preview showed.
mutation ($expectedEffectiveDate: String) {
convertOneSubToSplit(expectedEffectiveDate: $expectedEffectiveDate) {
error message # message is ready to show
blockedCode
effectiveDate
parts { part amount servicesTotal fees dueDate services }
}
}
If their payment date moved between preview and confirm, the mutation refuses with
DATES_CHANGED — re-run the preview and show the new dates. On success, refetch
getUserOneSubPaymentDetails; the One Sub screen flips to two payments straight away.
One existing query changed. getUserOneSubPaymentDetails gained no new fields and no
breaking shape change. Single-payment customers are unchanged. For split customers,
these now describe the whole One Sub instead of one half:
| Field | Now |
|---|---|
amount / fullRecurringAmount |
both payments combined |
services |
every service across both payments |
dueDate |
the soonest upcoming payment |
startDate / discountEnds |
the original start — no longer resets on conversion |
nextPaymentDetails |
the next 3 real pulls, alternating amounts (e.g. $20.98 · $9.99 · $20.98) |
splitParts[].dueDate |
each half's live payment date |
Keep branching on type ('single' | 'split') and keep rendering the two halves from
splitParts. Two things to fix on the app side: the top-level totals are now correct for
split customers (they used to be one half), and nextPaymentDetails rows no longer share
one amount.
Full contract: split-conversion-plan.md §4.
The "which half?" audit (2026-08-27)
A split One Sub is two documents. Code written before splits existed reaches for the customer's
One Sub with findOne(...).sort({ createdAt: -1 }) — "the newest active doc". Both halves are
written by a single create([part1, part2]), so that sort is effectively a coin toss, and the half
it lands on describes only part of the customer's money.
Every findOne against userPassportSubModel was reviewed. Four were wrong:
| Where | What it did on a split | Fixed to |
|---|---|---|
changeFeeForUser |
Landing on part 2 wrote the Bundul fee onto the half that does not carry it. Part 2 has bundulFee: 0, so the idempotence check waved it through; part 1 kept charging its own fee, so the customer paid the fee twice, and the BOOK fee sweep was skipped because part 2's passportRecurringFeesChargeId is null. |
pickPrimaryOneSub — part 1 owns the fee, the split fee and the sweep |
migrateFeeForAllExisting |
Cursored over documents, so a split customer got changeFeeForUser called twice, each call re-running the same ambiguous query |
Iterates distinct users; the skip decision moved into changeFeeForUser, which reads the fee off the correct half |
Addition service snapshot (payment-orchestration) |
Showed the admin half the customer's services and half their monthly total, with no hint anything was missing | aggregateOneSub across every live doc |
| VC pre-charge verification | The halves are charged on different dates by design, so checking funding against an arbitrary half alerted a fortnight early, or not at all | Soonest daysUntilCharge across both halves |
Left alone deliberately: the findOne({ disbursementPaused: true }) / shouldSkipTopUp lookups. Those
are existence checks — "is any half paused?" — which is already the correct rule for a split, and
matches aggregateOneSub's worst-state-across-parts behaviour. The admin list endpoints use find,
so they already see both.
Open: reconnect cannot handle a split — it now refuses
processReconnectPayment is written end-to-end around exactly one document: it replaces that
doc's service list with the kept subset, re-points that doc's charge to the new funding account,
and collects one current-period charge for that doc's amount.
Run it on a split and the other half is silently left behind — still pointing at the old bank, still holding live virtual cards for services the customer just de-selected, and never reactivated. So it now throws with a message telling the customer we will finish it by hand, and logs loudly. Nothing is terminated, re-pointed or collected.
Making it split-aware needs a decision first, because reconnect collects the current period once and a split customer's money is deliberately collected in two tranches on two dates:
- One combined charge — simple, but defeats the point of the split for that month.
- Two charges, one per half — faithful to the split, but doubles the ACH count on a
flow the customer just re-authorised, and
issueReconnectChargetakes a single amount today.
Until that is settled, refusing is the safe answer. Only one split customer exists (betatesting, converted 2026-08-17), so the blast radius of the guard is effectively zero.
Known limits
- No self-serve way back to a single payment. Reversing is manual.
- Customers with no known bill dates can't convert until we learn their dates.
- A service whose billing date shifts can end up in the wrong half; it stays there until someone moves it.
Engineering detail — mechanism, guard rails, API contract, the first live run: split-conversion-plan.md · split-conversion-test-run.md