Skip to content

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, collection
  • collection_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_cost
  • remaining = 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_rate is the central bank reference rate at the time the claim was created (snapshotted on the claim itself so the rate is stable).
  • interest_margin is the tenant's margin over the reference rate, also frozen at claim creation.
  • outstanding_capital is original_amount minus 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:

  1. Collection costs — external agency fees and court costs, paid off first
  2. Fees — reminder fees, late fees
  3. Interest — accrued interest
  4. 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_accrued similarly.
  • status is recomputed: paid if remaining == 0, partial if paid_amount > 0 && remaining > 0, else active.

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:

await svc.mark_collection_stage(claim.id, new_stage="collection")

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 lookup
  • transitions — list of StageTransition(from_stage, to_stage, transitioned_at, reason, actor)
  • reminders_sent — list of ReminderRecord(reminder_number, sent_at, channel, fee_amount)
  • collection_agency, collection_reference, collection_handover_date
  • enforcement_case_number, enforcement_filed_date
  • settled_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:

  1. Interest accrual — nightly; walks every claim in active, partial, collection, or enforcement status and calls accrue_interest. Skips paused claims (they accrue but the job can be configured to skip them too if the deployment requires).
  2. Escalation runner — nightly; walks every claim, compares overdue_since and reminder counts against CollectionConfig, and advances collection_stage where appropriate. Writes a StageTransition on 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.