Skip to content

Payment Plans

A payment plan is an installment agreement attached to a claim. Instead of recovering the entire debt in one shot, staff and debtor agree on a schedule — four installments over four months, say — and the framework tracks each installment as it comes due. Plans are specifically for claims; subscriptions and invoices use the billing system directly.

Payment plans are a debt-recovery tool. They exist because many debtors will pay in stages when they cannot pay all at once, and because escalating a claim to a collection agency while a plan is in progress would be both legally dubious and bad for the working relationship.

The model

craft_easy.models.payment_plan.PaymentPlan:

Field Purpose
claim_id The claim this plan recovers
tenant_id Tenant scope
status active, completed, defaulted, cancelled
installments Ordered list of Installment records
total_amount Sum of installment amounts — should equal claim.remaining at plan creation
created_by The user who created the plan, or 'system'

Installment

Installment(
    due_date=date(2026, 5, 1),
    amount=Decimal("250"),
    paid=False,
    paid_at=None,
    payment_id=None,  # link to the Payment that settled this installment
)

Installments are plain dataclasses embedded in the plan. No separate collection, no foreign keys — the plan owns its installments, and the plan is owned by the claim.

Status transitions

       create           all paid              any overdue
draft ──────▶ active ───────────▶ completed ◀────── (rare reopen for reconciliation)
                 ▼ default
              defaulted
                 ▼ cancel
             cancelled
  • active — normal state while installments are being paid on schedule.
  • completed — every installment has been paid in full. Terminal.
  • defaulted — at least one installment is overdue. check_default flips the status; staff decide what to do next (renegotiate, cancel, hand over to collection).
  • cancelled — staff cancelled the plan, usually to escalate the underlying claim or because the debtor formally refused.

PaymentPlanService

craft_easy.core.payment_plans.service.PaymentPlanService:

from craft_easy.core.payment_plans.service import PaymentPlanService

svc = PaymentPlanService()

Creating a plan

plan = await svc.create(
    claim_id=claim.id,
    installments=[
        Installment(due_date=date(2026, 5, 1),  amount=Decimal("250")),
        Installment(due_date=date(2026, 6, 1),  amount=Decimal("250")),
        Installment(due_date=date(2026, 7, 1),  amount=Decimal("250")),
        Installment(due_date=date(2026, 8, 1),  amount=Decimal("250")),
    ],
    created_by=user.id,
)

create enforces three invariants:

  1. The claim is not already paid or written_off. Creating a plan for a settled debt is a mistake and the service rejects it with 400.
  2. The claim does not already have an active plan. A claim can have many historical plans (after a default and renegotiation) but only one active at a time.
  3. sum(installment.amount) == claim.remaining. If the plan undershoots or overshoots the debt, the service rejects it with 422. This is the single most important invariant of the plan — a plan that does not add up to the debt means the debtor pays and still owes money, which is exactly the confusion the plan is supposed to prevent.

Fetching the plan

plan = await svc.get_for_claim(claim.id)
# Returns the active plan or None

Historical plans (completed, defaulted, cancelled) are accessible via the standard CRUD list route by filtering on claim_id and status.

Updating installments

Before a plan is activated, or while active with future installments still unpaid, staff can revise the schedule:

await svc.update(
    claim_id=claim.id,
    installments=[
        Installment(due_date=date(2026, 5, 1),  amount=Decimal("500"), paid=True, paid_at=datetime(2026, 5, 2)),
        Installment(due_date=date(2026, 6, 15), amount=Decimal("500")),  # renegotiated later date
    ],
)

Already-paid installments are left alone — update only touches unpaid future installments. If the caller tries to modify a paid installment, the service rejects the call. This protects the plan from accidental rewriting of history.

Marking an installment paid

When a payment lands on the claim (usually via ClaimService.register_payment), the service looks for a matching installment and, if found, flips it to paid:

await svc.mark_installment_paid(
    claim_id=claim.id,
    installment_index=0,
    payment_id=payment.id,
)

Matching is by index rather than by amount — the caller decides which installment is being paid, rather than letting the service guess. That matters when a debtor pays twice in one month (one for the overdue installment, one for the current one) and you want to credit the right slot.

After marking, the service recomputes plan.status:

  • If every installment has paid=True, status=completed and the linked claim is marked paid.
  • Otherwise the plan stays active.

Cancelling a plan

await svc.cancel(claim.id)
# plan.status == "cancelled"

Cancellation is one-way. Staff typically cancel a plan right before escalating the underlying claim to a collection agency — the claim needs to be free of an active plan before collection can pick it up, because most agencies will not take a claim under plan.

Checking for default

plan = await svc.check_default(claim.id)
# plan.status == "defaulted" if any installment is overdue, else unchanged

check_default walks the installments, finds any unpaid ones with due_date < today, and flips the plan to defaulted. Run this as part of the nightly escalation job — the escalation runner can then treat a defaulted plan the same way it treats an overdue claim (next reminder, then collection handover, etc.).

Default is not automatic on the day after a missed installment; it takes the nightly job to pick it up. For tighter control, call check_default from a webhook that fires on every payment event.

Routes

Plans are exposed as shallow routes under the owning claim rather than as a standalone collection:

POST /claims/{claim_id}/payment-plan             # create
GET  /claims/{claim_id}/payment-plan             # fetch active plan
PUT  /claims/{claim_id}/payment-plan             # update installments

Cancellation is typically done through a DELETE on the same route, which calls svc.cancel; check your deployment's route table for the exact verb.

Example — full lifecycle

# Starting point: a claim for 1000 SEK, debtor cannot pay in one shot
claim = await get_claim(claim_id)
# claim.remaining == Decimal("1000")

# 1. Staff agrees a 4-month plan
plan = await svc.create(
    claim_id=claim.id,
    installments=[
        Installment(due_date=date(2026, 5, 1), amount=Decimal("250")),
        Installment(due_date=date(2026, 6, 1), amount=Decimal("250")),
        Installment(due_date=date(2026, 7, 1), amount=Decimal("250")),
        Installment(due_date=date(2026, 8, 1), amount=Decimal("250")),
    ],
    created_by=staff.id,
)

# 2. May 3: debtor pays 250 SEK
payment = await create_payment_for_claim(claim.id, amount=Decimal("250"))
await svc.mark_installment_paid(claim.id, installment_index=0, payment_id=payment.id)

# 3. June 1 comes and goes with no payment
# Nightly escalation job calls check_default:
await svc.check_default(claim.id)
# plan.status == "defaulted"

# 4. Staff renegotiates — debtor promises to catch up by end of July
await svc.update(claim.id, installments=[
    Installment(due_date=date(2026, 5, 1), amount=Decimal("250"), paid=True, paid_at=...),
    Installment(due_date=date(2026, 7, 31), amount=Decimal("750")),  # consolidated
])
# plan.status back to active

# 5. July 30: debtor pays the remaining 750 SEK
payment = await create_payment_for_claim(claim.id, amount=Decimal("750"))
await svc.mark_installment_paid(claim.id, installment_index=1, payment_id=payment.id)
# plan.status == "completed"
# claim.status == "paid"

Plans and interest

Interest continues to accrue on the underlying claim while the plan is active. A plan does not freeze interest — it just schedules capital repayment. If the tenant wants to freeze interest as an incentive to pay the plan, they add a negative add_fee entry to the claim for the accrued-but-waived interest.

This is deliberate: freezing interest automatically would be a policy decision, and policy decisions about money should be explicit, not hidden in a service method default.

Plans and escalation

Creating a plan does not automatically pause claim escalation. Staff usually call ClaimService.pause_escalation explicitly when a plan is created, and resume_escalation if the plan defaults. See Claims & Collections for the escalation runner.

Some deployments auto-pause on plan creation via a hook:

@hook(model=PaymentPlan, event="after_create")
async def pause_on_plan_create(ctx):
    plan = ctx.data
    await claim_service.pause_escalation(
        plan.claim_id,
        reason=f"Payment plan #{plan.id} active",
    )

Wire this in if your policy is "a plan always pauses escalation". Otherwise keep it explicit.

Read Claims & Collections for the upstream model and Settlements & Payouts for how recovered plan payments flow back to the tenant.