Bundul
Internal
Browse docs
Waiting for review

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 several getUserOneSubPaymentDetails fields 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-level amount and services are 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.
  • processPayment only 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-level fullRecurringAmount + dueDate + services.
  • 'split' → use splitParts to 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 / amount is the combined total, services lists all services, and dueDate is the soonest upcoming pull. Still render the two halves from splitParts — 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)

  1. Provision — customer calls processPayment(isSplitPayment:true) → request accepted, no charge, One Sub docs not yet created.
  2. Fulfillment + charge — an admin sets up each service and triggers payment → the split One Sub docs (and charges) are created.
  3. Only after step 2 does getUserOneSubPaymentDetails reflect the split (type: 'split', populated splitParts). 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 getSplitPaymentOptionfirstPayment / 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.