Skip to content

Settlements & Payouts

A settlement is the periodic close-out where the platform pays the tenant their share of revenue. Every billing period (typically a month), the settlement service collects every payment that landed in the period, computes the tenant's cut via the revenue split rules, subtracts service fees and tax, and produces a payout amount. If the payout is under the auto-approval threshold it is approved automatically; otherwise a human reviews it. Once approved, the settlement is executed — a PayoutBatch is created and the bank transfer goes out.

The other half of this page is allocation: how a single incoming payment is spread across outstanding claims for a tenant. That uses the same service family but runs on a per-payment basis rather than per-period.

The Settlement model

craft_easy.models.settlement.Settlement:

Field Purpose
tenant_id The tenant being settled
period_start, period_end Date range covered
gross_amount Total revenue in the period before fees
platform_fee The platform's cut (from revenue splits + service fees)
net_payout gross_amount - platform_fee. The amount the tenant will actually receive
currency ISO 4217
status draft, pending_approval, approved, paid, failed
line_items Per-transaction breakdown: one SettlementLineItem per underlying payment or service event
approved_by, approved_at, auto_approved Approval audit
paid_at, payout_reference Execution audit
failure_reason Populated if the payout fails

SettlementLineItem

SettlementLineItem(
    reference_type="order",       # or "subscription", "service"
    reference_id=order.id,
    description="Order #1234 — Acme Corp",
    gross_amount=Decimal("1000"),
    platform_fee=Decimal("300"),
    net_amount=Decimal("700"),
)

Each line item traces a single revenue event to its net contribution. The sum of all line items matches the header totals; if it does not, the service rejects the settlement at create time.

Settlement lifecycle

 draft ──▶ pending_approval ──▶ approved ──▶ paid
                                  failed
  • draft — created by the settlement runner, not yet submitted. Mostly a transient state while the runner is still adding line items.
  • pending_approval — settlement is complete, over threshold, awaiting a human approver.
  • approved — approved (auto or manual). The payout runner will pick it up and execute.
  • paid — bank transfer executed, payout_reference set.
  • failed — bank transfer failed, failure_reason set. Staff can retry or adjust and resubmit.

Auto-approval threshold

SettlementService(auto_approve_threshold=Decimal("10000")). Settlements where net_payout < auto_approve_threshold skip the pending_approval state and go directly to approved. Settlements at or above the threshold stop at pending_approval and require a human approver.

The threshold exists to keep low-value routine payouts moving while making sure that a bug in the split engine cannot wire a million krona to the wrong tenant before anyone notices. Set the threshold conservatively — one bad auto-approval is worse than ten slow manual ones.

SettlementService

craft_easy.core.settlement.service.SettlementService:

from craft_easy.core.settlement.service import SettlementService

svc = SettlementService(auto_approve_threshold=Decimal("10000"))

Creating a settlement

settlement = await svc.create_settlement(
    tenant_id=tenant.id,
    period_start=date(2026, 4, 1),
    period_end=date(2026, 5, 1),
    line_items=[
        SettlementLineItem(
            reference_type="order",
            reference_id=order.id,
            description="Order #1234",
            gross_amount=Decimal("1000"),
            platform_fee=Decimal("300"),
            net_amount=Decimal("700"),
        ),
        # ... more line items
    ],
    currency="SEK",
    auto_approve=True,
)

The service:

  1. Validates that line_items is non-empty and that the sum of net_amount values matches the computed net_payout.
  2. Checks the net_payout against the threshold. If under, sets status="approved" and auto_approved=True. Otherwise status="pending_approval".
  3. Persists the document.

The caller — usually the settlement runner job — is responsible for computing line items from payments and revenue split records. The service does not aggregate payments itself; it accepts ready-made line items.

Approving a settlement

await svc.approve(settlement_id, approved_by=user.id)
# status: pending_approval -> approved

Only settlements in pending_approval can be approved. Re-approving is a no-op, approving a draft or already-approved is rejected with 409.

Executing a settlement

await svc.execute(settlement_id)
# status: approved -> paid
# payout_reference: generated

execute is what actually pays the tenant. In practice it:

  1. Fetches the tenant's payout account (from PaymentAccount).
  2. Calls the provider (a bank integration, or a manual-export job that writes a SEPA file).
  3. Creates a PayoutBatch linking the settlement to the transfer.
  4. Updates status="paid" and sets payout_reference and paid_at.

If the bank call fails:

await svc.fail(settlement_id, failure_reason="Insufficient funds on platform account")
# status: approved -> failed

Staff can fix the underlying issue and re-execute, or cancel and re-create with adjusted line items.

SettlementOrder — the allocation recipe

A settlement's big sibling is payment allocation: when a debtor pays against an outstanding claim, how is that payment distributed across the claim's cost types (collection cost, fees, interest, capital)? SettlementOrder defines the tenant's preferred order.

SettlementOrder(
    tenant_id=tenant.id,
    entries=[
        SettlementOrderEntry(priority=1, cost_type="collection_cost", label="Collection cost"),
        SettlementOrderEntry(priority=2, cost_type="fee",             label="Fee"),
        SettlementOrderEntry(priority=3, cost_type="interest",        label="Interest"),
        SettlementOrderEntry(priority=4, cost_type="capital",         label="Capital"),
    ],
)

The default order (collection cost first, capital last) is the most creditor-friendly — the creditor's added costs are recovered first, and the principal debt takes longest to erode. Tenants who prefer a debtor-friendly order (capital first, interest last) can reconfigure their own SettlementOrder and every allocation will respect the new priorities.

Endpoints:

GET /settlements/tenants/{tenant_id}/settlement-order   # fetch; auto-creates default if missing
PUT /settlements/tenants/{tenant_id}/settlement-order   # replace

AllocationService

craft_easy.core.payments.allocation_service.AllocationService consumes SettlementOrder to spread a payment across claims.

from craft_easy.core.payments.allocation_service import AllocationService

alloc = AllocationService()

result = await alloc.allocate_payment(
    tenant_id=tenant.id,
    payment_amount=Decimal("1500"),
    claims=[claim1, claim2, claim3],
    settlement_order_id=order.id,
)

The algorithm:

  1. Order claims by due date — oldest due first. Payments are applied to the oldest debt first, which matches both accounting convention and most legal frameworks.
  2. For each claim, walk the settlement order — apply the payment against collection_cost remaining, then fee, then interest, then capital. Exhaust one cost type before moving to the next.
  3. Move to the next claim when the current claim is fully paid.
  4. Stop when the payment is exhausted. Any remaining amount is returned as "unallocated" — usually a tenant-level credit or, in rare cases, a refund to the debtor.

AllocationResult

AllocationResult(
    tenant_id=tenant.id,
    payment_amount=Decimal("1500"),
    currency="SEK",
    allocated_total=Decimal("1500"),
    unallocated=Decimal("0"),
    claim_allocations=[
        ClaimAllocation(
            claim_id=claim1.id,
            claim_reference="CLM-001",
            due_date=date(2026, 2, 15),
            total_allocated=Decimal("1000"),
            cost_type_allocations=[
                CostTypeAllocation(cost_type="collection_cost", allocated_amount=Decimal("100"), remaining_before=Decimal("100"), remaining_after=Decimal("0")),
                CostTypeAllocation(cost_type="fee",             allocated_amount=Decimal("60"),  remaining_before=Decimal("60"),  remaining_after=Decimal("0")),
                CostTypeAllocation(cost_type="interest",        allocated_amount=Decimal("40"),  remaining_before=Decimal("40"),  remaining_after=Decimal("0")),
                CostTypeAllocation(cost_type="capital",         allocated_amount=Decimal("800"), remaining_before=Decimal("800"), remaining_after=Decimal("0")),
            ],
            fully_paid=True,
        ),
        ClaimAllocation(
            claim_id=claim2.id,
            ...,
            total_allocated=Decimal("500"),
            fully_paid=False,
        ),
    ],
    payment_id=payment.id,
    settlement_order_id=order.id,
)

The result is persisted as a document — it is the audit trail for the allocation. If a debtor ever asks "where did my 1500 SEK go?", the answer is this document. Every cost-type hit, every claim touched, every krona accounted for.

Routes

POST /settlements/allocate                         # run an allocation
GET  /settlements/allocations                      # list allocation results
GET  /settlements/allocations/{allocation_id}      # fetch one

Payout batches

Once a settlement is paid, a PayoutBatch is created with the underlying payment IDs, bank reference, and fee/net breakdown. See Payments for the PayoutBatch fields — settlements produce payouts but the payout object is defined in the payments module.

Running the settlement job

# Example settlement runner pseudocode (run from a cron job)
async def run_monthly_settlements(period_start, period_end):
    for tenant in await Tenant.find_all():
        # 1. Compute splits for the period
        splits = await revenue_split_svc.calculate_for_period(
            tenant.id, period_start, period_end
        )
        if not splits["payments"]:
            continue

        # 2. Build line items from splits
        line_items = [
            SettlementLineItem(
                reference_type="payment",
                reference_id=p["payment_id"],
                description=f"Payment {p['payment_id']}",
                gross_amount=p["gross"],
                platform_fee=p["platform"],
                net_amount=p["owner"],
            )
            for p in splits["payments"]
        ]

        # 3. Create settlement
        await settlement_svc.create_settlement(
            tenant_id=tenant.id,
            period_start=period_start,
            period_end=period_end,
            line_items=line_items,
            currency=splits["currency"],
            auto_approve=True,
        )

async def run_payout_executor():
    # Picks up approved settlements and executes them
    async for settlement in Settlement.find({"status": "approved"}):
        try:
            await settlement_svc.execute(settlement.id)
        except BankError as e:
            await settlement_svc.fail(settlement.id, failure_reason=str(e))

Split the runner from the executor — computing settlements is CPU work that benefits from parallelism, while executing payouts is I/O work that needs careful rate limiting to avoid overwhelming the bank integration.

Read Revenue Splits for the rules the settlement runner reads, Payments for the payout counterpart, and Bookkeeping for how settlements and payouts land in the double-entry books.