DuroService, with the same x-api-key and idempotency discipline as the subscription calls.
The settlement balance
Everything a creator can withdraw or spend is derived from the append-onlyledger_entries table, not a mutable balance column. PayoutsService.balanceFor() computes the breakdown with database aggregates (src/payouts/payouts.service.ts):
The rules encoded here:
- T+1 settlement. A charge counts toward
availableonly once its day has passed (ledger.createdAt < date_trunc('day', now())). Same-day charges land inpendingand become available the next day. This models a settlement delay without a scheduler. - 10% platform fee.
netToCreator = gross - platformFee, whereplatformFee = gross * PLATFORM_FEE_BPS / 10000andPLATFORM_FEE_BPS = 1000(src/common/money.ts). Payouts and bills draw from the net settled amount. - Reservations. In-flight payouts (
pending/processing/paid) and successful/processing bill purchases are subtracted, so a creator cannot double-spend a balance that is already committed.
available figure caps both a payout request and a VAS purchase.
Saving a payout bank account (OTP-guarded)
Before withdrawing, a creator saves a destination account. Adding or removing an account is protected by an email OTP, so a hijacked session cannot silently redirect payouts. Flow inPayoutsController + PayoutsService:
- Bank list + name resolution come from Duro.
listBankscallsGET /payouts/banks(falling back to a bundled list of ~50 Nigerian banks if Duro returns nothing), andresolveAccountcallsPOST /payouts/banks/resolveto confirm the account holder’s name before it is saved. - OTP is a 6-digit code emailed to the creator, stored in
two_factor_challenges(purposebank), with a 10-minute TTL and a 5-attempt cap. BothaddAccountanddeleteAccountrequire a valid code. - Removing the default account promotes the next most recent one.
Requesting a payout
PayoutsService.request() validates the amount against available, confirms the destination is a saved account, then calls Duro:
The payouts row stores Duro’s returned status and duroPayoutId. Final status arrives asynchronously: Duro’s payout_completed / payout_failed webhooks call markStatusByDuroPayoutId, flipping the row to paid or failed and stamping processedAt. So Quill records the intent immediately and reconciles the outcome from Duro, exactly as it does for subscriptions. Endpoints are guarded @Roles('creator').
VAS: spending the balance on bills
Quill lets a creator spend settled earnings on Nigerian bills, all vended through Duro’s VAS API.VasController exposes five purchase categories plus catalog and lookup endpoints (src/vas/vas.controller.ts), all @Roles('creator').
Catalog data (data plans per telco, betting providers, cable packages, DISCOs, meter types) is served from a static
VasCatalog (src/vas/vas-catalog.ts); the supported networks are MTN, AIRTEL, GLO, 9MOBILE, and DISCOs cover the ten major distributors (Ikeja, Eko, Abuja, Ibadan, PHED, and others). Customer-id and meter lookups call DuroService.vasLookup (POST /vas/{kind}/lookup).
The purchase lifecycle
VasService.purchase() records a bill_transactions row, calls Duro, and reconciles the result (src/vas/vas.service.ts):
- The purchase is charged against the same
availablesettlement balance as payouts, so bill spend and withdrawals draw from one pot. - In
mockmode,vasPurchasereturns deterministic results, including a fabricated prepaid-electricity token forPREPAIDmeters, so the flow demos end to end offline. - Every purchase is idempotency-keyed off the Quill reference (
vas_{category}_{reference}), so a retried vend does not double-charge on Duro’s side. - History is queryable and searchable via
GET /vas/history(paginated, filter by category, search recipient/provider/reference).
One processor, four money flows
Stepping back, Quill routes all four of its money movements through Duro:Subscriptions (in)
/checkout/sessions + /subscriptions/* + webhooks. Recurring reader billing and recovery.Payouts (out)
/payouts + /payouts/banks + /payouts/banks/resolve. Creator withdrawals to a bank account.Bills (out)
/vas/{category} + /vas/{kind}/lookup. Airtime, data, betting, cable TV, electricity.Reconciliation
Duro webhooks (
payout_*, subscription_*) settle the asynchronous outcome of every flow back into Quill’s tables.