Payments¶
A Payment is the framework's record of a single money transfer between a customer and a tenant. It carries the amount, the currency, the provider that captured it, the resource it paid for, and — critically — a well-defined lifecycle that survives asynchronous provider callbacks, partial refunds, and reconciliation fixes.
Payments are deliberately dumb objects. They do not talk to Stripe or Klarna themselves — that is the job of the Payment Providers layer. A Payment is the local record; the provider is the external system whose state you are mirroring.
The Payment model¶
craft_easy.models.payment.Payment:
| Field | Purpose |
|---|---|
resource_type, resource_id |
What this payment is for — an order, a subscription, an invoice |
user_id, user_name |
The end-user who paid |
amount, currency |
Decimal amount and ISO 4217 code |
provider |
Name of the provider that handled it (stripe, klarna, swish, or a custom provider) |
provider_reference |
The provider's own payment ID, set once the provider confirms capture |
status |
Lifecycle state (see below) |
refunded_amount |
Total refunded so far — supports partial refunds |
refund_reason |
Human-readable note from the refund caller |
paid_at, refunded_at |
Timestamps |
receipt_url |
Link to the provider-hosted receipt, if any |
Indexes live on (resource_type, resource_id), user_id, status, provider, and paid_at, matching the most common read patterns (list all payments for an invoice, list payments by user, list recent completions).
Lifecycle states¶
| State | Meaning |
|---|---|
pending |
Local record created. No provider call yet, or the provider is waiting for customer action (e.g. BankID prompt) |
processing |
Provider has accepted the intent but not confirmed capture |
completed |
Provider confirmed capture. paid_at is set. The resource should be marked paid |
failed |
Provider declined the payment. Terminal |
cancelled |
Caller or customer cancelled before capture. Terminal |
refunded |
refunded_amount == amount. Terminal |
partially_refunded |
0 < refunded_amount < amount |
The states are a strict superset of what providers report — Craft Easy normalizes every provider's vocabulary into this one. See Payment Providers for each provider's status map.
PaymentService¶
craft_easy.core.payments.service.PaymentService owns the lifecycle. Every state transition goes through it; application code should never mutate a Payment directly.
from craft_easy.core.payments.service import PaymentService
svc = PaymentService()
payment = await svc.create_payment(
resource_type="order",
resource_id=order.id,
user_id=user.id,
user_name=user.name,
amount=Decimal("499.00"),
currency="SEK",
provider="stripe",
)
# payment.status == "pending"
At this point nothing has been charged. create_payment only records the intent locally. The caller is responsible for asking the provider to actually move money — usually by fetching the provider from the registry, calling provider.create_payment(intent), and storing the returned provider_payment_id.
Once the provider confirms capture (either inline for synchronous providers or via webhook for asynchronous ones), mark the payment complete:
payment = await svc.complete_payment(
payment_id=payment.id,
provider_reference="pi_3NxQ...", # Stripe PaymentIntent ID
provider_status="succeeded",
receipt_url="https://pay.stripe.com/receipts/...",
)
# payment.status == "completed"
# payment.paid_at == now
complete_payment is idempotent — calling it twice with the same provider_reference is a no-op, which matters because webhooks retry. It also posts ledger entries (bank debit, revenue credit) via the bookkeeping service if that subsystem is enabled.
Refunds¶
Full and partial refunds share the same method:
# Partial refund
await svc.refund_payment(
payment_id=payment.id,
amount=Decimal("100.00"),
reason="Service unavailable on 2026-04-03",
)
# payment.status == "partially_refunded"
# payment.refunded_amount == Decimal("100.00")
# Full refund (remaining balance)
await svc.refund_payment(
payment_id=payment.id,
amount=Decimal("399.00"),
)
# payment.status == "refunded"
The service calls the provider's refund_payment() to actually move the money back, updates refunded_amount, and posts the reversing ledger entries. If the refund sum ever exceeds the original amount the call fails with 400 — refunds are capped at the captured amount.
Retrieval¶
Two convenience reads:
# Every payment tied to a given resource (e.g. all attempts on one order)
payments = await svc.get_payments_for_resource("order", order.id)
For more complex queries (by user, by status, by date range) use the CRUD routes directly — Payment is a fully indexed resource and benefits from the standard filtering query params.
Routes¶
POST /payments # create a pending payment
GET /payments # list (filter by resource, user, status, provider)
GET /payments/{payment_id} # fetch one
PUT /payments/{payment_id} # update (provider_reference, receipt_url, metadata)
POST /payments/{payment_id}/complete # mark completed (idempotent)
POST /payments/{payment_id}/refund # full or partial refund
The complete and refund endpoints exist to make the transitions explicit — they do more than a PATCH (they call the provider, post ledger entries, emit events), so they get their own verbs.
Webhooks and completion¶
Most real-world payments complete asynchronously: the customer leaves the tenant's site to authenticate with their bank, the bank calls the provider back, and the provider calls your webhook. A typical end-to-end flow:
- Client calls
POST /payments→Payment(status="pending", provider="stripe"). - Server looks up the Stripe provider from
payment_provider_registryand callsprovider.create_payment(intent). - Provider returns a
PaymentIntentwithredirect_urlorclient_secret. - Server stores
provider_payment_idon thePaymentviaPUT /payments/{id}. - Client finishes the payment at Stripe.
- Stripe calls your webhook endpoint. Your handler verifies the signature, extracts
payment_intent.succeeded, and callssvc.complete_payment(...). Payment.statusflips tocompleted, the ledger is updated, downstream systems (fulfillment, subscription activation) react to the domain event.
PaymentProvider.handle_webhook(payload, headers) handles signature verification and payload parsing per provider — read Payment Providers for how to wire that up.
Payouts: money going the other direction¶
A PayoutBatch is a batched payout from the system owner to a tenant (or to a partner). Unlike a Payment, which represents money coming in, a PayoutBatch represents money going out — typically at the end of a settlement period.
Fields:
| Field | Purpose |
|---|---|
recipient_type |
tenant or partner |
recipient_id |
The tenant/partner being paid |
gross_amount, fee_amount, net_amount |
Pre-fee, fees withheld, post-fee |
currency |
ISO 4217 |
period_start, period_end |
Date range covered |
status |
draft, approved, processing, completed, failed |
payment_count, payment_ids |
The payments included in this payout |
bank_reference |
Bank transfer reference once the payout has been executed |
paid_at |
Execution timestamp |
PayoutBatches are normally created by Settlements rather than by application code. Two service methods govern the transitions:
await svc.approve_payout(payout_id) # draft -> approved
await svc.execute_payout(payout_id, bank_reference=...) # approved -> completed
approve_payout enforces the auto_approve_threshold from SettlementService: batches under the threshold can be auto-approved by the settlement runner, batches above the threshold require a human approver.
What to log in your application¶
For an auditable trail, log at least:
- On create: the resource id, amount, currency, and the provider chosen (
Payment(resource_id, amount, currency, provider, pending)). - On provider call: the
provider_payment_idreturned by the provider. - On complete: the
provider_referenceand the ledger transaction id posted by the bookkeeping service. - On refund: the refund amount, reason, and the provider refund id.
Every Payment write is already recorded in the audit log (the audit_log collection) with the actor, the before/after payload, and the request id. That covers the baseline compliance requirement; application logging is for operational debugging, not for replay.
Read Payment Providers next for how to register Stripe, Klarna, and Swish, plus how to implement a provider of your own.