Skip to main content

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 payouts table) 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​

  1. POST /payments/public/payment-options — given an amount + serviceId (+ optional meta carrying eventId), returns computed fees for both Paystack and bank transfer so the client can show the user their real total before committing to a method.
  2. 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 for ACCOUNTNUMBER_EXPIRY_SECONDS; refresh: true bypasses the cache.
  3. User transfers money to those bank details, referencing the given reference.
  4. A webhook confirms the transfer landed; that's what flips the payment to CONFIRMED.
  5. 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.
  6. POST /payments/public/verify-bank-transfer — a lighter polling sibling that returns structured status instead of throwing.
  7. 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​

  1. POST /paystack/initialize — creates a Paystack checkout session; body (email, amount, reference, callback_url, metadata) is passed through to Paystack directly. The amount sent here already includes any platform charge (see below) — the customer sees and pays the marked-up total.
  2. Customer completes payment on Paystack's hosted page; Paystack redirects to GET /paystack/close (the callback) with the transaction reference.
  3. GET /paystack/verify/:reference — server-side verification against Paystack's API (don't trust the client-side redirect alone).
  4. Paystack also sends a POST /webhook/paystack (charge.success) webhook, HMAC-SHA512 signed. This — not the client-side callback — is authoritative and is what triggers recordPayout() in PaymentsService.

Payout: from Paystack settlement to a host's PartyBank​

This is the mechanism behind the host-facing "Pending Sales" / "Available to Withdraw" split:

  1. Every successful Paystack webhook creates a payouts record via recordPayout(), 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}).
  2. GET /paystack/payout-info/:eventId — for guest-purchase and rsvp service payouts only, returns:
    • pendingSales — sum of all pending payouts' netAmount (everything owed, regardless of age).
    • availableAmount — sum of only payouts whose paidAt is 24+ hours old. The 24-hour hold exists to absorb chargebacks/reversals before money leaves the platform.
    • canPayout — whether there's anything currently eligible.
  3. 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.
  4. 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-payout explicitly filter to serviceId 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, not 500, specifically so clients know it's safe to retry.
  • Bank details are cached per-reference, not per-user — refresh: true clears and regenerates for that specific reference only.

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.

  • src/modules/payments/payments.routes.ts, payments.controller.ts, payments.service.ts (recordPayout), payments.openapi.ts
  • src/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 crons
  • src/models/payout.model.ts, src/repositories/payout.repository.ts
  • src/shared/utils/feature-flags.util.ts — getPaystackPaymentPercentageCharge, getEventPaystackPercentageCharge
  • src/shared/validations/partybank.validation.ts — the validatorjs rules still governing bank-transfer runtime validation