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¶
PaymentDatais the input:email,amount(a decimal in major units, such as 5000 for 5000.00),gateway(stripeorpaystack), optionaldescription,callback_url,cancel_url. The gateways convert to the smallest currency unit by multiplying by 100; amounts of zero or less raisePaymentAmountError.Paymentis the stored record:reference,authorization_url,access_code,status,gateway,amount, and a JSONdatafield.PaymentQueueis one row per payment holding anevent_keyand a JSONdatapayload from your business flow. It is the hand-off to your fulfilment code. Statuses:queued, thenprocessed.PaymentStatus:unverifiedwhen created,verifiedonce the gateway confirms.failedandfulfilledalso exist in the enum;fulfilledis the status the gateways report internally on success.PaymentProcessor(settings)holds both gateways. Select one with.gateway("paystack")(returns the processor), thenawait .initiate(account, data)orawait .verify(reference). Calling either without selecting a gateway raisesAttributeError. Constructing a processor builds both gateways;StripeGatewaysetsstripe.api_keyglobally at construction.process_payment(inmsflib.payments.service.processor) wraps initiation, saves thePaymentand thePaymentQueuerow, and returns the payment.- Gateway behaviour:
- Paystack: initializes a transaction over HTTP and returns an
authorization_urlfor the customer to visit. Status mapssuccessto fulfilled;failedandabandonedraisePaymentStatusError. - Stripe: creates an embedded Checkout Session (card by default,
ui_mode="embedded", currency fixed tousd). The sessionclient_secretis returned asaccess_code,authorization_urlis empty, andreferenceis the session id. Pass theclient_secretto Stripe's embedded checkout in the browser and do not log it.
- Paystack: initializes a transaction over HTTP and returns an
BasePaymentGatewayis the abstract base (initiate,verify,convert_amount). Implement it for another provider; note thatPaymentProcessorregisters only the two built-in gateways andPaymentGatewayis 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:
VerificationResulthas norawfield, so theraw=value both gateways pass is dropped.- The router stores
verifiedwhile the gateways reportfulfilled. - Stripe checkout sessions always use
usd. PaymentProcessoralways builds aStripeGateway, which setsstripe.api_keyglobally.msflib-paymentsdoes not declaremsflib-accountalthough thepaymenttable has a foreign key toaccount.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
verifiedand whose queue is stillqueuedre-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
verifiedand the queue staysqueued, 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 (
PaymentConnectionErrororPaymentResponseError). 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 forpayment-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
verifiedbut nothing was fulfilled. The first verification call hit a missing or failing listener; the failure is logged and the queue staysqueued. 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¶
- account: the account model the payment record belongs to
- Event bus: registering listeners
- Mounting routes