# Split Rules

A payment is split by the seller's **active split rule**: a persistent, versioned rule
that owns the fee policy and decides where each payee's share comes from, applied
automatically when the payment settles. The rule's `allocationMode` names the source:

- **`absolute`** — the rule's own lines. Every payment splits the same way, and the
  payment's `orderData.payeeDistribution` is ignored.
- **`weight`** (the default) — the payment's `orderData.payeeDistribution`: the
  per-payment input that says how *this* payment divides between payees. The rule adds
  its fee policy on top.

> [!IMPORTANT]
> **Both are supported, and neither is legacy or deprecated.** This is the design, not a
> migration in progress. `payeeDistribution` is not a standalone split mechanism: it is
> the per-payment input to a `weight`-mode rule. An `absolute`-mode rule ignores it, and
> with no active rule nothing splits at all. There is therefore no migration path from
> one to the other to publish, and none is coming.

Use an `absolute`-mode rule alone when the distribution is the same for every payment.
Use a `weight`-mode rule plus `payeeDistribution` when one fee policy applies across many
payments but each payment divides differently. Either way, the rule version that ran is
captured in the split decision and remains the explanation of record even after the rule
is replaced or retired.

> [!NOTE]
> **Two surfaces, one lifecycle.** With a seller API key, manage split rules over REST
> under `/payments-api/v1/split-rules`: the operations are listed under this tag in the
> API reference, with their request and response schemas, and are described in the
> OpenAPI document a client is generated from. With a dashboard session, manage them over
> GraphQL on `POST /dashboard/graphql` (`dashboard_session` cookie required — see
> [GraphQL Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/graphql-auth)), which is what the Fynex dashboard uses. Both surfaces
> enforce the same create → preview → activate contract described below, and the REST
> shape is set out under **REST operations (API key)**.

> [!IMPORTANT]
> **A split rule is not created active, and `status` cannot be set.** Every rule is created inactive, previewed against a real amount, and then activated with the receipt the preview returns. Sending `status: "active"` to `createSplitRule` or `updateSplitRule` is rejected:

```
split rule status is lifecycle-controlled; create an inactive rule, preview it, then activate it
```

The splits section of the Fynex dashboard implements exactly this sequence and is the reference implementation.

---

## The lifecycle

Three steps, in order. Skipping the preview is not possible: activation requires a receipt that only a successful preview issues.

```flow
caption: Activation is an atomic swap: it activates this rule and deactivates the previous one.
node create 0 0 | createSplitRule | Stored inactive | | end
node preview 1 0 | previewSplit(ruleId:) | Returns activationReceipt | valid 10 minutes | warn
node activate 2 0 | activateSplitRule | previewReceipt, confirmed: true | atomic swap | end ok
edge create preview | ruleId
edge preview activate | receipt
```

1. **Create it inactive.** `createSplitRule` stores the rule and its lines. Omit `status` — the field is deprecated and any value other than `inactive` is rejected. Nothing runs against payments yet.

2. **Preview the saved rule.** `previewSplit` with `ruleId` set runs the rule against an amount and a distribution you supply, and returns the exact allocations it would produce — plus an `activationReceipt` valid for **10 minutes**. Pass `ruleId` *or* an inline `rule`, never both; only the `ruleId` form issues a receipt. If the rule cannot run, `wouldFailReason` says why and no receipt is issued.

3. **Activate with the receipt.** `activateSplitRule` takes the rule `id`, the `previewReceipt` and `confirmed: true`. The receipt is bound to that rule, that seller, the session that previewed it, and a **fingerprint of the exact rule snapshot that was previewed** — so editing the rule between preview and activation invalidates it:

   ```
   split rule changed after preview; run preview again
   ```

   Another rule's receipt, another seller's receipt or an expired one is refused the same way, and `confirmed: false` answers `activation confirmation is required`. Activating a rule that is already active answers `split rule is already active`.

`activateSplitRule` returns `SplitRuleActivationResult`, whose `deactivated` array names every rule the activation stood down.

### Three rules the API enforces

- **One active rule per seller.** Activation is an atomic swap, not an addition. Every other active rule becomes inactive in the same transaction, including one that was activated with a later `effectiveFrom` and has not started yet. That is what `deactivated` reports. A unique index in the database backs the same invariant.
- **Active rules are immutable.** `updateSplitRule`, `upsertSplitRuleLineByPayee` and `deleteSplitRuleLineByPayee` all refuse an active rule: *"active split rules are immutable; clone the rule, edit the inactive copy, preview it, then activate it"*. Use `cloneSplitRule` to get an editable inactive copy.
- **Every payee a rule pays must be payable.** Creating or activating a rule whose enabled line names a disabled or deleted payee is refused (`422` on activation). The live split would otherwise refuse that payee after the payment.
- **Active rules cannot be deleted.** `deleteSplitRule` refuses while the rule is active. Deactivate it first, or activate its replacement — which deactivates it for you.

> [!NOTE]
> `scheduleSplitRule` is disabled and returns an error. To schedule, set `effectiveFrom` / `effectiveTo` on the inactive rule, preview the saved version, then activate it. The swap is still immediate: the rule that was active stops now, not at the new rule's `effectiveFrom`. Until that date no rule applies, and payments that settle in the gap stay unsplit on the main wallet. `effectiveTo` must be later than `effectiveFrom`. Activating a rule whose `effectiveTo` has already passed (for example, one you deactivated earlier) clears that end: the rule applies from the moment it is activated.

---

## REST operations (API key)

Everything above — the lifecycle, the three enforced rules, the receipt/fingerprint contract — applies
identically here. The only difference from GraphQL is the wire shape and the identity a receipt is bound
to: a REST receipt is bound to the seller account behind your API key (not a dashboard user/session), so
it authorizes activation for any request authenticated with that same key.

All endpoints below require the standard seller `Authorization: Bearer <api-key>` header and live under
`/payments-api/v1`. Each one is in the OpenAPI document (`/payments-api/v1/openapi.json`) with its request
and response schemas and the statuses it answers, and is listed under this tag in the API reference — a
generated client can run create → preview → activate with no hand-written calls.

| Operation | Endpoint | Notes |
|---|---|---|
| Create | `POST /split-rules` | Body per `SplitRuleCreateRequest`. Always created inactive. |
| List | `GET /split-rules` | Paginated (`limit`, `offset`), newest first. |
| Get one | `GET /split-rules/{id}` | |
| Get active | `GET /split-rules/active` | The rule in effect now; 404 when there is none. |
| Clone | `POST /split-rules/{id}/clone` | Returns a new inactive draft with the same lines. |
| Delete | `DELETE /split-rules/{id}` | Refused while the rule is active. |
| Deactivate | `POST /split-rules/{id}/deactivate` | Stops the rule now; nothing splits until another rule is activated. |
| **Preview (issues receipt)** | `POST /split-rules/{id}/preview-activation` | Rule must be inactive. Returns `activationReceipt` + `activationReceiptExpiresAt` (10 minutes). |
| **Activate** | `POST /split-rules/{id}/activate` | Body: `{ "previewReceipt": "<from preview>", "confirmed": true }`. |
| Calculate (dry run) | `POST /split-rules/calculate` | Simulates a hypothetical payment against the active rule (or an inline `rule`); does not touch activation state. Every amount is integer minor units, `distribution[].amount` included. |

Errors are the usual `{"error": "<message>", "code": "<code>"}` JSON: a `401` for a missing or invalid
key (`missing_api_key`, `invalid_api_key`), a `403` for an inactive seller account
(`account_not_active`) or — on the six operations that change rules — a delegated dashboard session
whose user may not change split rules (`insufficient_scope`; a seller API key always may), and a `404`
`not_found` for an `{id}` that is not digits, which matches no route.

`PATCH /split-rules/active`, `PUT /split-rules/active/lines`, `PUT` and `DELETE
/split-rules/active/lines/by-payee/{payeeRef}`, and `POST /split-rules/{id}/schedule` are retired on REST
the same way as on GraphQL (immutable-active / disabled-scheduling): each answers `409`. Clone, edit the
inactive copy, preview, activate. They are deliberately absent from the OpenAPI document, so that no
generated client offers them.

#### Worked example — create, preview, activate

```bash
BASE="https://api.fynex.ai/payments-api/v1"
AUTH="Authorization: Bearer $SELLER_API_KEY"

# 1. Create — inactive by default.
RULE=$(curl -s -X POST "$BASE/split-rules" -H "$AUTH" -H "Content-Type: application/json" -d '{
  "type": "custom",
  "lines": [{ "payeeRef": { "id": 101 }, "allocationType": "percent_bps", "percentBps": 5000 }]
}')
ID=$(echo "$RULE" | jq -r '.id')

# 2. Preview — issues the activation receipt. No amount/distribution needed; this previews the
#    saved rule content itself, unlike /split-rules/calculate which simulates a hypothetical payment.
PREVIEW=$(curl -s -X POST "$BASE/split-rules/$ID/preview-activation" -H "$AUTH")
RECEIPT=$(echo "$PREVIEW" | jq -r '.activationReceipt')

# 3. Activate with the receipt (valid 10 minutes).
curl -s -X POST "$BASE/split-rules/$ID/activate" -H "$AUTH" -H "Content-Type: application/json" -d "{
  \"previewReceipt\": \"$RECEIPT\",
  \"confirmed\": true
}"
```

A refused receipt answers `409`. A stale preview — the rule's content or version changed since, for
instance through the GraphQL `updateSplitRule` mutation if dashboard staff also adjust your rules —
answers `split rule changed after preview; run preview again`: preview again. A receipt that is missing,
malformed, expired, or was issued for another rule or seller answers the same
`invalid or expired split rule activation receipt` in every case, so a caller can't use the failure
shape to probe rule ids it doesn't own.

---

## GraphQL operations

### Queries

| Operation | Signature | Permission |
|-----------|-----------|------------|
| List rules | `splitRules(limit: Int, offset: Int, merchantId: ID): [SplitRule!]!` | `SPLITRULES_READ` |
| Get rule | `splitRule(id: Int!): SplitRule` | `SPLITRULES_READ` |
| Get the active rule | `activeSplitRule(merchantId: ID!): SplitRule` | `SPLITRULES_READ` |
| Preview a split | `previewSplit(input: SplitRulePreviewInput!): SplitRulePreviewResult!` | `SPLITRULES_READ` |
| List executions | `splitExecutions(dateFrom: Time, dateTo: Time, merchantId: ID): [SplitExecution!]!` | `SPLITRULES_READ` |

### Mutations

| Operation | Signature | Permission |
|-----------|-----------|------------|
| Create (inactive) | `createSplitRule(input: CreateSplitRuleInput!): SplitRule!` | `SPLITRULES_CREATE` |
| Update (inactive only) | `updateSplitRule(id: Int!, merchantId: ID, input: UpdateSplitRuleInput!): SplitRule!` | `SPLITRULES_UPDATE` |
| Activate | `activateSplitRule(id: Int, input: ActivateSplitRuleInput): SplitRuleActivationResult!` | `SPLITRULES_UPDATE` |
| Deactivate | `deactivateSplitRule(id: Int!, merchantId: ID): SplitRule!` | `SPLITRULES_UPDATE` |
| Clone (to edit an active rule) | `cloneSplitRule(id: Int!, merchantId: ID): SplitRule!` | `SPLITRULES_CREATE` |
| Upsert one line by payee | `upsertSplitRuleLineByPayee(input: UpsertSplitRuleLineByPayeeInput!): SplitRule!` | `SPLITRULES_UPDATE` |
| Delete one line by payee | `deleteSplitRuleLineByPayee(input: DeleteSplitRuleLineByPayeeInput!): SplitRule!` | `SPLITRULES_UPDATE` |
| Delete (inactive only) | `deleteSplitRule(id: Int!, merchantId: ID): Boolean!` | `SPLITRULES_DELETE` |

---

## How Fynex splits money

This section is the one description of what a split does with money. Every other page that mentions splits points here. All the figures below are integer minor units (`10000` is €100.00) and were produced by the same calculation the preview, `POST /split-rules/calculate` and the live split run.

### What a split divides, and when

A split runs **once**, when the payment's money is credited to the seller's main wallet at settlement. It divides the settled credit (`amountBase: net_settled`, the only base supported today; `gross_payment` is refused). It then moves each payee's net from the main wallet to that payee's wallet as a `WalletTransfer`, and records a `SplitExecution` with the `ruleVersion` it ran and a decision record (`GET /payments/{paymentId}/split-decisions`). The rule in effect at the credit's settlement time is the one that applies.

### Weight mode: the payment says how it divides

Each payee gets `amount × weight / sum of weights`, where the weights are the payment's `orderData.payeeDistribution` shares. The shares always add up to the whole amount.

> **Example.** A `weight` rule of `type: fixed` with a 5 % fee (`percentBps: 500`), and a payment of 10000 distributed 7500 / 2500:
>
> | Payee | Share | Fee (5 %) | Net |
> |---|---|---|---|
> | A | 7500 | 375 | 7125 |
> | B | 2500 | 125 | 2375 |
>
> The seller keeps the fees, 500, on its own operational wallet. Nothing else is left over.

Three equal weights on 100 give 34 / 33 / 33: the unit that does not divide evenly goes to the largest dropped fraction, ties to the lowest payee ID (see [Rounding](#rounding)).

### Absolute mode: the rule says how it divides

Each enabled line claims `allocationPercentBps` of the amount, `allocationFixedAmountMinor`, or both. Whatever the lines do not claim is the remainder (see [Where the rest goes](#where-the-rest-goes)).

> **Percentage.** Lines of 60 % and 30 % on 10001 pay 6001 and 3000. The 90 % the two lines take together is 9001 (half-up), and the extra unit goes to the 60 % line, which dropped the larger fraction. The remainder is 1000.
>
> **Fixed.** Lines of 2000 and 500 on 10000 pay exactly 2000 and 500. The remainder is 7500.
>
> **Mixed.** A line of 10 % plus 200 on 10000 pays 1000 + 200 = 1200. The remainder is 8800.

### Fees

The fee is withheld from each payee's share and goes to the seller's own operational wallet:

- **`type: fixed`**: the rule's `percentBps` and `fixedAmountMinor` apply to every payee's share alike.
- **`type: custom`**: each line carries its own `percentBps` and `fixedAmountMinor`. Every payee that gets a share must have an enabled line.

The fee is `share × percentBps / 10000`, rounded half-up, plus the fixed part. A fee larger than the share fails the split.

> **Example.** A `custom` line of 60 % with a fee of 2 % + 30 on 10000: share 6000, fee 120 + 30 = 150, net 5850.

### Priority and over-allocation

When the lines of an absolute rule claim more than the amount, `overAllocationPolicy` decides. `priority` (lower goes first, ties to the lowest payee ID) matters only to `cap_by_priority`.

> Two fixed lines of 7000 (priority 1) and 5000 (priority 2) on 10000, i.e. 12000 claimed:
>
> | Policy | Result |
> |---|---|
> | `fail` (the default) | The split fails and moves nothing; the money stays on the main wallet. |
> | `cap_by_priority` | 7000 and 3000: priority 1 is paid in full, priority 2 gets what is left. |
> | `scale_down_all` | 5833 and 4167: both scaled to fit, the leftover unit by largest remainder. |
>
> `scale_down_percent` keeps fixed claims whole and scales only the percentage claims. A fixed 2000 plus two 50 % lines on 10000 pays 2000, 4000 and 4000. If the fixed claims alone exceed the amount, the split fails.

A rule that fails at preview never gets an activation receipt. A rule can still preview cleanly on a round amount and fail on a real one, which is why `fail` is worth choosing deliberately.

### Minimum and maximum

In absolute mode, a line's `minAmountMinor` raises its share and `maxAmountMinor` caps it. Both apply after the over-allocation policy.

> **Example.** A 1 % line with `minAmountMinor: 500` and a 50 % line with `maxAmountMinor: 3000`, on 10000, pay 500 (1 % would be 100) and 3000 (50 % would be 5000). The remainder is 6500.

If raising shares to their minimums would take more than the amount, the split fails.

### Rounding

All split arithmetic is in integer minor units, and the preview, `/split-rules/calculate` and the live execution run the same code, so they always round the same way.

- **Absolute-mode percent lines.** Together they receive `gross × (sum of their allocationPercentBps) / 10000`, rounded half-up. Each line first gets its share with the fraction dropped. Any minor units left over go one each to the lines with the largest dropped fractions. Ties go to the lowest payee ID. A 50 % / 50 % rule on 101 pays 51 to the lower payee ID and 50 to the other, never 51 + 51. A single percent line simply rounds half-up.
- **Fixed amounts** (`allocationFixedAmountMinor`) are added to a line exactly. They are never rounded.
- **Weight mode.** Each payee gets `gross × weight / sum of weights` with the fraction dropped. The minor units left over go the same way: largest dropped fraction first, ties to the lowest payee ID. The shares always sum to exactly the gross.
- **Fees.** Each line's fee is `share × percentBps / 10000`, rounded half-up, plus its fixed fee.

A live split can pay less than the preview showed in one case only; see [When a wallet cannot cover it](#when-a-wallet-cannot-cover-it).

Whatever the absolute-mode lines leave unallocated is the remainder, and `remainderPolicy` decides where it goes. See [Where the rest goes](#where-the-rest-goes).

### Where the rest goes

A split never leaves money unaccounted for. Whatever the payees' nets do not take stays with the seller, and `previewSplit` names it instead of leaving you to subtract:

- `remainderMinor` — the part of the amount that no allocation takes;
- `remainderDestination` — where that remainder goes: `main_wallet` or `remainder_wallet`;
- `sellerRetainedMinor` — the seller's own share: `remainderMinor` plus `totalFeeMinor`.

The payees' `netAmountMinor` values plus `sellerRetainedMinor` always add up to `totalAmountMinor`. The fees always go to the seller's own operational wallet. The remainder goes as follows:

| Case | `remainderMinor` | `remainderDestination` |
|------|------------------|------------------------|
| Weight mode | `0`: the weights share out the whole amount, so the seller keeps only the fees | `main_wallet` |
| Weight mode, every weight `0` | The whole amount: the split is skipped | `main_wallet` |
| Absolute mode, `remainderPolicy: to_main_wallet` (the default) | What the lines leave unallocated | `main_wallet`: it stays where the payment was credited |
| Absolute mode, `remainderPolicy: to_remainder_wallet` | What the lines leave unallocated | `remainder_wallet`: moved to the rule's `remainderWalletId` |
| The rule would fail (`wouldFailReason` is set) | The whole amount: a failed split moves nothing | `main_wallet` |
| No active rule | The whole amount: the split is skipped | Not reported. `previewSplit` returns an error unless you pass an inline `rule` |

Weight mode ignores `remainderPolicy`, so it reports `main_wallet` whatever the policy says. A zero remainder moves nothing, whichever destination is reported.

A 30 % / 50 % absolute rule on 10000, with a 1 % fee on the 30 % line, allocates 3000 (fee 30, net 2970) and 5000 (net 5000). It reports `remainderMinor: 2000`, `totalFeeMinor: 30` and `sellerRetainedMinor: 2030`. 2970 + 5000 + 2030 = 10000.

### When a wallet cannot cover it

- **At split time.** A payment credited net of Fynex's fees is split on its gross. When the seller's main wallet cannot cover those fees, the payees (and a remainder routed to the remainder wallet) give back up to that amount, pro rata to their nets. Finance gets a `split_fees_uncovered` review. This is the one case where a live split pays less than the preview showed.
- **After a refund or chargeback.** A payee who has already been paid out may not hold their share of it. See [Refunds and chargebacks](#refunds-and-chargebacks): the shortfall is shown and the seller's main wallet bears it. Fynex never drives a payee wallet negative.
- **Paying out.** A payout is capped at what the wallet can withdraw: its available balance less any unmatured hold.

### Holds

Every leg a split posts lands in the destination wallet at once, but under a maturity hold. The part the main wallet keeps is held the same way. It becomes withdrawable **T+N business days** after settlement, where N is the seller's `payoutHoldDays` (3 by default, between 1 and 30), counted on the currency's settlement calendar. Until then it counts in the wallet's balance but not in what a payout can take.

On a **Demo** account there is no hold: sandbox money has no chargeback window to wait out, so
the payment's credit and every leg of its split are withdrawable as soon as they post, and a
payee can be paid in the same session as the payment.

### Rule versions

A rule never changes under a payment:

- Every activation records a new `version`, and each `SplitExecution` stores the `ruleVersion` it ran.
- The decision record keeps the rule and its lines exactly as they were, so a split stays explainable after the rule is replaced.
- An active rule cannot be edited, only replaced: clone it, edit the copy, preview it, activate it.
- One rule is active per seller at a time (see [Three rules the API enforces](#three-rules-the-api-enforces)).

### Refunds and chargebacks

A split runs once. Money that later goes back to the buyer is **not** taken back from the payees automatically. What Fynex does instead:

**Who bears it.** Every party gives back the same fraction of what the split paid it as the fraction of the payment that went back. That covers each payee, the rule's commission, a remainder routed to its own wallet, and the seller's main wallet for what it kept. A refund of 30 % of the payment takes back 30 % of each leg. The part of the payment the split never divided (for example fees withheld before a `net_settled` or annex-net split) is the seller's to bear. Rounding is half-up on the cumulative refunded amount, spread over the legs by largest remainder, so a series of partial refunds adds up to exactly the whole split once the payment is fully refunded.

**What happens.** For every refund and every lost chargeback on a live payment whose split succeeded, Fynex computes that plan once. It records the plan with each wallet's available balance and any shortfall, and sends it to Fynex finance for review. **No wallet is debited automatically**: an operator makes the transfers. The payee wallets, the commission wallet and the remainder wallet each return their share to the seller's main wallet, and the main wallet carries the refund. This runs when `payments.split.refund_reversal_review` is on, and picks up refunds and chargebacks from the last 14 days.

| Scenario | What Fynex does | Who bears it | API / webhook |
|---|---|---|---|
| Refund **before** the split (the credit has not arrived yet) | When the split runs, the refund is planned as `refund_before_split`. Some providers net the refund out of the credit the split divides, so the operator checks the credit before acting on the plan. | As above, only if the credit included the refunded money. | `PaymentRefunded` webhook when the refund succeeds. |
| Refund **after** the split, before any payout | Planned as `refund_after_split`. The legs hold the money, so there is no shortfall. | Each leg pro rata; the seller bears the undivided part. | `PaymentRefunded` webhook. The split execution and its decision record (`GET /payments/{paymentId}/split-decisions`) are unchanged. |
| Refund **after** a payee was paid out | Same plan. A leg whose available balance is below its give-back shows a shortfall. | Until the payee is asked for it outside Fynex, the seller's main wallet bears the shortfall. Fynex never drives a payee wallet negative. | `PaymentRefunded` webhook. |
| **Partial** refund | Each partial refund gets its own plan, computed on the cumulative refunded amount. | Pro rata to the refunded fraction. | `PaymentRefunded` webhook per refund. |
| **Insufficient** payee balance | Shown as `shortfallMinor` per leg in the plan. | Seller's main wallet, as above. | — |
| Chargeback **opened** / under review | Nothing moves. The dispute is recorded; the provider may hold the funds on its side. | — | See [Disputes & chargebacks](https://docs.fynex.ai/payments-api/v2/docs#tag/disputes). |
| Chargeback **won** | Nothing moves (nothing moved when it opened). | — | — |
| Chargeback **lost** | Planned as `chargeback_lost` for the disputed amount, exactly like a refund of that amount. No chargeback fee is charged to the split today. | Each leg pro rata; seller bears the undivided part. | — |
| **Automatic vs manual** reversal | Manual: Fynex decides and records who owes what, and finance moves the money. Automatic reversal of split legs is not available yet. | — | — |

A payment whose split moved nothing off the seller's main wallet (no payee legs, no commission) is recorded as `not_applicable`: the refund is the seller's alone. So is a refund or chargeback that arrives after earlier ones already took back everything the split divided. Neither asks finance for anything.

> **Example.** A payment of 10000 settles as 9700 and splits 70 / 30: 6790 and 2910. A refund of 3000 (30 % of the payment) reverses 30 % of what the split divided, which is 2910: A gives back 2037 and B 873. The other 90 is the seller's, the fees the split never divided.

---

## Type reference

### `SplitRule`

| Field | Type | Description |
|-------|------|-------------|
| `id` | `Int!` | Rule ID |
| `merchantId` | `ID!` | Owning seller account |
| `type` | `SplitRuleType!` | `fixed` or `custom` |
| `status` | `SplitRuleStatus!` | `active` or `inactive`. Read-only — set by the activation lifecycle, never by input |
| `amountBase` | `SplitRuleAmountBase!` | `net_settled`, the settled credit the lines run against. `gross_payment` is refused today. |
| `allocationMode` | `SplitRuleAllocationMode!` | `weight` or `absolute` — whether line allocations are relative shares or fixed claims |
| `remainderPolicy` | `SplitRuleRemainderPolicy!` | Where leftover funds go: `to_main_wallet` or `to_remainder_wallet` |
| `overAllocationPolicy` | `SplitRuleOverAllocationPolicy!` | How to handle over-allocation: `cap_by_priority`, `scale_down_percent`, `scale_down_all`, or `fail` |
| `remainderWalletId` | `Int` | Target wallet for remainder (when `remainderPolicy` is `to_remainder_wallet`). It must be one of your own active wallets other than the main wallet. This is checked when the rule is saved and again when it is activated (`422` if the wallet no longer qualifies) |
| `percentBps` | `Int` | Fee in basis points (1 bps = 0.01 %) withheld from every payee's share when `type` is `fixed` |
| `fixedAmountMinor` | `Int` | Fixed fee in minor units withheld from every payee's share when `type` is `fixed` |
| `effectiveFrom` | `Time` | Rule starts applying from this time |
| `effectiveTo` | `Time` | Rule stops applying after this time |
| `version` | `Int!` | Incremented on each saved edit; `SplitExecution.ruleVersion` records which version ran |
| `lines` | `[SplitRuleLine!]!` | Per-payee allocation lines |

### `SplitRuleLine`

| Field | Type | Description |
|-------|------|-------------|
| `id` | `Int!` | Line ID |
| `splitRuleId` | `Int!` | Parent rule |
| `payeeId` | `Int!` | Recipient payee |
| `allocationType` | `SplitRuleAllocationType!` | `percent_bps`, `fixed_minor`, or `mixed` |
| `percentBps` | `Int` | This line's **fee** in basis points when the rule's `type` is `custom` (e.g. `200` = 2 %) — not its share |
| `fixedAmountMinor` | `Int` | This line's fixed **fee** in minor units when the rule's `type` is `custom` |
| `allocationPercentBps` | `Int` | The line's share in basis points, in `absolute` mode |
| `allocationFixedAmountMinor` | `Int` | The line's fixed share in minor units, in `absolute` mode (added to `allocationPercentBps` when both are set) |
| `priority` | `Int!` | Lower is paid first under `overAllocationPolicy: cap_by_priority`; ignored otherwise |
| `isEnabled` | `Boolean!` | Whether this line is active |
| `minAmountMinor` | `Int` | Raises the line's share to at least this, in `absolute` mode, after over-allocation |
| `maxAmountMinor` | `Int` | Caps the line's share at this, in `absolute` mode, after over-allocation |

### `SplitRulePreviewResult`

| Field | Type | Description |
|-------|------|-------------|
| `ruleId` | `Int` | The previewed rule, when previewing a saved one |
| `ruleVersion` | `Int!` | Version the preview ran against. Activation checks it, and the rule fingerprint, against the live rule |
| `totalAmountMinor` | `Int!` | Amount the preview distributed |
| `totalFeeMinor` | `Int!` | Total fee across allocations |
| `allocations` | `[SplitRulePreviewAllocation!]!` | Per-payee `grossShareMinor`, `feeMinor`, `netAmountMinor` |
| `remainderMinor` | `Int!` | The part of `totalAmountMinor` no allocation takes: `totalAmountMinor` minus the `grossShareMinor` values. `0` in weight mode unless every weight is `0`; the whole amount when `wouldFailReason` is set. See [Where the rest goes](#where-the-rest-goes) |
| `remainderDestination` | `SplitRuleRemainderDestination!` | Where `remainderMinor` goes: `main_wallet` or `remainder_wallet`. Only an absolute-mode rule with `remainderPolicy: to_remainder_wallet` reports `remainder_wallet` |
| `sellerRetainedMinor` | `Int!` | The seller's own share: `remainderMinor` plus `totalFeeMinor`. The `netAmountMinor` values plus this always equal `totalAmountMinor` |
| `wouldFailReason` | `String` | Non-null when the rule could not run; no receipt is issued |
| `activationReceipt` | `String` | Opaque receipt for `activateSplitRule`. Only issued for a `ruleId` preview |
| `activationReceiptExpiresAt` | `Time` | 10 minutes after the preview |

### `SplitExecution`

| Field | Type | Description |
|-------|------|-------------|
| `id` | `Int!` | Execution ID |
| `genericPaymentId` | `Int` | Payment that triggered the split |
| `splitRuleId` | `Int` | Rule that ran |
| `sourceWalletId` | `Int!` | Wallet funds were distributed from |
| `amountMinor` | `Int!` | Total amount distributed |
| `currencyCode` | `CurrencyCode!` | Currency |
| `status` | `String!` | Execution status |
| `ruleVersion` | `Int!` | Snapshot of rule version at execution time |
| `requestedAt` | `Time` | When the split was triggered |
| `postedAt` | `Time` | When transfers were posted |
| `walletTransfers` | `[WalletTransfer!]!` | Individual per-payee transfers |

### Inputs

```graphql
input CreateSplitRuleInput {
  merchantId: ID!
  type: SplitRuleType               # fixed | custom
  percentBps: Int
  fixedAmountMinor: Int
  status: SplitRuleStatus           # deprecated — lifecycle-controlled, omit it
  amountBase: SplitRuleAmountBase   # net_settled | gross_payment
  allocationMode: SplitRuleAllocationMode   # weight | absolute
  remainderPolicy: SplitRuleRemainderPolicy
  overAllocationPolicy: SplitRuleOverAllocationPolicy
  remainderWalletId: Int
  effectiveFrom: Time
  effectiveTo: Time
  lines: [SplitRuleLineInput!]
}

input SplitRuleLineInput {
  payeeId: Int
  allocationType: SplitRuleAllocationType   # percent_bps | fixed_minor | mixed
  percentBps: Int
  fixedAmountMinor: Int
  priority: Int
  isEnabled: Boolean
  minAmountMinor: Int
  maxAmountMinor: Int
}

input SplitRulePreviewInput {
  merchantId: ID!
  amountMinor: Int!
  distribution: [SplitRulePreviewDistributionItem!]!   # { payeeRef: { id } | { externalId }, amountMinor }
  ruleId: Int          # preview a SAVED rule — this is the form that issues a receipt
  rule: SplitRulePreviewRuleInput   # preview an unsaved shape instead; no receipt
}

input ActivateSplitRuleInput {
  id: Int!
  previewReceipt: String!
  confirmed: Boolean!
}
```

`UpdateSplitRuleInput` carries the same fields as `CreateSplitRuleInput` minus `merchantId`, plus three sub-operations on lines — `createLines`, `updateLines` (each entry needs its `lineId`), `deleteLineIds` — and explicit clear flags: `clearRemainderWallet`, `clearEffectiveFrom`, `clearEffectiveTo`.

---

## Worked example — create, preview, activate

A rule that sends 30% of each net-settled payment to payee 101 and 70% to payee 102.

> [!NOTE]
> Split arithmetic is **minor units** throughout: `amountMinor: 10000` is €100.00. The inline `payeeDistribution` on a checkout payment takes major units instead — see the warning at the end of this guide.

#### curl

```bash
# Step 0 — login
curl -sc cookies.txt \
  -X POST https://api.fynex.ai/api/v1/login/dashboard \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your_password"}'

# Step 1 — create the rule. It is created INACTIVE; do not send `status`.
curl -b cookies.txt \
  -X POST https://api.fynex.ai/dashboard/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CreateSplit($input: CreateSplitRuleInput!) { createSplitRule(input: $input) { id status version lines { id payeeId percentBps priority } } }",
    "variables": {
      "input": {
        "merchantId": "42",
        "amountBase": "net_settled",
        "remainderPolicy": "to_main_wallet",
        "overAllocationPolicy": "fail",
        "lines": [
          { "payeeId": 101, "allocationType": "percent_bps", "percentBps": 3000, "priority": 1, "isEnabled": true },
          { "payeeId": 102, "allocationType": "percent_bps", "percentBps": 7000, "priority": 2, "isEnabled": true }
        ]
      }
    }
  }'

# Step 2 — preview the SAVED rule against a real amount. Returns the receipt.
curl -b cookies.txt \
  -X POST https://api.fynex.ai/dashboard/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query Preview($input: SplitRulePreviewInput!) { previewSplit(input: $input) { ruleId ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt activationReceiptExpiresAt allocations { payeeId grossShareMinor feeMinor netAmountMinor } } }",
    "variables": {
      "input": {
        "merchantId": "42",
        "ruleId": 7,
        "amountMinor": 10000,
        "distribution": [
          { "payeeRef": { "id": 101 }, "amountMinor": 3000 },
          { "payeeRef": { "id": 102 }, "amountMinor": 7000 }
        ]
      }
    }
  }'

# Step 3 — activate with the receipt from step 2 (valid 10 minutes).
curl -b cookies.txt \
  -X POST https://api.fynex.ai/dashboard/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation Activate($input: ActivateSplitRuleInput!) { activateSplitRule(input: $input) { activated { id status version } deactivated { id status } } }",
    "variables": {
      "input": { "id": 7, "previewReceipt": "<activationReceipt from step 2>", "confirmed": true }
    }
  }'
```

#### JavaScript

```js
const BASE = 'https://api.fynex.ai';

async function login(email, password) {
  const res = await fetch(`${BASE}/api/v1/login/dashboard`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error(`Login failed: ${res.status}`);
}

async function gql(query, variables = {}) {
  const res = await fetch(`${BASE}/dashboard/graphql`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const { data, errors } = await res.json();
  if (errors?.length) throw new Error(errors[0].message);
  return data;
}

await login('you@example.com', 'your_password');
const merchantId = '42';

// 1. Create — inactive, no `status` field.
const { createSplitRule: rule } = await gql(
  `mutation CreateSplit($input: CreateSplitRuleInput!) {
     createSplitRule(input: $input) { id status version }
   }`,
  {
    input: {
      merchantId,
      amountBase: 'net_settled',
      remainderPolicy: 'to_main_wallet',
      overAllocationPolicy: 'fail',
      lines: [
        { payeeId: 101, allocationType: 'percent_bps', percentBps: 3000, priority: 1, isEnabled: true },
        { payeeId: 102, allocationType: 'percent_bps', percentBps: 7000, priority: 2, isEnabled: true },
      ],
    },
  }
);

// 2. Preview the saved rule — this is what issues the activation receipt.
const { previewSplit: preview } = await gql(
  `query Preview($input: SplitRulePreviewInput!) {
     previewSplit(input: $input) {
       ruleVersion
       totalAmountMinor
       totalFeeMinor
       wouldFailReason
       activationReceipt
       allocations { payeeId grossShareMinor feeMinor netAmountMinor }
     }
   }`,
  {
    input: {
      merchantId,
      ruleId: rule.id,
      amountMinor: 10000,
      distribution: [
        { payeeRef: { id: 101 }, amountMinor: 3000 },
        { payeeRef: { id: 102 }, amountMinor: 7000 },
      ],
    },
  }
);

if (preview.wouldFailReason) {
  throw new Error(`Rule would not run: ${preview.wouldFailReason}`);
}

// 3. Activate. Deactivates whatever was active, in the same transaction.
const { activateSplitRule: result } = await gql(
  `mutation Activate($input: ActivateSplitRuleInput!) {
     activateSplitRule(input: $input) {
       activated { id status version }
       deactivated { id status }
     }
   }`,
  { input: { id: rule.id, previewReceipt: preview.activationReceipt, confirmed: true } }
);

console.log('Active rule:', result.activated.id, '— stood down:', result.deactivated.map((r) => r.id));
```

#### Python

```python
import requests

BASE = "https://api.fynex.ai"
MERCHANT_ID = "42"
session = requests.Session()

session.post(
    f"{BASE}/api/v1/login/dashboard",
    json={"email": "you@example.com", "password": "your_password"},
).raise_for_status()


def gql(query, variables):
    resp = session.post(f"{BASE}/dashboard/graphql", json={"query": query, "variables": variables})
    resp.raise_for_status()
    body = resp.json()
    if "errors" in body:
        raise RuntimeError(body["errors"][0]["message"])
    return body["data"]


# 1. Create — inactive. Sending `status` is rejected.
rule = gql(
    """
    mutation CreateSplit($input: CreateSplitRuleInput!) {
      createSplitRule(input: $input) { id status version }
    }
    """,
    {
        "input": {
            "merchantId": MERCHANT_ID,
            "amountBase": "net_settled",
            "remainderPolicy": "to_main_wallet",
            "overAllocationPolicy": "fail",
            "lines": [
                {"payeeId": 101, "allocationType": "percent_bps", "percentBps": 3000, "priority": 1, "isEnabled": True},
                {"payeeId": 102, "allocationType": "percent_bps", "percentBps": 7000, "priority": 2, "isEnabled": True},
            ],
        }
    },
)["createSplitRule"]

# 2. Preview the saved rule — returns the activation receipt.
preview = gql(
    """
    query Preview($input: SplitRulePreviewInput!) {
      previewSplit(input: $input) {
        ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt
        allocations { payeeId grossShareMinor feeMinor netAmountMinor }
      }
    }
    """,
    {
        "input": {
            "merchantId": MERCHANT_ID,
            "ruleId": rule["id"],
            "amountMinor": 10000,
            "distribution": [
                {"payeeRef": {"id": 101}, "amountMinor": 3000},
                {"payeeRef": {"id": 102}, "amountMinor": 7000},
            ],
        }
    },
)["previewSplit"]

if preview["wouldFailReason"]:
    raise RuntimeError(f"Rule would not run: {preview['wouldFailReason']}")

# 3. Activate within 10 minutes of the preview.
result = gql(
    """
    mutation Activate($input: ActivateSplitRuleInput!) {
      activateSplitRule(input: $input) {
        activated { id status version }
        deactivated { id status }
      }
    }
    """,
    {"input": {"id": rule["id"], "previewReceipt": preview["activationReceipt"], "confirmed": True}},
)["activateSplitRule"]

print("Active rule:", result["activated"]["id"],
      "— stood down:", [r["id"] for r in result["deactivated"]])
```

---

## Why did this payee get 90 and not 95?

Every split execution writes a **decision record** — a snapshot of the rule
and lines as they were when the split ran, what each line contributed, which
lines did not fire and the machine-readable reason (`line_disabled`,
`no_distribution_weight`, `zero_allocation`), and which policy moved a number
afterwards (`clamped_to_min`, `clamped_to_max`, `over_allocation_resolved`).
Read it over REST with your seller key:

```
GET /payments-api/v1/payments/{paymentId}/split-decisions
```

`paymentId` is your `externalOrderRef`, the same value the other payment reads
take.

Records come back newest first, and **more than one is normal**: a re-split
after a correction is a second evaluation, and the first entry is the
allocation in force. An empty list means no split has run for the payment —
it is not a 404. Every amount in a record is an integer in minor units; the
payment it explains still reports `amount` in major units, so trust the record
for arithmetic.

---

## Editing, replacing and removing rules

**Editing the rule that is live.** You cannot. Clone it, edit the copy, preview the copy, activate the copy:

```graphql
mutation Replace($id: Int!) {
  cloneSplitRule(id: $id) { id status version }   # a new INACTIVE copy
}
```

Then `updateSplitRule` on the clone, `previewSplit(ruleId:)` on the clone, and `activateSplitRule` — which deactivates the original as part of the same transaction. There is no window in which the seller has two active rules. There is none in which they have none either, unless the clone carries an `effectiveFrom` in the future (see the scheduling note above).

**Editing an inactive rule.** `updateSplitRule(id, input)` supports three sub-operations on lines in one call: `createLines`, `updateLines` (each needs its `lineId`), `deleteLineIds`. For single-line changes keyed by payee rather than line ID, use `upsertSplitRuleLineByPayee` / `deleteSplitRuleLineByPayee`. All of them refuse an active rule.

**Turning splitting off.** `deactivateSplitRule(id:)`. Setting `status: inactive` through an update is *not* the way — the field is rejected.

**Deleting.** `deleteSplitRule(id:)` works only on an inactive rule; an active one is refused. Deleting does not reverse split executions that have already posted — inspect `splitExecutions` before deleting a rule that has run.

---

## Per-payment inline splits

Pass `payeeDistribution` inside `orderData` when you create the payment — on `POST /payments-api/v1/checkout` or `POST /payments-api/v1/initialize-payment` — to steer that one payment's split. Each element names a `payeeId` and that payee's share in `amountMinor`: integer **minor units** of the payment currency, so `3000` is €30.00. That is the same unit split rules, previews and executions use. The shares must add up to the payment `amount`.

```json
{
  "amount": 100.00,
  "currencyCode": "EUR",
  "orderData": {
    "totalAmount": 100.00,
    "payeeDistribution": [
      { "payeeId": 101, "amountMinor": 3000 },
      { "payeeId": 102, "amountMinor": 7000 }
    ]
  }
}
```

Only the shares are in minor units. The payment `amount` and `orderData.totalAmount` stay in **major** units (`100.00` is €100.00).

- **Use `amountMinor`.** The older `amount` field carries the same share in **major** units (`30.00` for €30.00). It is deprecated but still accepted, and it behaves exactly as it always has.
- **Use one unit per distribution.** Each element sets exactly one of `amountMinor` or `amount`, and every element in the distribution sets the same one. An element that sets both or neither is refused with `400`, and so is a distribution that mixes the two.
- **Send positive shares.** An `amountMinor` of zero or less is refused with `400`, the same as a zero or negative `amount`.

> [!WARNING]
> **Read the field name before you send a number.** A field ending in `Minor` takes minor units: €30.00 is `3000`. The deprecated `amount` takes major units, like the payment `amount` and `orderData.totalAmount`: €30.00 is `30.00`. Checkout and initialize-payment both check the shares against the payment amount. A distribution in the wrong unit is a hundred times too large or too small, so it cannot add up. It is refused with `400` and `orderData.payeeDistribution total must equal payment amount`, or `… must equal orderData.totalAmount` when you sent a total. That error is about the sum, so check the units before you change the numbers.

### Precedence when both are present

`payeeDistribution` is **not** a standalone mechanism — it only ever takes effect alongside an active split rule, and which one actually decides the payee shares depends on that rule's allocation mode:

- **No active split rule on the seller account.** The split is skipped entirely — `payeeDistribution` is not read, and nothing is distributed. A persistent rule must exist and be active for a split to run at all.
- **Exactly one active rule, `absolute` mode.** The rule's own lines determine every payee's share. `payeeDistribution` is ignored completely, even if present and non-empty.
- **Exactly one active rule, `weight` mode (the default).** `payeeDistribution` supplies the shares; the rule contributes only its fee policy on top of them — `weight` mode never produces a remainder, so a rule's remainder policy has no effect here. An empty or missing `payeeDistribution` means the split is skipped, same as having no rule at all.
- **More than one active rule, with a non-empty `payeeDistribution`.** The split execution fails outright. With an empty or missing `payeeDistribution`, the empty-distribution skip above takes precedence instead and the split is silently a no-op success — this is a real, deliberate ordering in the code, not a documentation simplification.

Activation stands down every other active rule in the same transaction, and a unique index in the database backs it, so activating through either surface cannot put a seller in the last case.

### Choosing a rule mode — there is no migration path

`payeeDistribution` is not deprecated and has no removal date: it is how a payment tells a
`weight`-mode rule how to divide that payment, and it is supported on the same terms as the
rule itself. There is nothing to migrate away from. What you choose is the rule's mode:

- **The distribution is the same for every payment** — an `absolute`-mode rule alone. Its
  versioned lines are the whole allocation, in integer **minor units** (`3000` = €30.00).
  Stop sending `payeeDistribution`: the rule ignores it.
- **The distribution varies per payment under one fee policy** — a `weight`-mode rule, plus
  `payeeDistribution` on every payment you want split, in the units the warning above
  describes. A payment without one is not split.

Moving from one mode to the other is an ordinary rule change: create the new rule inactive,
preview it, activate it — activation replaces the previous rule atomically. The
[Hosted Checkout](https://docs.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) guide shows `payeeDistribution` on a checkout request.

## See also

- **[GraphQL Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain the dashboard_session cookie required for all GraphQL calls.
- **[Payees](https://docs.fynex.ai/payments-api/v2/docs#tag/payees)** — Create and manage the payees that appear in your split rule lines.
- **[Hosted Checkout](https://docs.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Pass payeeDistribution for per-payment inline splits.
- **[Wallets](https://docs.fynex.ai/payments-api/v2/docs#tag/wallets)** — Inspect the wallet ledger entries produced by split executions.
