Skip to content

msflib-payments

Purpose

msflib-payments starts and verifies one-off payments through Stripe or Paystack, stores each payment, and runs your own fulfilment code once a payment is verified. You call process_payment from your business flow to create a checkout, expose the module's router so the client can confirm the payment, and subscribe to an event to grant whatever the customer paid for.

It does not handle webhooks, subscriptions, refunds or invoices. Verification is pull-based: the client calls the verify endpoint with the payment reference.

Install

[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-account = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/account", rev = "account-v0.2.2" }
msflib-payments = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/payments", rev = "payments-v0.2.1" }

The stripe SDK is a hard dependency and there are no extras. The payment table has a foreign key to account.id, but msflib-payments does not declare msflib-account itself, so add it to your own dependencies as above (tracked in #273, see Key concepts).

Wiring into a host app

Add PaymentsSettings to your settings class, then mount the router. router is exported from msflib.payments.router and takes the settings object directly.

from fastapi import FastAPI
from msflib.account.config import AccountSettings
from msflib.core.config import SettingsBase
from msflib.payments.config import PaymentsSettings
from msflib.payments.router import router as payments_router


class AppSettings(PaymentsSettings, AccountSettings, SettingsBase):
    PAYSTACK_SECRET_KEY: str = "sk_test_..."


settings = AppSettings()
app = FastAPI()
# get_session and get_current_account come from your host (for example
# get_session_factory(engine) from msflib.api.deps, and get_account_dependencies(...))
app.include_router(
    payments_router(
        get_session=get_session,
        get_current_account=get_current_account,
        settings=settings,
        prefix="/payments",
    )
)
Method and path Effect
GET /payments/verify/{reference} Verify the caller's payment with the gateway, mark it verified, then fire the fulfilment event
GET /payments/{payment_id} The caller's payment record

Both routes only find payments whose account_id matches the caller; anything else is a 404. get_current_account must return an object with id (and email for process_payment's fallback). The payment, paymentqueue and account tables must exist.

Configuration

PaymentsSettings has the PAYMENTS namespace.

Key Default Notes
PAYMENT_GATEWAY paystack Default gateway used by process_payment when the request does not name one
PAYMENT_CALLBACK_URL empty Where the gateway sends the customer after paying; per-payment callback_url overrides it
PAYMENT_CANCEL_URL empty Same for cancellation
PAYSTACK_BASE_URL https://api.paystack.co
PAYSTACK_SECRET_KEY empty
STRIPE_SECRET_KEY empty

Every key has a flat alias, so the environment variables are the plain names: PAYSTACK_SECRET_KEY, STRIPE_SECRET_KEY, PAYMENT_GATEWAY and so on. PAYMENTS__PAYSTACK_SECRET_KEY form works only if you declare the namespace as a field (PAYMENTS: PaymentsSettings = PaymentsSettings()); with the subclassed style above it is not read. Both styles were checked.

Behaviour may change (#280)

The NAMESPACE__KEY environment variable style only works for settings composed as a field, not for subclassed hosts. This may change.

Key concepts

  • PaymentData is the input: email, amount (a decimal in major units, such as 5000 for 5000.00), gateway (stripe or paystack), optional description, callback_url, cancel_url. The gateways convert to the smallest currency unit by multiplying by 100; amounts of zero or less raise PaymentAmountError.
  • Payment is the stored record: reference, authorization_url, access_code, status, gateway, amount, and a JSON data field.
  • PaymentQueue is one row per payment holding an event_key and a JSON data payload from your business flow. It is the hand-off to your fulfilment code. Statuses: queued, then processed.
  • PaymentStatus: unverified when created, verified once the gateway confirms. failed and fulfilled also exist in the enum; fulfilled is the status the gateways report internally on success.
  • PaymentProcessor(settings) holds both gateways. Select one with .gateway("paystack") (returns the processor), then await .initiate(account, data) or await .verify(reference). Calling either without selecting a gateway raises AttributeError. Constructing a processor builds both gateways; StripeGateway sets stripe.api_key globally at construction.
  • process_payment (in msflib.payments.service.processor) wraps initiation, saves the Payment and the PaymentQueue row, and returns the payment.
  • Gateway behaviour:
    • Paystack: initializes a transaction over HTTP and returns an authorization_url for the customer to visit. Status maps success to fulfilled; failed and abandoned raise PaymentStatusError.
    • Stripe: creates an embedded Checkout Session (card by default, ui_mode="embedded", currency fixed to usd). The session client_secret is returned as access_code, authorization_url is empty, and reference is the session id. Pass the client_secret to Stripe's embedded checkout in the browser and do not log it.
  • BasePaymentGateway is the abstract base (initiate, verify, convert_amount). Implement it for another provider; note that PaymentProcessor registers only the two built-in gateways and PaymentGateway is a fixed enum, so a third gateway needs changes in the module.

Known issue (#273)

Several gateway and packaging details differ from what you would expect:

  • VerificationResult has no raw field, so the raw= value both gateways pass is dropped.
  • The router stores verified while the gateways report fulfilled.
  • Stripe checkout sessions always use usd.
  • PaymentProcessor always builds a StripeGateway, which sets stripe.api_key globally.
  • msflib-payments does not declare msflib-account although the payment table has a foreign key to account.id; add it yourself.

Fulfilment event

After a successful verification the router emits payment-queue-execute-{event_key} through the core event bus with emit_required_async(event_name, queue, {"session": session}). A listener receives the PaymentQueue row and an options dict containing the session. Bind the app emitter when you create the app, app_emitter = bind_app_emitter(app), and register the listener with @app_emitter.on(event_name). The router emits through the app emitter during the request, so it sees these listeners. A listener added with emitter.on(...) on the module-level emitter object goes to the default emitter instead, which the router does not use in an app bound with bind_app_emitter(app), and the router reports a missing handler: a fresh verification still returns 200 but leaves the queue queued, and a retry of an already verified payment returns 500 "Payment queue handler is not registered." The router sets the queue status to processed and commits after the listener returns, so the listener should not commit itself.

  • Verifying a payment that is already verified and whose queue is still queued re-emits the event, so a failed fulfilment can be retried by calling verify again.
  • If no listener is registered or a listener raises, the session is rolled back. On a fresh verification the payment stays verified and the queue stays queued, and the request still returns 200. On a retry of an already verified payment the request returns 500.

Examples

Create a payment from your own endpoint, register a fulfilment listener, and verify. The gateway calls are replaced with stubs so this runs without network access; with real keys, drop the two patches. This was run against in-memory SQLite.

import asyncio
from unittest.mock import patch

from msflib.eventbus import bind_app_emitter
from msflib.payments.models import (
    PaymentData, PaymentGateway, PaymentInfo, PaymentStatus, VerificationResult,
)
from msflib.payments.service.paystack_gateway import PaystackGateway
from msflib.payments.service.processor import process_payment


app_emitter = bind_app_emitter(app)   # the FastAPI app that mounts the payments router


@app_emitter.on("payment-queue-execute-subscription-create")
async def activate_subscription(queue, options):
    session = options["session"]
    print("fulfil", queue.data)          # {'plan': 'pro'}


async def fake_initiate(self, account, data):
    return PaymentInfo(
        **data.model_dump(), account_id=account.id,
        authorization_url="https://pay.example/x", access_code="ac",
        reference="ref-1", status=PaymentStatus.unverified, data={},
    )


async def fake_verify(self, reference):
    return VerificationResult(
        status=PaymentStatus.fulfilled, reference=reference, amount=500000,
        currency="NGN", gateway=PaymentGateway.paystack,
    )


with patch.object(PaystackGateway, "initiate", fake_initiate), \
     patch.object(PaystackGateway, "verify", fake_verify):
    payment = asyncio.run(process_payment(
        session=session,
        payment_data=PaymentData(
            email="buyer@example.com", amount=5000,
            description="Pro plan", gateway="paystack",
        ),
        account=account,
        settings=settings,
        queue_event_key="subscription-create",
        queue_data={"plan": "pro"},
    ))
    # payment.status == unverified, payment.queue.status == queued
    # GET /payments/verify/ref-1 now returns 200 with status "verified",
    # runs activate_subscription, and leaves payment.queue.status == processed

In a real app process_payment is awaited inside an async endpoint and the response sends payment.authorization_url (Paystack) or payment.access_code (Stripe) to the client. process_payment converts gateway connection and response errors into PaymentError from msflib.payments.service.error (PaymentConnectionError and PaymentResponseError are subclasses that carry the original cause).

The verify route also compares the gateway's reported amount with convert_amount(payment.amount) and returns 400 "Payment amount mismatch." if they differ.

Troubleshooting

  • Verify returns 404 "Payment record not found for this user." The reference does not belong to the authenticated account, or the reference stored at initiation differs from the one the client sent.
  • Verify returns 400. Either the gateway reports the payment as not successful (PaymentStatusError), the amount does not match, or the stored gateway is unsupported.
  • Verify returns 502. The gateway could not be reached or returned an error status (PaymentConnectionError or PaymentResponseError). Check the secret key and, for Paystack, PAYSTACK_BASE_URL.
  • Verify returns 500 "Payment queue handler is not registered." The payment was already verified, its queue is still queued, and no listener is registered for payment-queue-execute-{event_key}. Register the listener with @app_emitter.on(...) on the app emitter of the app that serves the router, at creation or in the lifespan handler of that process.
  • Payment is verified but nothing was fulfilled. The first verification call hit a missing or failing listener; the failure is logged and the queue stays queued. Fix the listener and call verify again.

API reference

See the generated API reference for msflib.payments. The module's README.md in modules/payments has a short summary.

See also