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— 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_referenceset.failed— bank transfer failed,failure_reasonset. 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:
- Validates that
line_itemsis non-empty and that the sum ofnet_amountvalues matches the computednet_payout. - Checks the
net_payoutagainst the threshold. If under, setsstatus="approved"andauto_approved=True. Otherwisestatus="pending_approval". - 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¶
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¶
execute is what actually pays the tenant. In practice it:
- Fetches the tenant's payout account (from
PaymentAccount). - Calls the provider (a bank integration, or a manual-export job that writes a SEPA file).
- Creates a
PayoutBatchlinking the settlement to the transfer. - Updates
status="paid"and setspayout_referenceandpaid_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:
- 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.
- For each claim, walk the settlement order — apply the payment against
collection_costremaining, thenfee, theninterest, thencapital. Exhaust one cost type before moving to the next. - Move to the next claim when the current claim is fully paid.
- 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.