Claims & Collections¶
A Claim is an unpaid receivable. When an invoice is overdue, a subscription exhausts its retries, or a manual debt is recorded, Craft Easy creates a claim and starts tracking it: accruing interest day by day, allocating incoming payments across cost types in a configurable order, escalating through collection stages, and capturing disputes. The subsystem is designed to produce a complete paper trail suitable for handover to an external collection agency and, eventually, a court enforcement process.
This page is long because the domain is. Read Payments first if you have not — it is the upstream of every claim.
Lifecycle at a glance¶
invoice overdue partial payment full payment
──────────────▶ active ──────────────────▶ partial ──────────────▶ paid
│ │
▼ no payments ▼ no further payments
overdue ──▶ reminder ──▶ collection ──▶ enforcement
│
▼
written_off
Claim state is encoded in two fields:
status— the financial state:active,partial,paid,written_off,collectioncollection_stage— how far along the escalation ladder:normal,overdue,reminder,collection,enforcement
Both change over time. A claim can be in partial status at the reminder stage if the debtor has paid some of it but the rest has not been recovered. They are orthogonal.
The Claim model¶
craft_easy.models.claim.Claim:
| Field | Purpose |
|---|---|
tenant_id |
Which tenant owns the receivable |
source_type, source_id |
What generated the claim — invoice, order, fee, manual |
debtor_name, debtor_email, debtor_phone, debtor_national_id |
Who owes |
original_amount |
The capital that was owed when the claim was created |
interest_accrued |
Sum of interest added over time via accrue_interest |
fees |
Sum of explicit fees (reminder fees, admin fees) added via add_fee |
collection_cost |
External collection agency cost once the claim is handed over |
paid_amount |
Sum of all payments registered |
currency |
ISO 4217 |
status |
Financial state (see above) |
collection_stage |
Escalation ladder (see above) |
due_date |
When the original obligation was due |
overdue_since |
Set when the claim first ages past due_date |
reference_rate, interest_margin |
Used to compute daily interest |
last_interest_date |
Last day interest was accrued, to avoid double-counting |
payments |
Embedded list of ClaimPayment records |
fee_cost_type_ids |
Links to the cost-type catalog for accounting breakdowns |
dispute_text, dispute_date, dispute_status, dispute_decided_at, dispute_decided_by, dispute_decision_reason |
Dispute workflow |
escalation_paused, escalation_paused_reason |
Manual hold on escalation |
payment_plan_id |
Link to an active payment plan, if any |
Two computed properties simplify reporting:
total_due = original_amount + interest_accrued + fees + collection_costremaining = total_due - paid_amount
Interest calculation¶
Claims accrue simple daily interest on the outstanding capital. The formula is straightforward but matters for regulatory compliance (Swedish interest law requires daily accrual with reference-rate tracking):
daily_rate = (reference_rate + interest_margin) / 100 / 365
accrued = outstanding_capital * daily_rate * days
Where:
reference_rateis the central bank reference rate at the time the claim was created (snapshotted on the claim itself so the rate is stable).interest_marginis the tenant's margin over the reference rate, also frozen at claim creation.outstanding_capitalisoriginal_amountminus the capital portion of any payments received (interest and fees have already been paid off in the settlement order; the remainder is capital).
from craft_easy.core.claims.service import ClaimService
svc = ClaimService()
# Add all interest due up to today
claim = await svc.accrue_interest(claim.id)
# Or up to a specific date (useful for backdated runs)
claim = await svc.accrue_interest(claim.id, up_to=date(2026, 4, 30))
accrue_interest is idempotent on last_interest_date — running it twice on the same day does not double-accrue. The usual wiring is a nightly batch job that calls accrue_interest on every active claim.
calculate_daily_interest(claim, up_to) exposes the formula directly for preview calculations without persisting.
Payment allocation¶
When the debtor pays — fully or partially — register_payment allocates the payment across the four cost types in the tenant's settlement order. The default order is:
- Collection costs — external agency fees and court costs, paid off first
- Fees — reminder fees, late fees
- Interest — accrued interest
- Capital — the original debt
claim = await svc.register_payment(
claim.id,
amount=Decimal("500"),
reference="bg_ref_abc123",
payment_id=payment.id,
)
The payment record is appended to claim.payments with the allocation breakdown — allocated_collection_cost, allocated_fees, allocated_interest, allocated_capital — so you can reconstruct exactly where every krona went.
After allocation the service updates:
paid_amount += payment amount- Cost-type counters (
fees,collection_cost) are reduced by the allocated portion;interest_accruedsimilarly. statusis recomputed:paidifremaining == 0,partialifpaid_amount > 0 && remaining > 0, elseactive.
If the paid amount exceeds total_due, the excess is returned to the allocation service as "unallocated" — see Settlements & Payouts for what happens to overpayments.
The settlement order is tenant-configurable via the SettlementOrder model. A tenant that wants to allocate to capital first (and charge interest later on the remaining interest/fees) can reorder their own table; see the settlements page for how.
Escalation stages¶
Escalation is driven by aging. A nightly job walks active claims and, based on time past due_date, sets collection_stage:
| Stage | When it applies | Actions |
|---|---|---|
normal |
Not yet overdue | Nothing special — claim exists but interest does not start yet |
overdue |
Past due_date, grace period exceeded |
overdue_since is set; interest accrual starts |
reminder |
Days past due > reminder_interval_days |
First reminder sent to debtor, reminder fee added |
collection |
After max_reminders reminders, and days past due > days_to_collection |
Claim handed over to collection agency, CollectionFlow.collection_handover_date set |
enforcement |
Collection failed, legal enforcement started | enforcement_case_number captured |
settled / written_off |
Terminal states | No further action |
Thresholds are configured per tenant in CollectionConfig:
CollectionConfig(
tenant_id=tenant.id,
grace_period_days=5, # days after due_date before overdue stage
max_reminders=3,
reminder_interval_days=14, # days between reminders
reminder_fee=Decimal("60"), # added to claim when reminder sent
days_to_collection=14, # days from final reminder to handover
reference_rate=Decimal("4.5"), # default rate used for new claims
interest_margin=Decimal("8"), # default margin used for new claims
default_collection_agency="Intrum Sverige AB",
)
Manually push a claim through stages via mark_collection_stage:
This is for edge cases — the automated runner handles 99% of transitions.
CollectionFlow — the audit trail¶
Every stage transition is recorded on a separate document: CollectionFlow. One flow exists per claim, tracking:
current_stage— mirrors the claim's stage, for fast lookuptransitions— list ofStageTransition(from_stage, to_stage, transitioned_at, reason, actor)reminders_sent— list ofReminderRecord(reminder_number, sent_at, channel, fee_amount)collection_agency,collection_reference,collection_handover_dateenforcement_case_number,enforcement_filed_datesettled_at,written_off_at,written_off_reason
Collection flows are append-only in spirit — the service never deletes a transition or a reminder, so the history is preserved for audits and possible court proceedings.
Disputes¶
A debtor can dispute a claim (often in writing) before they pay. The framework captures the dispute, freezes escalation, and waits for a decision:
# Debtor disputes
claim = await svc.register_dispute(
claim.id,
dispute_text="I never received the goods.",
)
# claim.dispute_status == "pending"
# escalation is paused automatically
# Staff investigates and decides
claim = await svc.resolve_dispute(
claim.id,
decision="rejected", # or "accepted"
decided_by=user.id,
reason="Delivery confirmed via tracking 123456789",
)
# If accepted: claim status may flip to "written_off"
# If rejected: escalation resumes
Dispute metadata is captured on the Claim itself (dispute_text, dispute_date, dispute_status, dispute_decided_at, dispute_decided_by, dispute_decision_reason). Every resolution is also added as a StageTransition on the collection flow with reason="dispute_resolved" so the timeline is complete.
Escalation pause¶
Sometimes staff need to pause escalation for reasons other than a dispute — a payment plan under negotiation, a hardship case, awaiting a court date. pause_escalation and resume_escalation handle this:
await svc.pause_escalation(claim.id, reason="Payment plan negotiation")
# claim.escalation_paused = True, reason captured
# Later
await svc.resume_escalation(claim.id)
While paused, the nightly escalation runner skips the claim. Interest accrual continues (pausing is not forgiveness, just a hold on escalation). This is critical: forgiving interest requires add_fee(amount=-X) or a write-off, not a pause.
Fees¶
Beyond automatic reminder fees, staff can add one-off fees to a claim:
await svc.add_fee(
claim.id,
amount=Decimal("150"),
fee_type="admin_fee", # free-form, logged to audit
)
fee_cost_type_ids can be populated to link fees to the cost-type catalog for accounting breakdown. Fees immediately increase claim.fees and are recovered before interest under the default settlement order.
Routes¶
POST /claims # create (CreateClaimRequest)
GET /claims # list (filter: tenant_id, status, collection_stage, due_date)
GET /claims/{claim_id} # fetch
PUT /claims/{claim_id} # update
POST /claims/{claim_id}/register-payment # register a payment on the claim
POST /claims/{claim_id}/add-fee # add a fee
POST /claims/{claim_id}/accrue-interest # manual accrual (idempotent on date)
POST /claims/{claim_id}/register-dispute # file a dispute
POST /claims/{claim_id}/resolve-dispute # accept or reject the dispute
POST /claims/{claim_id}/pause-escalation # hold
POST /claims/{claim_id}/resume-escalation # resume
POST /claims/{claim_id}/payment-plan # create a payment plan
GET /claims/{claim_id}/payment-plan # fetch the active plan
PUT /claims/{claim_id}/payment-plan # update installments
Payment plan routes are shallow aliases for the Payment Plans service — see that page for the installment model.
Running the batch jobs¶
Two scheduled jobs drive the lifecycle end-to-end:
- Interest accrual — nightly; walks every claim in
active,partial,collection, orenforcementstatus and callsaccrue_interest. Skips paused claims (they accrue but the job can be configured to skip them too if the deployment requires). - Escalation runner — nightly; walks every claim, compares
overdue_sinceand reminder counts againstCollectionConfig, and advancescollection_stagewhere appropriate. Writes aStageTransitionon the flow and triggers the reminder sender if needed.
Both jobs are idempotent. A missed night is caught up automatically: interest is accrued from last_interest_date to today in one call, and the escalation runner just picks up claims that are now overdue since its last run.
Read Payment Plans next for how to offer installment agreements on claims, and Settlements & Payouts for how claim recoveries flow back to the tenant.