Agreements¶
A TenantAgreement is the signed contract between the system owner (or a partner) and a tenant. It captures the terms of the commercial relationship in a structured, queryable document: which features the tenant pays for, how revenue is split, what service fees are charged, and in what order those fees are deducted during settlement.
Agreements are the source of truth for billing. Feature gating, fee calculation, and settlement all read from the tenant's currently active agreement. Changing the terms means issuing a new agreement version — the old one stays in history.
The model¶
craft_easy.models.agreement.TenantAgreement is a system-level document (not tenant-scoped) but carries an explicit tenant_id field so the correct tenant is easy to locate.
| Field | Type | Purpose |
|---|---|---|
tenant_id |
ObjectId | The tenant bound by this agreement |
status |
string | draft, active, terminated, ended |
version |
int | Monotonic version counter within a tenant |
replaces_agreement_id |
ObjectId | Link to the agreement this one replaces |
start_date |
date | When the terms take effect |
end_date |
date | null | When the terms expire (null = indefinite) |
termination_date |
date | null | When termination was requested |
notice_period_days |
int | Notice required to terminate, default 90 |
enabled_features |
list[string] | Features granted by this agreement |
revenue_split_rules |
list[RevenueSplitRule] | How payment revenue is split |
service_fees |
list[ServiceFee] | Fees the platform charges the tenant |
settlement_order |
list[SettlementOrderItem] | Deduction priority at settlement time |
notes |
string | Internal notes, not shown to the tenant |
Indexes exist on (tenant_id, status), (tenant_id, version desc), and status — all three support the main query: "what is the currently active agreement for this tenant?".
Embedded: ServiceFee¶
A service fee is a recurring charge the platform bills the tenant — a subscription fee, a transaction fee, a per-unit fee.
ServiceFee(
name="Platform fee",
fee_type="percentage", # "percentage" | "fixed" | "tiered"
percentage=Decimal("2.5"), # required for percentage and tiered
fixed_amount=None, # required for fixed
currency="SEK", # required for fixed
tiers=None, # required for tiered (list[FeeTier])
calculation_basis="gross_revenue", # gross_revenue | transaction_count | per_unit | per_transaction
vat_rate=Decimal("25"),
vat_included=False,
)
Tiered fees use FeeTier(min_value, max_value, percentage, fixed_amount) to describe volume breakpoints. The fee calculator matches the gross amount against each tier and applies the matching tier's rate.
Embedded: RevenueSplitRule¶
Revenue splits describe how the money from a customer payment is divided between the platform, the partner, and the tenant.
RevenueSplitRule(recipient_type="platform", percentage=Decimal("30")),
RevenueSplitRule(recipient_type="tenant", percentage=Decimal("70")),
A validator on the model asserts that the list sums to exactly 100%. Deeper splits (three-way deals with a partner) are supported — every rule adds one more slice, and all slices together must add up to 100.
See Revenue Splits for the calculation engine that reads these rules.
Embedded: SettlementOrderItem¶
When a tenant's payout is settled, the platform withholds its fees before paying out. The order of deductions is encoded here:
SettlementOrderItem(priority=1, item_type="platform_fee"),
SettlementOrderItem(priority=2, item_type="service_fee"),
SettlementOrderItem(priority=3, item_type="tax"),
SettlementOrderItem(priority=4, item_type="tenant_payout"),
Priority 1 is deducted first. tenant_payout is always the tail — it is whatever remains after every deduction in front of it. See Settlements & Payouts for how this list is consumed.
Lifecycle¶
┌────────┐ activate ┌─────────┐ terminate ┌──────────────┐ end ┌────────┐
│ draft │ ───────────────▶ │ active │ ─────────────▶ │ terminated │ ───────▶ │ ended │
└────────┘ └─────────┘ └──────────────┘ └────────┘
│ │
└── delete (draft only) ─────────────────────────────────┘
Only a draft agreement can be edited or deleted. Activating it freezes the terms. Terminating it sets termination_date and computes the effective end_date from notice_period_days. Ending it closes the window — at that point the agreement is purely historical.
Creating a new version¶
To change terms, you do not edit the active agreement. You create a new draft, point replaces_agreement_id at the current active agreement, activate the new one (which auto-terminates the old one), and let the old one end when its notice period expires. The monotonic version counter makes it trivial to audit the chain of amendments.
Routes¶
CRUD:
GET /agreements
GET /agreements/{agreement_id}
POST /agreements
PATCH /agreements/{agreement_id} # draft only
DELETE /agreements/{agreement_id} # draft only
Lifecycle:
POST /agreements/{agreement_id}/activate
POST /agreements/{agreement_id}/terminate
POST /agreements/{agreement_id}/end
Fee calculation:
The calculate-fees endpoint returns the fee breakdown without persisting anything — useful for client-side previews before committing a transaction:
{
"gross_amount": "10000",
"total_fees": "250",
"net_amount": "9750",
"fees": [
{
"name": "Platform Fee",
"fee_type": "percentage",
"amount": "250.00",
"vat_amount": "62.50",
"vat_rate": "25"
}
]
}
Feature resolution:
Returns the enabled_features list from the tenant's currently active agreement. The FastAPI tenant-feature guard (check_tenant_feature) consults the tenant's own enabled_features cache, which is synced from the active agreement whenever the agreement is activated or amended.
Full example — onboarding a new tenant¶
# 1. Create the tenant (status=draft on the tenant until the agreement is active)
tenant = await create_tenant(name="Acme Co", slug="acme-co")
# 2. Draft an agreement
agreement = await client.post("/agreements", json={
"tenant_id": str(tenant.id),
"start_date": "2026-05-01",
"end_date": None,
"notice_period_days": 90,
"enabled_features": ["sales", "invoicing", "reports"],
"revenue_split_rules": [
{"recipient_type": "platform", "percentage": 25},
{"recipient_type": "tenant", "percentage": 75}
],
"service_fees": [
{
"name": "Monthly platform fee",
"fee_type": "fixed",
"fixed_amount": 499,
"currency": "SEK",
"calculation_basis": "gross_revenue",
"vat_rate": 25
},
{
"name": "Transaction fee",
"fee_type": "percentage",
"percentage": 1.5,
"calculation_basis": "gross_revenue",
"vat_rate": 25
}
],
"settlement_order": [
{"priority": 1, "item_type": "platform_fee"},
{"priority": 2, "item_type": "service_fee"},
{"priority": 3, "item_type": "tax"},
{"priority": 4, "item_type": "tenant_payout"}
]
})
# 3. Activate once legal signs off
await client.post(f"/agreements/{agreement['id']}/activate")
# The tenant's enabled_features is now ["sales", "invoicing", "reports"].
# Settlements will deduct platform fee + service fee + tax before paying out.
Why agreements, not just settings on the tenant?¶
Two reasons:
- Auditability. A
Tenant.enabled_featuresfield would be overwritten whenever someone changes a plan. With agreements, every change creates a new versioned record linked to the previous one — the historical chain is intact. - Settlement accuracy. Settlements must use the fee schedule that applied when the revenue was earned, not the current fee schedule. Agreements have date ranges, which lets the settlement service pick the right one even weeks after the transaction.
Agreements are the piece that makes Craft Easy suitable for production SaaS billing — they turn commercial terms into a queryable, auditable, versioned record the rest of the platform can read from.