Implementing Reliable Billing Infrastructure with FlexPay and Symfony

Cover image for Implementing Reliable Billing Infrastructure with FlexPay and Symfony

How to build a durable payment workflow around FlexPay using Orders, Payment Transactions, signed webhooks, idempotent fulfillment, and a migration path from a legacy billing model.

Ever integrated a payment provider, received a successful response, and assumed the billing work was finished?

Calling a payment API is the easy part.

The difficult part starts when:

  • the provider accepts the request but its webhook arrives several minutes later;

  • the customer closes the browser before returning to the application;

  • the same webhook is delivered twice;

  • two callbacks arrive concurrently;

  • the provider is unavailable after I have already started the checkout;

  • payment succeeds but access fulfillment fails;

  • or support needs to understand what happened three months later.

A payment provider answers a narrow question: can this payment attempt be processed?

A billing system must answer a much larger set of questions:

  • What did the customer intend to buy?

  • Which price did I charge?

  • Which payment attempt belongs to that purchase?

  • What evidence did the provider send?

  • Was the callback already processed?

  • Was the order paid?

  • Was the purchased access actually delivered?

  • Can the complete history be audited and retried safely?

While rebuilding the billing infrastructure of a Symfony platform, I integrated FlexPay for Mobile Money and card payments. I also maintain the PHP client used by this integration, which gave me direct visibility into the provider protocol, request and response mapping, and SDK behavior at the infrastructure boundary. I also had years of behavior and data in a legacy application, so this was not a greenfield exercise.

The legacy system already contained good ideas: a payment-gateway interface, signed callback URLs, pending transactions, and an in-memory gateway. The refactor kept those useful seams while separating commercial intent, payment attempts, provider events, and access fulfillment.

As a co-founder, this distinction matters to me beyond code quality. Payment defects immediately become trust defects. If a customer pays and does not receive access, the architecture becomes a customer-support problem, a financial problem, and a reputation problem at the same time.

In this post, I’ll show how the billing flow is structured, how FlexPay is isolated behind an adapter, what I learned from the legacy implementation, and how you can reproduce the same architecture in another Symfony project.

A Payment Integration Is Not a Billing Model

The first version of many payment integrations looks like this:

PHP
$response = $flexpay->pay($request);

if ($response->isSuccessful()) {
    $subscription->activate();
}

This code collapses several different facts into one condition.

An accepted initiation response may mean only that the provider accepted the request for processing. It does not necessarily mean that money was collected. A browser redirect is not payment proof. A subscription is not a payment transaction. A provider callback is not an order.

I now model these concepts separately:

Mermaid

That vocabulary is the foundation of the implementation.

The Architecture in One Flow

Mermaid

The important design decision is that FlexPay never activates a subscription directly. It changes a payment fact. Application events then coordinate what a successful payment means to the rest of the system.

What the Legacy System Got Right

It is tempting to describe a legacy system only through its problems. That usually hides the decisions that made the refactor possible.

My previous implementation already had an OnlinePaymentGateway interface:

PHP
interface OnlinePaymentGateway
{
    public function createMobileRequest(...): MobileRequest;

    public function createCardRequest(...): CardRequest;

    public function pay(
        MobileRequest|CardRequest $request,
    ): PaymentResponse|CardResponse;

    public function handleCallback(array $data): PaymentResponse;
}

It also had:

  • a real FlexPay adapter;

  • an in-memory adapter for development;

  • signed callback URLs;

  • a pending Transaction created before calling the provider;

  • and separate accepted, declined, and cancelled callback routes.

Those were valuable foundations.

The limitations appeared as the application grew.

The gateway abstraction imported FlexPay request and response classes directly into the domain contract. A comment in the old interface even documented the problem: the gateway still needed to be decoupled from FlexPay.

The old checkout created a pending Subscription or lifetime-access entity before payment. A declined callback then deleted that domain object. In other words, an access concept was also being used as temporary checkout state.

The callback controller parsed provider data, checked transaction state, chose a command, handled exceptions, rendered browser pages, and logged the provider payload. Too many responsibilities met at the most security-sensitive boundary in the application.

There was also no durable provider-event journal, no payload fingerprint, and no explicit pessimistic locking boundary. A repeated webhook was guarded mainly by asking whether the transaction was still pending.

These problems did not mean the legacy system was useless. They showed me where the next boundaries had to be.

Step 1: Separate Order State from Payment State

An Order represents commercial intent:

PHP
enum OrderStatus: string
{
    case Draft = 'draft';
    case PendingPayment = 'pending_payment';
    case Paid = 'paid';
    case Fulfilled = 'fulfilled';
    case Cancelled = 'cancelled';
    case Failed = 'failed';
}

A PaymentTransaction represents one attempt to collect money:

PHP
enum PaymentStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Succeeded = 'succeeded';
    case Failed = 'failed';
    case Cancelled = 'cancelled';
    case Refunded = 'refunded';
}

These state machines are related, but they are not interchangeable.

Mermaid

An order may eventually have more than one payment attempt. A succeeded payment does not prove that fulfillment completed. A fulfilled order says that the purchased capability was delivered.

Keeping the state machines separate gives operations and support a much more precise answer than a single payment_status column attached to a subscription.

Step 2: Snapshot What Was Purchased

An order item stores the product, selected price, charged amount, and fulfillment metadata:

PHP
final readonly class OrderItem
{
    public function __construct(
        public OrderItemId $id,
        public OrderId $orderId,
        public ProductId $productId,
        public PriceId $priceId,
        public Money $amount,
        public OrderItemMetadata $metadata,
    ) {}
}

This is important because catalogue data changes.

If a price changes next month, an existing invoice must not silently change with it. The order item keeps the price identity and actual charged amount from checkout time.

My checkout resolves an active product and price, applies any country-aware pricing rule, and stores the resulting Money value on both the order and its item.

PHP
final readonly class Money
{
    public function __construct(
        public int $amount,
        public Currency $currency,
    ) {
        if ($amount < 0) {
            throw new InvalidArgumentException(
                'A monetary amount cannot be negative.',
            );
        }
    }
}

Never pass a bare amount through billing code. 10000 without CDF or USD is incomplete business data.

Step 3: Persist the Checkout Intent Before Calling FlexPay

External HTTP requests must not run while a database transaction is holding locks.

My self-service checkout has three phases:

PHP
final readonly class SelfServiceCheckout
{
    public function start(CheckoutIntent $intent): CheckoutResult
    {
        $checkout = $this->transactions->run(
            fn () => $this->intentRecorder->record(
                $intent,
                $this->clock->now(),
            ),
        );

        $response = $this->paymentInitiator->initiate(
            $intent,
            $checkout,
        );

        $payment = $this->transactions->run(
            fn () => $this->paymentRecorder->record(
                $checkout->paymentTransaction->id,
                $response,
                $this->clock->now(),
            ),
        );

        return CheckoutResult::from($checkout, $payment, $response);
    }
}

Phase one records an Order in pending_payment, an immutable Order Item purchase snapshot, and a Payment Transaction in pending.

The transaction commits before FlexPay is contacted.

Phase two performs the external call with no database transaction open.

Phase three locks or reloads the payment transaction and records the initiation response: provider acceptance moves it to processing, while provider rejection moves it to failed.

This design gives me a durable internal identifier before any network call. The identifier is embedded in the callback URL and makes every later provider interaction traceable to an internal record.

It also handles an uncomfortable but important failure correctly: if FlexPay is unavailable, the order and pending payment intent remain available for support and recovery. I do not lose the fact that checkout started or keep a transaction open while waiting on the network.

The unit test protects that exact boundary:

PHP
self::assertTrue($gateway->calledOutsideTransaction);
self::assertCount(1, $orders->savedOrders);
self::assertCount(1, $paymentTransactions->savedTransactions);
self::assertSame(
    PaymentStatus::Pending,
    $paymentTransactions->savedTransactions[0]->status,
);

Step 4: Put FlexPay Behind an Outbound Port

Conceptually, application code needs a small gateway contract. If I were introducing the boundary from scratch today, I would make every input and output application-owned:

PHP
interface OnlinePaymentGateway
{
    public function createMobileRequest(
        Money $amount,
        string $reference,
        PaymentTransactionId $transactionId,
        PhoneNumber $phoneNumber,
        ?string $description = null,
    ): object;

    public function createCardRequest(
        Money $amount,
        string $reference,
        PaymentTransactionId $transactionId,
        string $description,
    ): object;

    public function pay(object $request): PaymentInitiationResult;

    public function verifyCallback(array $payload): VerifiedPaymentCallback;
}

My current implementation introduced the port incrementally, so request creation is behind the contract but a few SDK request and response types still appear in its method signatures. The real adapter converts domain values to FlexPay SDK objects:

PHP
final readonly class FlexpayGateway implements OnlinePaymentGateway
{
    public function createMobileRequest(
        Money $amount,
        string $reference,
        PaymentTransactionId $transactionId,
        PhoneNumber $phoneNumber,
        ?string $description = null,
    ): MobileRequest {
        return new MobileRequest(
            amount: $amount->amount,
            reference: $reference,
            currency: FlexpayCurrency::from(
                $amount->currency->value,
            ),
            phone: ltrim((string) $phoneNumber, '+'),
            callbackUrl: $this->urls->accepted($transactionId),
            approveUrl: $this->urls->accepted($transactionId),
            cancelUrl: $this->urls->cancelled($transactionId),
            declineUrl: $this->urls->declined($transactionId),
            description: $description,
        );
    }
}

The adapter owns:

  • provider credentials and environment selection;

  • SDK request construction;

  • currency translation;

  • phone-number normalization;

  • provider callback parsing;

  • safe integration logging;

  • and translation of SDK or network exceptions into PaymentGatewayUnavailable.

I maintain the devscast/flexpay PHP package used by this adapter. That is useful operationally: when the provider contract evolves or an edge case appears, I can investigate both the application integration and the client library. But maintaining the package does not make it part of the domain. The SDK remains an infrastructure dependency and is still kept behind the gateway boundary so Billing does not inherit its transport model.

That remaining type leakage is a pragmatic compromise. It is already better isolated than direct SDK usage throughout the application, but it is not complete provider independence.

For a new implementation, define application-owned PaymentInitiationResult and VerifiedPaymentCallback objects as shown above. The FlexPay adapter should be the only place that knows PaymentResponse, CardResponse, or provider status codes.

Architecture is allowed to improve in stages. The important part is to know where the remaining coupling lives.

Step 5: Select the Gateway through Configuration

The application can use FlexPay in production and a controlled implementation locally:

YAML
parameters:
    billing.online_payment_gateway.default: 'in_memory'

services:
    billing.gateway.in_memory:
        alias: App\Billing\Infrastructure\InMemoryFlexpayGateway

    billing.gateway.flexpay:
        alias: App\Billing\Infrastructure\FlexpayGateway

    App\Billing\Domain\Service\OnlinePaymentGateway:
        alias: App\Billing\Infrastructure\ConfigurableOnlinePaymentGateway

The configurable gateway delegates through a service locator:

PHP
private function gateway(): OnlinePaymentGateway
{
    $key = match ($this->selectedGateway) {
        'in_memory' => 'billing.gateway.in_memory',
        'flexpay' => 'billing.gateway.flexpay',
        default => throw new LogicException(
            'Unknown payment gateway.',
        ),
    };

    return $this->gateways->get($key);
}

Configuration comes from environment variables:

Plain text
APP_PAYMENT_GATEWAY=flexpay
FLEXPAY_TOKEN=***
FLEXPAY_MERCHANT=***
FLEXPAY_ENV=prod

Do not log these values, store them in source-controlled environment files, or include them in exception context.

The application defaulting to an in-memory adapter in development is a safety feature. A developer should not trigger a real payment accidentally by running the application locally.

Step 6: Generate Signed Callback URLs

Every payment transaction gets outcome-specific callback URLs:

PHP
final readonly class PaymentWebhookUrlGenerator
{
    public function accepted(
        PaymentTransactionId $transactionId,
    ): string {
        return $this->generate(
            'billing.payment.accepted',
            $transactionId,
        );
    }

    private function generate(
        string $route,
        PaymentTransactionId $transactionId,
    ): string {
        $url = $this->router->generate($route, [
            'id' => $transactionId->toString(),
        ], UrlGeneratorInterface::ABSOLUTE_URL);

        return $this->uriSigner->sign($url);
    }
}

Symfony’s URI signature protects the route and transaction identifier from tampering.

However, a signed URL and a verified provider callback prove different things:

MechanismWhat it proves
Signed callback URLThe URL was generated by the application
Provider verificationThe payload represents a provider-confirmed outcome

A browser return must never be treated as the authoritative proof of payment. The browser can display “processing,” “cancelled,” or a provisional status, but only a verified server-to-server callback—or an explicit provider-status lookup—should move a payment to succeeded.

My current compatibility boundary accepts both GET and POST on the outcome routes because FlexPay uses related URLs for browser returns and provider callbacks. A verifyProvider flag distinguishes the POST callback from the browser redirect before both enter the application processor. This is a seam I would tighten further: use a POST-only webhook that may mutate payment state and a separate GET-only return page that only reads and displays the authoritative status.

This is an important hardening rule when reproducing the implementation. Correlation is not authentication, and authentication is not settlement verification.

Step 7: Normalize Provider Outcomes at the Boundary

FlexPay result codes should not spread through the domain.

The callback boundary maps provider behavior to the outcomes understood by Billing:

PHP
enum PaymentWebhookOutcome: string
{
    case Accepted = 'accepted';
    case Declined = 'declined';
    case Cancelled = 'cancelled';
}

A thin controller gathers untrusted transport data and dispatches one command:

PHP
#[Route(
    '/billing/system/payment/{id}/accepted',
    methods: ['POST'],
)]
#[IsSignatureValid]
final class AcceptedPaymentWebhookController
{
    public function __invoke(
        PaymentTransactionId $id,
        Request $request,
    ): JsonResponse {
        $providerResult = $this->gateway->verifyCallback(
            $request->getPayload()->all(),
        );

        $this->commands->handle(new HandlePaymentWebhook(
            paymentTransactionId: $id,
            outcome: $providerResult->outcome,
            payload: $providerResult->payload,
        ));

        return new JsonResponse([], Response::HTTP_OK);
    }
}

The controller does not activate access, edit entities, or contain the payment state machine.

For accepted payments, verify more than the result code. Compare the callback’s internal reference, provider reference, amount, and currency with the stored transaction whenever the provider supplies them. A validly signed request containing mismatched commercial data must not mark the order paid.

Step 8: Treat Webhooks as At-Least-Once Delivery

Payment providers retry callbacks. Your handler must assume that the same event can arrive multiple times and that two workers can process it concurrently.

My webhook handler uses two protections.

First, it locks the payment transaction:

PHP
public function getForUpdate(
    PaymentTransactionId $id,
): PaymentTransaction {
    return $this->entityManager->find(
        PaymentTransaction::class,
        $id,
        LockMode::PESSIMISTIC_WRITE,
    ) ?? throw PaymentTransactionNotFound::withId($id);
}

Second, it records a fingerprint:

PHP
private function fingerprint(
    PaymentWebhookOutcome $outcome,
    PaymentProvider $provider,
    PaymentTransactionId $transactionId,
    array $payload,
): string {
    $payload = $this->sortRecursively($payload);

    return hash('sha256', json_encode([
        'provider' => $provider->value,
        'transactionId' => $transactionId->toString(),
        'outcome' => $outcome->value,
        'payload' => $payload,
    ], JSON_THROW_ON_ERROR));
}

The database enforces uniqueness on the (provider, fingerprint) pair.

The handler flow becomes:

PHP
$transaction = $payments->getForUpdate($command->transactionId);
$fingerprint = $this->fingerprint(...);

if ($events->exists($transaction->provider, $fingerprint)) {
    return;
}

$event = PaymentEvent::record(
    paymentTransactionId: $transaction->id,
    provider: $transaction->provider,
    fingerprint: $fingerprint,
    payload: $command->payload,
    now: $clock->now(),
);

if ($transaction->isPendingOrProcessing()) {
    $this->apply($command->outcome, $transaction);
}

$event->markProcessed($clock->now());

The lock protects concurrent state transitions. The fingerprint protects replay. The unique database constraint remains the final defense if two processes race past an application-level existence check.

Step 9: Preserve Raw Evidence Separately from Normalized State

A PaymentTransaction contains the normalized lifecycle used by business rules.

A PaymentEvent stores callback evidence:

PHP
final class PaymentEvent
{
    public function __construct(
        public readonly PaymentEventId $id,
        public readonly PaymentTransactionId $transactionId,
        public readonly PaymentProvider $provider,
        public readonly string $fingerprint,
        public readonly DateTimeImmutable $receivedAt,
        public ?DateTimeImmutable $processedAt,
        public readonly array $payload,
    ) {}
}

Known provider fields are also projected into typed transaction metadata:

PHP
final readonly class PaymentTransactionMetadata
{
    public function __construct(
        public ?string $providerReference = null,
        public ?string $orderNumber = null,
        public ?string $channel = null,
        public ?string $failureReason = null,
        public array $additionalPayload = [],
    ) {}
}

Business decisions use typed state:

PHP
$transaction->status === PaymentStatus::Succeeded;

They do not repeatedly interpret arbitrary provider payload keys.

Keeping the raw event remains valuable for audits, provider disputes, and debugging. It also creates a data-governance responsibility. Redact secrets, avoid storing unnecessary credentials or card data, restrict administrative access, and define a retention policy.

Step 10: Make State Transitions Idempotent

The payment aggregate owns legal transitions:

PHP
public function markSucceeded(
    DateTimeImmutable $now,
    array $payload = [],
): void {
    if ($this->status === PaymentStatus::Succeeded) {
        return;
    }

    if (! $this->isPendingOrProcessing()) {
        throw PaymentStatusTransitionNotAllowed::from(
            $this->status,
            PaymentStatus::Succeeded,
        );
    }

    $this->status = PaymentStatus::Succeeded;
    $this->succeededAt = $now;
    $this->metadata = $this->metadata
        ->withProviderPayload($payload);

    $this->recordThat(new PaymentSucceeded(
        $this->id,
        $this->orderId,
        $now,
    ));
}

An accepted callback also marks the order paid:

PHP
$payment->markSucceeded($now, $payload);
$order->markPaid($now);

The domain event is released only when the transition actually happens. A duplicate callback cannot announce payment success twice.

Step 11: Fulfill Access after Payment

PaymentSucceeded is the bridge from Billing to Access:

PHP
final readonly class PaymentSucceededHandler
{
    public function __invoke(PaymentSucceeded $event): void
    {
        $this->commands->handle(new FulfillPaidOrder(
            orderId: $event->orderId,
            occurredAt: $event->occurredAt,
        ));
    }
}

The fulfillment service:

  1. checks whether the order is already fulfilled;

  2. locks the order;

  3. requires the order to be paid;

  4. loads every order item;

  5. delegates according to product type;

  6. creates or extends the entitlement;

  7. and marks the order fulfilled only after all items succeed.

PHP
if ($order->status === OrderStatus::Fulfilled) {
    return;
}

if ($order->status !== OrderStatus::Paid) {
    throw new PaidOrderRequired();
}

foreach ($items as $item) {
    $this->fulfillItem($order, $item, $now);
}

$order->markFulfilled($now);

Each entitlement path has its own idempotency key. A subscription checks whether one already exists for the order. A work-access grant checks the order and purchased work.

This is stronger than relying only on the payment handler. Event delivery can be retried without granting the same access twice.

The same fulfillment boundary supports manually recorded cash and bank-transfer payments. Administrative checkout creates a PaymentTransaction with a manual provider, marks it succeeded, and follows the same paid-order audit and fulfillment rules without pretending it passed through FlexPay.

Step 12: Return a Checkout Result the Frontend Can Use

The API returns internal correlation and provider navigation data:

PHP
final readonly class CheckoutResult
{
    public function __construct(
        public OrderId $orderId,
        public PaymentTransactionId $paymentTransactionId,
        public bool $paymentRequestAccepted,
        public string $message,
        public ?string $paymentUrl,
        public ?string $providerReference,
        public ?string $providerOrderNumber,
    ) {}
}

For card flows, the frontend redirects to the provider URL:

TypeScript
const paymentUrl = response.paymentUrl;

if (paymentUrl) {
  window.location.href = paymentUrl;
}

For Mobile Money, the customer may also need to confirm the request on their phone.

The frontend initiation response is still not the source of truth for access. It can show that payment processing started, but the backend webhook and fulfillment state remain authoritative.

Step 13: Build an In-Memory Gateway That Behaves Like the Real Boundary

A useful fake does more than return true.

My in-memory gateway builds the same request shapes, returns provider-like responses, and schedules an HTTP callback to the signed webhook URL.

PHP
public function pay(PaymentRequest $request): PaymentResult
{
    $status = $this->statusFor($request);
    $payload = $this->callbackPayload($request, $status);

    $this->scheduleCallback(
        $request->callbackUrl,
        $payload,
    );

    return PaymentResult::fromStatus($status, $payload);
}

A configured phone number produces success; other numbers produce decline. A test clock supplies callback timestamps.

This lets local development exercise:

Mermaid

No real payment credentials or external network are required.

The legacy fake was already a useful idea, but it used the system clock directly, hardcoded the success number, and could return success even after constructing a failure status. The refactor made its configuration explicit and aligned its response with the callback outcome.

Step 14: Test Business Failure, Not the Provider SDK

Unit tests protect aggregate transitions:

PHP
$payment->markProcessing($now);
$payment->markSucceeded($later);

self::assertSame(
    PaymentStatus::Succeeded,
    $payment->status,
);

Application tests protect the orchestration:

Plain text
Scenario: Replaying the same accepted webhook is idempotent
    Given a pending payment transaction exists
    When the same accepted payment webhook is handled twice
    Then one provider payment event should be recorded
    And payment success should be announced once

Other important scenarios include:

  • an accepted webhook pays the order;

  • a declined webhook fails the payment without paying the order;

  • cancellation closes the attempt without granting access;

  • a callback for an already-final transaction is recorded but does not mutate it;

  • a provider exception leaves a durable pending checkout;

  • external I/O runs outside the database transaction;

  • fulfillment rejects an unpaid order;

  • retrying fulfillment does not duplicate a subscription or access grant;

  • and manual payment follows the same audit trail.

Do not call the real FlexPay environment from the automated test suite. Test your adapter mapping separately and test your application with a deterministic fake.

Migrating Legacy Billing Data without Importing the Legacy Model

The refactor also needed to preserve historical billing records.

The legacy database had one transaction row connected either to a subscription or a lifetime-access record. Migration plans reconstruct that commercial intent and expand it into the new model:

Mermaid

The mapper then selects a stable product code, resolves the current product and price references, and preserves legacy identifiers in order-item metadata when needed.

Old states are translated explicitly:

Mermaid

Rows whose commercial intent cannot be classified are skipped or imported as limited financial history according to an explicit rule. Conflicts such as a missing user, product, price, or order are reported instead of silently creating partial records.

One compatibility compromise remains: transaction references still use the legacy-compatible format while dependent systems migrate. For a new system, use a collision-resistant public reference with a database uniqueness constraint—preferably an opaque UUID/ULID-based reference or a dedicated sequence designed for customer-facing payment references.

The broader lesson is:

Translate legacy data at the migration boundary. Do not make the new domain pretend the old schema is still the correct model.

What I Improved from the Legacy Design

The refactor changed the responsibilities, not only the class names.

Legacy behaviorRefactored behavior
Pending subscription or access record doubled as checkout stateOrder and Payment Transaction own checkout state; entitlement is created after payment
Transaction mixed payment and purchase typeOrder Items describe the purchase; Payment Transaction describes collection
Callback controller orchestrated business behaviorThin controller delegates a normalized application command
Pending check was the main duplicate defenseFingerprint, unique constraint, idempotent state transition, and database lock
Provider payload existed mainly in logsPayment Event persists callback evidence
Full request and payload logged at critical levelStructured logs use limited business identifiers; sensitive payload stays out of logs
Ambient DateTimeImmutable() callsInjected clock supplies business timestamps
Hardcoded callback URL constructionDedicated router-based signed URL generator
Hardcoded local simulator behaviorConfigurable in-memory gateway with deterministic outcomes
Decline deleted the provisional entitlementFailed attempts remain auditable; no entitlement exists before success
One transaction row had to explain everythingOrder, item, payment attempt, provider event, and entitlement have distinct roles

The new implementation is not “more architectural” for aesthetic reasons. Each additional boundary answers a production failure mode that the old model could not represent clearly.

Remaining Hardening Work

No payment architecture is finished merely because the happy path works.

For a production implementation, verify these points explicitly:

  • only a verified server callback or provider-status lookup can succeed a payment;

  • callback reference, amount, and currency match the stored transaction;

  • payment references and provider event fingerprints have database uniqueness constraints;

  • secrets are stored outside version control and rotated safely;

  • logs never contain tokens, full payment requests, or unnecessary personal data;

  • provider payload storage has access controls and retention rules;

  • HTTP clients use bounded connection and response timeouts;

  • retries cannot create another order or duplicate entitlement;

  • a reconciliation job can inspect transactions stuck in pending or processing when a webhook is lost;

  • refund transitions update payment, order, and entitlement policies consistently;

  • and domain-event delivery uses an outbox or an equivalent reliability mechanism if events cross process boundaries.

Some of these are already represented by the current model; others are deliberate next steps. For example, refunded exists in the payment vocabulary, but a complete automated refund workflow should not be claimed until provider reversal, order effects, and entitlement revocation are implemented together.

Being precise about what the system guarantees is part of reliable billing engineering.

Wrapping Up

FlexPay is an infrastructure dependency. Billing is a domain.

The provider adapter initiates Mobile Money and card payments, parses callbacks, and translates failures. It should not decide what the customer bought, whether an order is fulfilled, or how subscription access changes.

The architecture works because each concept has one job:

  • Product and Price define what can be sold.

  • Order and Order Item preserve the commercial intent.

  • Payment Transaction owns the collection state machine.

  • Payment Event preserves provider evidence and idempotency.

  • Signed URLs correlate callbacks with internal transactions.

  • Provider verification establishes the payment outcome.

  • Database locks protect concurrent webhook processing.

  • Domain events connect successful payment to fulfillment.

  • Idempotent fulfillment creates access exactly once.

  • An in-memory gateway exercises the whole workflow locally.

  • Legacy migration plans translate old data without contaminating the new model.

The most important refactor was moving from one implicit transition to an explicit, recoverable workflow:

Mermaid

That flow contains more steps, but each step makes failure visible and recovery possible.

In billing, that is not accidental complexity. It is the cost of making customer money and customer access trustworthy.

Happy coding!

Related writing