/projects/dietbox-payment

All projects

Dietbox Payment

Subscriptions and recurring billing, behind a checkout of its own.

Head of Technology · 2023–2024

.NET 6C#CQRSVue 3VitePrimeVuePiniaCypressAzure DevOps
WebsitePrivate
Dietbox Payment screenshot

Overview

The service responsible for the money: subscription commands on one side, a webhook handler for every event a payment gateway raises on the other, and two gateway integrations in between — Iugu and TSPay, each in a crosscutting package of its own. It is kept separate because money has a different failure mode from everything else in the product — its own release train, in a repository it shares with the platform’s other services.

What I did

As principal architect across the estate, I set the patterns this service is built on: the path-filtered release pipeline that lets it ship on its own train, and the crosscutting-package convention every third-party integration is wrapped in before a service takes a dependency on it. The commands, the webhook handlers and the checkout itself were the team’s to write.

  • The release pipeline’s path filter, so a payment hotfix ships on its own branch without redeploying the other four services.
  • The crosscutting package each gateway integration lives in, and the one shared project that pulls them in for whichever service needs them.
  • The Azure estate this service deploys onto, configured the same way as its neighbours.

The subscription commands, the webhook handlers and the checkout client were a team’s work: I hold roughly a tenth of the checkout client’s commits, across February 2023 to July 2024, and about a fifth of the service’s, whose repository does not begin until October 2023 — the bulk of both belongs to other engineers.

The problem

A subscription doesn’t live only in the product’s own database — it also lives in whichever gateway is processing it, and that gateway’s opinion of the subscription’s state arrives asynchronously, by webhook, on its own schedule. Two gateways were live at once while subscribers were being moved between them, each with its own event names, its own payload shape and its own idea of what a subscription is. And every one of those webhook deliveries has to be reconciled with what the product already believes happened, not simply trusted.

Architecture

A Vue checkout out front, CQRS commands and controllers in the middle, and two gateway integrations each in a package of its own — with each gateway closing the loop asynchronously through a webhook endpoint of its own.

  1. Checkout clientThe Vue checkout — subscription, renewal and thank-you views — calls the service’s commands: subscribe, create an invoice, generate a payment link.
  2. Payment serviceSubscription, transaction, voucher, extension and webhook controllers sit in front of the CQRS commands that do the work.
  3. Gateway packagesIugu and TSPay each live in a crosscutting package with an interface of their own, reached through the one shared project every service in this repository references.
  4. Gateway webhooksEach gateway posts its own opinion of the subscription back to an endpoint of its own, where a factory maps that gateway’s event names onto commands — one handler directory per event.

The subscription lifecycle

Every step below is a directory in the webhook handler tree, named for the gateway event it answers.

  1. CreatedThe gateway has created the subscription on its side; the service records it before the first invoice exists.
  2. ActivatedThe subscription’s first payment cleared; the service marks it active and the customer’s access follows.
  3. ChangedA plan, a price or a payment method changed on the gateway’s side; the service updates its own record to match.
  4. Payment failedAn invoice on the subscription failed to charge on the gateway’s side; the service records the failure.
  5. SuspendedThe gateway has suspended the subscription; the service mirrors the state, and access follows it.
  6. ExpiredThe subscription has run its course and the gateway has closed it; the service marks the record accordingly.
  7. Invoice paidA marketplace invoice has been paid; the service records the payment against the subaccount it belongs to.
  8. Invoice releasedThe marketplace has released the funds from a paid invoice to the subaccount holder.
  9. Invoice refundedA marketplace invoice has been refunded; the service reverses what it recorded against the subaccount.

What it does

  • Subscribing and renewing, with a suspend path when a payment lapses.
  • Vouchers and plan extensions, adjusting a subscription without cancelling and re-creating it.
  • Payment links generated on demand, for a charge outside the regular checkout flow.
  • Marketplace subaccounts, with their own invoice-paid, released and refunded events.

Engineering decisions

  • One repository, five release trains

    The payment service shares its repository with the core, auth, foods and jobs services, and each of the five ships on its own release train: its own pipeline file, its own branch trigger, and a path filter naming the other four services’ directories as reasons not to build. A payment hotfix does not redeploy auth. A monorepo without a shared deploy.

  • A package per gateway, not one interface for all

    Iugu and TSPay do not share an interface — they have nothing in common to share. Each sits in its own crosscutting package with its own vocabulary, its own webhook endpoint and its own command factory, and a handler asks for the gateway it actually needs by name. Which gateway a subscription belongs to is a value in the domain, not a detail hidden from it, and that is what made moving subscribers between the two possible one at a time: a TSPay webhook can still reach into Iugu to suspend the old subscription of a nutritionist who has just been moved across.

  • The webhook tree is the state machine

    There is one handler per gateway event, named for the event itself — subscription created, invoice paid, invoice refunded — rather than one endpoint switching on a payload field. The directory structure is the lifecycle, readable without opening a single file.

  • A checkout that is not the app

    The purchase funnel ships as its own client — its own Vue app, its own Cypress suite reporting through Allure — separately from the rest of the product, on its own cadence.