Skip to content

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

pending ──▶ processing ──▶ completed ──▶ partially_refunded ──▶ refunded
   │                         │
   │                         ▼
   ▼                       failed
cancelled
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:

  1. Client calls POST /paymentsPayment(status="pending", provider="stripe").
  2. Server looks up the Stripe provider from payment_provider_registry and calls provider.create_payment(intent).
  3. Provider returns a PaymentIntent with redirect_url or client_secret.
  4. Server stores provider_payment_id on the Payment via PUT /payments/{id}.
  5. Client finishes the payment at Stripe.
  6. Stripe calls your webhook endpoint. Your handler verifies the signature, extracts payment_intent.succeeded, and calls svc.complete_payment(...).
  7. Payment.status flips to completed, 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_id returned by the provider.
  • On complete: the provider_reference and 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.