Payment Providers¶
A payment provider is the component that talks to an external payment system on Craft Easy's behalf. The framework ships three built-in providers — Stripe, Klarna, and Swish — plus a provider contract you can implement to plug in anything else (Adyen, PayPal, an internal bank gateway, a test double). Providers are registered at startup, configured per tenant, and looked up via a singleton registry whenever a payment is created.
This page covers the provider contract, the three built-ins, tenant-specific configuration, and how to register a provider of your own.
The contract: PaymentProvider¶
craft_easy.core.payments.provider.PaymentProvider is an abstract base class with five methods:
from abc import ABC, abstractmethod
from craft_easy.core.payments.provider import (
PaymentIntent, PaymentResult, RefundResult, PaymentStatus,
)
class PaymentProvider(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
async def create_payment(self, intent: PaymentIntent) -> PaymentIntent: ...
@abstractmethod
async def capture_payment(self, provider_payment_id: str) -> PaymentResult: ...
@abstractmethod
async def refund_payment(
self, provider_payment_id: str, amount: Decimal, reason: str | None = None
) -> RefundResult: ...
@abstractmethod
async def get_payment_status(self, provider_payment_id: str) -> PaymentStatus: ...
@abstractmethod
async def handle_webhook(self, payload: bytes, headers: dict) -> dict: ...
Three provider-agnostic dataclasses travel between the service layer and the provider:
PaymentIntent¶
The request to start a payment. Also the response when the provider returns client-side instructions (a redirect URL, a client secret, etc.).
PaymentIntent(
provider="stripe",
provider_payment_id=None, # filled in by the provider on return
status="pending",
amount=Decimal("499.00"),
currency="SEK",
client_secret=None, # filled in by providers that use one (Stripe)
redirect_url=None, # filled in by providers that redirect (Klarna)
metadata={"order_id": "..."},
)
PaymentResult¶
What you get back after capture (or from a webhook). This is what complete_payment on the service layer expects.
PaymentResult(
provider="stripe",
provider_payment_id="pi_3NxQ...",
status="completed",
amount=Decimal("499.00"),
currency="SEK",
provider_reference="pi_3NxQ...",
paid_at=datetime(2026, 4, 5, 14, 32, 11),
receipt_url="https://pay.stripe.com/receipts/...",
metadata={},
)
RefundResult¶
Mirrors PaymentResult but for refunds. Includes original_payment_id and reason.
PaymentStatus (enum)¶
pending, processing, completed, failed, refunded, partially_refunded, cancelled. Every provider maps its own vocabulary into this enum — see the per-provider sections below.
The registry¶
PaymentProviderRegistry holds the instantiated providers and their per-tenant configuration. There is one module-level singleton:
Registering providers at startup¶
from craft_easy.core.payments.providers.stripe import StripeProvider
from craft_easy.core.payments.providers.klarna import KlarnaProvider
from craft_easy.core.payments.providers.swish import SwishProvider
payment_provider_registry.register(
StripeProvider(
api_key=settings.STRIPE_API_KEY,
webhook_secret=settings.STRIPE_WEBHOOK_SECRET,
sandbox=settings.ENVIRONMENT != "production",
)
)
payment_provider_registry.register(
KlarnaProvider(
username=settings.KLARNA_USERNAME,
password=settings.KLARNA_PASSWORD,
region="eu",
sandbox=settings.ENVIRONMENT != "production",
)
)
payment_provider_registry.register(
SwishProvider(
merchant_swish_number=settings.SWISH_MERCHANT_NUMBER,
cert_path=settings.SWISH_CERT_PATH,
cert_password=settings.SWISH_CERT_PASSWORD,
callback_url=settings.SWISH_CALLBACK_URL,
sandbox=settings.ENVIRONMENT != "production",
)
)
Register every provider your deployment will ever need at startup. Providers are singletons — they hold the API credentials and any connection pools — and registering the same name twice overwrites the previous one.
Tenant-specific configuration¶
Different tenants can use different providers. A boutique selling online uses Stripe; a Swedish tenant selling to consumers uses Swish; a classifieds platform prefers Klarna's buy-now-pay-later. The registry supports per-tenant config:
from craft_easy.core.payments.registry import TenantProviderConfig
payment_provider_registry.configure_tenant(
tenant_id=tenant.id,
configs=[
TenantProviderConfig(
provider_name="stripe",
enabled=True,
is_default=True,
config={"account_id": "acct_1...", "statement_descriptor": "ACME"},
),
TenantProviderConfig(
provider_name="klarna",
enabled=True,
is_default=False,
config={"merchant_id": "N1234..."},
),
],
)
Look up a tenant's providers at payment time:
providers = payment_provider_registry.get_tenant_providers(tenant_id)
default = payment_provider_registry.get_tenant_default(tenant_id)
stripe = payment_provider_registry.get_tenant_provider(tenant_id, "stripe")
If the client picks a provider explicitly, validate it against get_tenant_providers first — do not let a user pay through a provider that is not enabled for their tenant.
The built-in providers¶
Stripe¶
StripeProvider(
api_key="sk_live_...", # or sk_test_... for sandbox
webhook_secret="whsec_...",
sandbox=False,
)
Flow: create_payment calls POST /v1/payment_intents, returning the client_secret and redirect_url for 3D Secure / SCA. The client-side Stripe.js uses the client_secret to complete the payment. Your webhook receives payment_intent.succeeded, which maps to PaymentStatus.completed.
Status map: requires_payment_method → pending, requires_confirmation → pending, requires_action → pending, processing → processing, succeeded → completed, canceled → cancelled, requires_capture → processing.
Handles zero-decimal currencies (JPY, ISK) correctly — the amount sent to Stripe is multiplied by 10 ** currency_decimals from core.money.CURRENCIES so there is never a fractional-cent off-by-100 bug.
Webhook signature verification: handle_webhook computes an HMAC-SHA256 over the payload using webhook_secret and compares it to the Stripe-Signature header. Failures raise, which translates to a 400 from the webhook route.
Klarna¶
KlarnaProvider(
username="N1234_abcd", # merchant id
password="sharedSecret",
region="eu", # eu | na | oc
sandbox=False,
)
Flow: Klarna is a two-step provider — create_session returns a client token, the user authorizes at Klarna, then capture_payment finalizes. The provider abstracts both into a single create_payment call that returns the redirect URL, and the service layer calls capture_payment from the webhook handler when Klarna reports AUTHORIZED.
Status map: AUTHORIZED → processing, CAPTURED → completed, CANCELLED → cancelled, EXPIRED → failed, REFUNDED → refunded.
Klarna uses HTTP Basic Auth with the merchant username and password; the provider constructs the header automatically. Base URLs differ by region and sandbox flag:
| Region | Production | Playground |
|---|---|---|
| EU | https://api.klarna.com |
https://api.playground.klarna.com |
| NA | https://api-na.klarna.com |
https://api-na.playground.klarna.com |
| OC | https://api-oc.klarna.com |
https://api-oc.playground.klarna.com |
Swish¶
SwishProvider(
merchant_swish_number="1234567890", # 10 digits, enrolled with Swish
cert_path="/etc/swish/merchant.p12",
cert_password="certPassword",
callback_url="https://api.example.com/webhooks/swish",
sandbox=False,
)
Flow: Swish is mTLS-authenticated. create_payment posts a payment request to Swish with an instruction UUID (uppercase, no dashes). The customer completes the payment in the Swish mobile app. Swish calls callback_url with the result.
Status map: CREATED → pending, PAID → completed, DECLINED → failed, ERROR → failed, CANCELLED → cancelled.
The provider reads a P12 or PEM certificate from disk. The sandbox (Merchant Swish Simulator, MSS) uses a different base URL and a Swish-provided test certificate. See the Swish merchant portal for onboarding — there is no quick signup path.
Registering your own provider¶
Implement PaymentProvider and register it. Example — a minimal in-memory test provider:
from craft_easy.core.payments.provider import (
PaymentProvider, PaymentIntent, PaymentResult, PaymentStatus, RefundResult,
)
class FakeProvider(PaymentProvider):
@property
def name(self) -> str:
return "fake"
async def create_payment(self, intent: PaymentIntent) -> PaymentIntent:
return PaymentIntent(
provider="fake",
provider_payment_id=f"fake_{uuid4().hex}",
status=PaymentStatus.completed,
amount=intent.amount,
currency=intent.currency,
)
async def capture_payment(self, provider_payment_id: str) -> PaymentResult:
return PaymentResult(
provider="fake",
provider_payment_id=provider_payment_id,
status=PaymentStatus.completed,
amount=Decimal("0"),
currency="SEK",
provider_reference=provider_payment_id,
paid_at=datetime.utcnow(),
)
async def refund_payment(self, provider_payment_id, amount, reason=None):
return RefundResult(
provider="fake",
provider_refund_id=f"r_{uuid4().hex}",
original_payment_id=provider_payment_id,
amount=amount,
currency="SEK",
status="refunded",
reason=reason,
)
async def get_payment_status(self, provider_payment_id):
return PaymentStatus.completed
async def handle_webhook(self, payload, headers):
return {"status": "ok"}
payment_provider_registry.register(FakeProvider())
That is enough to swap in during integration tests. For a production integration, you would replace each method body with calls to the real provider's API, and handle_webhook would verify signatures.
What to implement carefully¶
name— unique across the registry. The name is what clients pass when they want a specific provider.create_payment— should be idempotent on the caller's idempotency key (pass it inintent.metadata) to survive retries.handle_webhook— always verify signatures. A missing or wrong signature must raise; do not fall back to "trust the payload".- Status mapping — map every state the provider can return, including the obscure ones (
expired,requires_action). An unmapped state that ends up aspendingforever is worse than afailed.
The provider contract is thin on purpose. Every provider-specific concept — webhooks, redirects, client secrets, certificates, regional base URLs — is hidden inside the provider implementation. The rest of the framework just sees PaymentIntent and PaymentResult.
Read Payments for how the service layer uses the provider, and Settlements & Payouts for the inverse flow that pays tenants via PayoutBatch.