archive
Split Payment — Frontend Integration Guide
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 Payment — Frontend Integration Guide
How the customer app creates a split payment, and how it detects/renders split vs single
on the One Sub screen. All queries/mutations are JwtAuthGuard-protected (user comes from
the token — never pass a userId).
Also see split-conversion-plan.md §4 — an existing SINGLE One Sub can now be converted to a split (
previewSplitConversion+convertOneSubToSplit), and for split customers severalgetUserOneSubPaymentDetailsfields now describe the whole One Sub rather than one half. Single customers are unaffected. In particular §3's ⚠️ below is now OUT OF DATE: the top-levelamountandservicesare the combined figures.
1. What "split payment" is
A customer can break their monthly bundle into two payments timed to when their bills are actually due, instead of one charge:
- Payment 1 — services due in
[X, X+15), pulled on the customer's chosen date X. Carries the fees (Bundul fee + split fee). - Payment 2 — services due in
[X+15, X+30), pulled on X+15. No extra fee.
Bundul never fronts money — each pull lands when those bills are due. A split can collapse back to a single payment if a whole bucket fails fulfillment (see §4).
2. Creating a split payment (two steps)
Step 1 — Preview: getSplitPaymentOption
query GetSplitPaymentOption($subscriptionIds: [String]!) {
getSplitPaymentOption(subscriptionIds: $subscriptionIds) {
totalAmount
firstPayment { amount percentageOfTotal subscriptionIdsUnderIt startDate }
secondPayment { amount percentageOfTotal subscriptionIdsUnderIt startDate }
}
}
| Field | Type | Notes |
|---|---|---|
totalAmount |
Float! |
combined total (both parts) |
firstPayment / secondPayment |
PaymentPartDetails! |
the two buckets |
PaymentPartDetails.amount |
Float! |
bucket total — part 1 includes the fees |
PaymentPartDetails.percentageOfTotal |
Float! |
share of total (sums to ~100) |
PaymentPartDetails.subscriptionIdsUnderIt |
[String]! |
which services are in this bucket |
PaymentPartDetails.startDate |
DateTime! |
part 1 = X, part 2 = X+15 |
Render the two buckets and let the user choose single vs split.
Step 2 — Commit: processPayment
mutation ProcessPayment(
$accountId: String!, $isSplitPayment: Boolean!,
$splitPaymentFirstPart: Float, $referralCode: String
) {
processPayment(
accountId: $accountId, isSplitPayment: $isSplitPayment,
splitPaymentFirstPart: $splitPaymentFirstPart, referralCode: $referralCode
) { error message paymentProcessId }
}
| Arg | Type | Notes |
|---|---|---|
accountId |
String! |
the funding bank account |
isSplitPayment |
Boolean! |
true for split |
splitPaymentFirstPart |
Float (nullable) |
pass firstPayment.amount from the preview |
referralCode |
String (nullable) |
optional |
Response PaymentResponse: { error: Boolean!, message: String!, paymentProcessId: String }.
Important semantics:
- The server re-computes the buckets authoritatively — the preview is advisory. You only
send
isSplitPayment+splitPaymentFirstPart; you do NOT send bucket assignments. processPaymentonly PROVISIONS. No money moves here — charging happens later when an admin triggers the run. A success response means "request accepted," not "charged."
Empty-bucket case you MUST handle: if every bill falls in one 15-day window, splitting
doesn't help, and the server returns (no paymentProcessId):
{ "error": true,
"message": "Splitting isn't available for this bundle — all your bills fall within the same window. Please continue with a single payment." }
Catch error: true and fall back to offering a single payment. (The same {error, message}
shape is used for other guard failures, e.g. invalid bank account, insufficient balance.)
3. Detecting & rendering split on the One Sub screen
Query: getUserOneSubPaymentDetails
Return type:
OneSubPaymentDetails. (Payments run on Passport.)
query OneSub {
getUserOneSubPaymentDetails {
type # <-- 'single' | 'split'
fullRecurringAmount
originalRecurringAmount
dueDate
services
preferredPaymentDate
nextPaymentDetails { nextPaymentDate nextPaymentAmount }
splitParts { # <-- per-bucket breakdown (present when type == 'split')
part amount dueDate services
}
}
}
Detect split vs single: read type: String! — 'single' or 'split'.
'single'→ use the top-levelfullRecurringAmount+dueDate+services.'split'→ usesplitPartsto render the two halves (see below).
(isSplitPayment: Boolean! still exists for back-compat and equals type === 'split', but
prefer type.)
splitParts (the per-bucket data — use this for split)
splitParts: [SplitPartDetail] (null/empty for single):
| Field | Type | Notes |
|---|---|---|
part |
Int |
1 (Payment 1 @ X) or 2 (Payment 2 @ X+15) |
amount |
Float |
what that bucket's recurring actually collects (part 1 carries fees) |
dueDate |
String (ISO) |
that bucket's next pull date |
services |
[String] |
service names in that bucket |
⚠️ UPDATED — this used to say the top-level fields reflected only one bucket. They no longer do:
fullRecurringAmount/amountis the combined total,serviceslists all services, anddueDateis the soonest upcoming pull. Still render the two halves fromsplitParts— that is the only place per-part amounts and dates live. See split-conversion-plan.md §4 for the full field-by-field diff.
4. Collapse → single (automatic)
If a whole bucket fails fulfillment (100% of its services couldn't be set up), the split
collapses to a single payment at charge time — no split fee. The surviving record is written
as a normal One Sub, so getUserOneSubPaymentDetails.type returns 'single' and
splitParts is null. No special handling: your type branch already covers it.
5. Lifecycle & timing (important)
- Provision — customer calls
processPayment(isSplitPayment:true)→ request accepted, no charge, One Sub docs not yet created. - Fulfillment + charge — an admin sets up each service and triggers payment → the split One Sub docs (and charges) are created.
- Only after step 2 does
getUserOneSubPaymentDetailsreflect the split (type: 'split', populatedsplitParts). Before that it shows the prior/empty state.
So don't expect the One Sub screen to show the split immediately after processPayment.
6. Fees & free month
- The split fee is folded into each bucket's
amount(part 1). During the free first month the Bundul fee is waived but the split fee still applies. preferredPaymentDate= the customer's chosen due date X.
Quick reference
| Need | Field |
|---|---|
| Preview both buckets before commit | getSplitPaymentOption → firstPayment / secondPayment |
| Commit a split | processPayment(isSplitPayment: true, splitPaymentFirstPart) |
| Handle "can't split" | processPayment → { error: true, message } (no paymentProcessId) |
| Is this One Sub a split? | getUserOneSubPaymentDetails.type ('single' | 'split') |
| Render the two halves | getUserOneSubPaymentDetails.splitParts[] (part, amount, dueDate, services) |
| Collapsed to single? | type === 'single' (splitParts null) |
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.