Payments & Payouts
What it is
Two directions of money movement: collection (getting money from guests/hosts into the platform, via Paystack checkout or direct bank transfer) and payout (getting a host's earned sales balance out to their PartyBank, and from there to vendors or their own bank account). Both are covered here. Flutterwave and PiggyVest Business (PVB) wallet infrastructure underpin parts of this but aren't covered in depth yet.
Core concepts
- PaymentTrackingServiceId identifies what a payment is for (
rsvp,guest-purchase,gift-purchase,cash-gift,gift-registry,gift-box,point-of-sale,third-party-purchase). Fee rules, payout destination routing, and follow-up validation (like the 18+ check) all branch on this. - Reference — the same
RSVP-{eventId}-{code}/GUESTPURCHASE-{eventId}-{code}scheme used by RSVP is what payment tracking keys off; a payment and its RSVP are linked by this string. - Payout ≠ Payment: a payment is money coming in from a guest; a payout is a separate tracked record (the
payoutstable) created once a Paystack payment settles, representing what the host is owed net of fees. Payouts have their own lifecycle (pending → settled), independent of the originating payment tracking record. - PartyBank is the event's own internal wallet — both collection (bank-transfer proceeds land there indirectly) and payout (Paystack payouts land there directly) ultimately route through it. Vendor payments, broadcast charges, etc. all debit from the same PartyBank wallet.
How it works
Collection: public bank-transfer flow
POST /payments/public/payment-options— given an amount + serviceId (+ optional meta carryingeventId), returns computed fees for both Paystack and bank transfer so the client can show the user their real total before committing to a method.POST /payments/public/generate-bank-details— once the user picks bank transfer, generates a dedicated virtual account (reference, account number, bank name, expiry). Cached in Redis forACCOUNTNUMBER_EXPIRY_SECONDS;refresh: truebypasses the cache.- User transfers money to those bank details, referencing the given
reference. - A webhook confirms the transfer landed; that's what flips the payment to
CONFIRMED. POST /payments/public/confirm-bank-transfer— the client polls this. This endpoint does not simulate a payment or trust client-provided amounts — it only succeeds once the webhook has confirmed a real transfer.POST /payments/public/verify-bank-transfer— a lighter polling sibling that returns structured status instead of throwing.GET /payments/public/payment-status/{reference}— full tracking detail, including a per-attempt payment history for guests who send money in more than one transfer.
Collection: Paystack checkout flow
POST /paystack/initialize— creates a Paystack checkout session; body (email, amount, reference, callback_url, metadata) is passed through to Paystack directly. Theamountsent here already includes any platform charge (see below) — the customer sees and pays the marked-up total.- Customer completes payment on Paystack's hosted page; Paystack redirects to
GET /paystack/close(the callback) with the transaction reference. GET /paystack/verify/:reference— server-side verification against Paystack's API (don't trust the client-side redirect alone).- Paystack also sends a
POST /webhook/paystack(charge.success) webhook, HMAC-SHA512 signed. This — not the client-side callback — is authoritative and is what triggersrecordPayout()inPaymentsService.
Payout: from Paystack settlement to a host's PartyBank
This is the mechanism behind the host-facing "Pending Sales" / "Available to Withdraw" split:
- Every successful Paystack webhook creates a
payoutsrecord viarecordPayout(), computing:- Platform charge applied (customer paid more than the expected amount — the markup from step 1 of checkout): merchant gets the full
expectedAmount; the platform keeps the difference between what was paid and what was expected. - No platform charge (customer paid exactly the expected amount): merchant gets
amountPaid - paystackFees; the platform keeps nothing extra beyond passing through Paystack's own fee. - Platform charge percentage is Firebase-configured, either globally (
appConfig.paystackPaymentPercentageCharge) or per-event override (appConfig.eventPaystackCharges.{eventId}).
- Platform charge applied (customer paid more than the expected amount — the markup from step 1 of checkout): merchant gets the full
GET /paystack/payout-info/:eventId— forguest-purchaseandrsvpservice payouts only, returns:pendingSales— sum of all pending payouts'netAmount(everything owed, regardless of age).availableAmount— sum of only payouts whosepaidAtis 24+ hours old. The 24-hour hold exists to absorb chargebacks/reversals before money leaves the platform.canPayout— whether there's anything currently eligible.
POST /paystack/trigger-payout/:eventId— moves the eligible (24h+) balance into the event's PartyBank. Requires event-planner PartyBank access and transaction-PIN verification (encryptedPinMiddleware) — payout is treated as a security-sensitive action, not a read.- From PartyBank, a host can move funds onward — to vendors (see the Vendors guide for how vendor payments feed the weighted-rating system), to broadcast charges, or out to their own bank account.
Key business rules & edge cases
- Never trust a client-reported "I paid" — every confirmation path (bank transfer and Paystack alike) ultimately depends on a provider webhook, never on what the frontend claims.
- Gift purchases and other service types settle differently from RSVP/guest-purchase —
payout-info/trigger-payoutexplicitly filter toserviceId IN ('guest-purchase', 'rsvp'); gift-related payouts go through a different path (per the archived payout docs, "those go through a different payout system" — worth confirming precisely if you're building against gift payouts specifically). - The 24-hour eligibility window is calculated per payout record, not per event — an event with a steady trickle of sales has some money always available and some always pending; it's not an all-or-nothing gate.
- Timeout errors from bank-details generation are surfaced as
408, not500, specifically so clients know it's safe to retry. - Bank details are cached per-reference, not per-user —
refresh: trueclears and regenerates for that specific reference only.
Related endpoints
Bank transfer: POST /v1/payments/public/payment-options, POST /v1/payments/public/generate-bank-details, POST /v1/payments/public/confirm-bank-transfer, POST /v1/payments/public/verify-bank-transfer, GET /v1/payments/public/payment-status/{reference} — all in the generated OpenAPI spec.
Paystack + payout: POST /v1/paystack/initialize, GET /v1/paystack/verify/{reference}, GET /v1/paystack/close, GET /v1/paystack/payout-info/{eventId}, POST /v1/paystack/trigger-payout/{eventId} — not yet in the generated OpenAPI spec.
Related code
src/modules/payments/payments.routes.ts,payments.controller.ts,payments.service.ts(recordPayout),payments.openapi.tssrc/modules/paystack/paystack.routes.ts,paystack.controller.ts,paystack.service.ts(getPayoutInfo)src/modules/payments/payments-reconcile.controller.ts,payments-cleanup.controller.ts— background reconciliation/cleanup cronssrc/models/payout.model.ts,src/repositories/payout.repository.tssrc/shared/utils/feature-flags.util.ts—getPaystackPaymentPercentageCharge,getEventPaystackPercentageChargesrc/shared/validations/partybank.validation.ts— the validatorjs rules still governing bank-transfer runtime validation