Bundul
Internal
Browse docs
Waiting for review

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 → PaymentsSplit conversionCheck 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 convertedPayments 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 issueReconnectCharge takes 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