Skip to content

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:

from craft_easy.core.payments.registry import payment_provider_registry

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 in intent.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 as pending forever is worse than a failed.

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.