# Split Rules

Fynex supports two complementary ways to distribute payment funds across multiple payees:

1. **Persistent split rules** — a standing rule attached to a seller account that automatically distributes funds at settlement time. Managed via GraphQL mutations.
2. **Per-payment inline splits** — a one-shot distribution specified at payment creation time via `orderData.payeeDistribution`. Documented in the Hosted Checkout and Server-to-Server guides.

> [!NOTE]
> **GraphQL only — cookie session required.** Split rule operations and execution queries live on `POST /dashboard/graphql`. A valid `dashboard_session` cookie is required. See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth).

> [!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.

```
createSplitRule ──▶ previewSplit(ruleId:) ──▶ activateSplitRule(previewReceipt:, confirmed: true)
   (inactive)          (returns receipt,           (atomic swap: activates this rule,
                        valid 10 minutes)           deactivates the previous one)
```

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. Whatever was active becomes inactive in the same transaction — that is what `deactivated` reports.
- **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.
- **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.

---

## 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 a rule runs

1. **Fynex applies the active rule** server-side when a payment settles. `amountBase` controls whether lines run against the `net_settled` or `gross_payment` amount.

2. **Funds are transferred** from the seller's main wallet to each payee's wallet via `WalletTransfer` records. The split produces a `SplitExecution` record you can query later.

3. **Inspect executions** via `splitExecutions(dateFrom, dateTo, merchantId)` to audit how funds were distributed. Each execution snapshots the `ruleVersion` it ran, so an execution stays readable after the rule is replaced.

---

## 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 default) or `gross_payment` — the amount the lines run against. Commission is computed on net settled money unless the rule explicitly says gross |
| `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`) |
| `percentBps` | `Int` | Top-level percentage in basis points (1 bps = 0.01%) |
| `fixedAmountMinor` | `Int` | Top-level fixed amount in minor currency units |
| `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` | Share in basis points (e.g. `1000` = 10%) |
| `fixedAmountMinor` | `Int` | Fixed amount in minor units |
| `allocationPercentBps` | `Int` | Percentage component when `allocationType` is `mixed` |
| `allocationFixedAmountMinor` | `Int` | Fixed component when `allocationType` is `mixed` |
| `priority` | `Int!` | Execution order when funds are insufficient |
| `isEnabled` | `Boolean!` | Whether this line is active |
| `minAmountMinor` | `Int` | Minimum transfer amount (clamps the allocation) |
| `maxAmountMinor` | `Int` | Maximum transfer amount (caps the 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` |
| `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: [CreateSplitRuleLineInput!]
}

input CreateSplitRuleLineInput {
  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, and none in which they have none.

**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

For one-off distributions that do not need a persistent rule, pass `payeeDistribution` inside `orderData` at payment creation time. Each element specifies a `payeeId` and an `amount`; the total must sum to the payment `amount`.

```json
{
  "amount": 100.00,
  "currencyCode": "EUR",
  "orderData": {
    "payeeDistribution": [
      { "payeeId": 101, "amount": 30.00 },
      { "payeeId": 102, "amount": 70.00 }
    ]
  }
}
```

> [!WARNING]
> **The two split surfaces do not agree on units.** Inline `payeeDistribution.amount` is in **major** units — `30.00` is €30.00 — while split rules, previews and executions are in **minor** units, where €30.00 is `3000`. Read the field name: a value ending in `Minor` is minor units, and everything else on the checkout payload is major. Neither surface will reject the wrong one; it is simply off by a hundred.

This is documented in detail in the [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) guide. Use persistent split rules when the same distribution applies across many payments; use inline splits for ad-hoc, per-transaction control.

## See also

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