SRS — Payments
Module owner: Domain 6 — Financial. All money on Yam3at flows through one payment service built on a provider abstraction over MyFatoorah and Tap Payments — the platform code never depends on either PSP directly. MVP money flows: vendor subscription payments, customer booking deposits, and — captured from each deposit — platform commission with basic vendor settlement (the vendor's share is paid out). Automated escrow with milestone release stays Vision. See the commission model.
Related: Booking · Vendor Onboarding & Subscriptions · Admin & CMS
Purpose
Collect subscription revenue and booking deposits safely via Kuwait-first payment methods (KNET above all), with webhook-driven truth, idempotency everywhere, controlled refunds, bilingual invoices, and books that reconcile to the fils.
Actors
| Actor |
Role |
| Customer |
Pays booking deposits; requests refunds (via booking flow) |
| Vendor |
Pays subscription (initial + renewals); receives invoices; receives booking settlements (deposit minus platform commission) |
| Admin (finance) |
Approves refunds, runs reconciliation and settlement reports, views financial reports |
| PSP (MyFatoorah/Tap) |
Hosts payment pages, sends webhooks, executes refunds and vendor payouts |
| System |
Creates payment intents, processes webhooks, captures platform commission from deposits and settles the vendor share, auto-refunds policy cases, generates invoices and settlement statements |
User stories
- As a customer, I want to pay my deposit with KNET in a page that feels familiar and trustworthy, so that I complete the payment without hesitation.
- As a vendor, I want my subscription to renew automatically with a clear invoice, so that my listing never lapses silently.
- As a finance admin, I want every refund above policy to require my approval, so that no money leaves the platform uncontrolled.
- As a finance admin, I want a daily reconciliation report against the PSP, so that mismatches surface within 24 hours, not at audit time.
- As a customer, I want a PDF receipt in Arabic or English, so that I have proof of payment for my records.
- As a vendor, I want the platform's commission deducted transparently and my share of the deposit paid out on a clear schedule with a statement, so that I trust how Yam3at handles my money.
Functional requirements
Provider abstraction — MVP
| ID |
Requirement |
Phase |
| SRS-PAY-001 |
A PaymentProvider interface (Laravel service contract) with drivers myfatoorah and tap: createCheckout(intent), getPaymentStatus(providerRef), refund(payment, amount), verifyWebhookSignature(request). Active driver is a config/feature flag per country; switching drivers requires zero changes outside the driver. |
MVP |
| SRS-PAY-002 |
Checkout is PSP-hosted (redirect on web, in-app WebView/SDK on Flutter) — Yam3at never touches, transmits or stores PAN/CVV, keeping the platform out of PCI DSS scope beyond SAQ-A. |
MVP |
| SRS-PAY-003 |
Supported methods at launch, surfaced in this order in Kuwait: KNET, Visa/Mastercard, Apple Pay, Google Pay. Method availability is provider- and country-driven config. |
MVP |
Payment intents & flows — MVP
| ID |
Requirement |
Phase |
| SRS-PAY-010 |
Every charge starts as a payment intent: purpose (booking_deposit | subscription | subscription_renewal), amount (KWD, decimal(12,3)), payable_type/payable_id (booking or subscription), customer/vendor id, idempotency_key. Intents expire after 60 minutes unpaid. |
MVP |
| SRS-PAY-011 |
Booking deposit flow: booking hands amount + reference to Payments (SRS-BKG-003); customer completes on the PSP page; the webhook — not the browser redirect — is the source of truth for success. |
MVP |
| SRS-PAY-012 |
Subscription flow: initial plan purchase and renewals. Auto-renewal uses PSP tokenization/recurring where supported (cards); KNET does not support recurring — KNET subscribers get renewal reminders at D−7, D−3, D−1 and a 3-day grace period before downgrade to Free (vendor module). |
MVP |
| SRS-PAY-013 |
Failed payment: customer/vendor may retry; each retry creates a new intent referencing the same payable; max 5 attempts per payable per hour (velocity guard). |
MVP |
States, webhooks, idempotency — MVP
| ID |
Requirement |
Phase |
| SRS-PAY-020 |
Payment states: initiated (intent created) → pending (checkout started/awaiting PSP) → paid | failed; paid → refunded | partially_refunded. No other transitions permitted; illegal transitions throw and alert. |
MVP |
| SRS-PAY-030 |
Webhooks: endpoint per provider, signature verified before processing (reject 401 otherwise). Processing is queued (Redis/Horizon), and idempotent: provider event ID stored unique — duplicates ack 200 and no-op. Out-of-order events resolve by re-querying getPaymentStatus against the PSP rather than trusting event order. |
MVP |
| SRS-PAY-031 |
Webhook success handler: marks payment paid, records paid_at, method, provider fee (if reported), then emits PaymentSucceeded domain event — Booking/Subscription modules react to the event; Payments never calls them directly. |
MVP |
| SRS-PAY-032 |
Safety-net poller: pending payments older than 15 minutes are re-checked against the PSP every 15 minutes for up to 24 h, then marked failed if unresolved. Covers lost webhooks. |
MVP |
| SRS-PAY-033 |
Client Idempotency-Key header honored on all money-moving POSTs: same key + same body → replay stored response; same key + different body → 422. Keys retained 48 h. |
MVP |
Refunds — MVP
| ID |
Requirement |
Phase |
| SRS-PAY-040 |
Refund workflow: refund request (from booking cancellation/dispute) creates a refund record requested → finance-admin approval → approved → executed via PSP → processed (webhook/status-confirmed) or failed (alert + retry queue). Denials record reason. Partial refunds supported to 3 dp; Σ(refunds) ≤ original amount, enforced. |
MVP |
| SRS-PAY-041 |
System-initiated refunds skip approval in exactly three whitelisted cases: deposit-timeout after auto-cancel, vendor no-confirm/decline, customer cancellation inside the 100% window (SRS-BKG-005/010/011). All still create refund records and audit entries. |
MVP |
| SRS-PAY-042 |
Payment status becomes refunded when Σ(processed refunds) = amount, else partially_refunded. Customer notified per refund with expected bank timelines (KNET 3–7 business days, cards 5–14). |
MVP |
Invoices, receipts, KWD, reconciliation — MVP
| ID |
Requirement |
Phase |
| SRS-PAY-050 |
Every paid payment generates an invoice/receipt PDF, bilingual (single document, AR RTL + EN): sequential number INV-{YYYY}-{000001} (gapless per year), Yam3at legal entity details, payer name, purpose, line detail, amount in KWD with 3 dp and amount-in-words (AR + EN), method, date, PSP reference. Stored on S3, immutable; refunds generate credit notes CRN-{YYYY}-{000001} referencing the invoice. |
MVP |
| SRS-PAY-051 |
KWD handling: all monetary values DECIMAL(12,3); APIs exchange amounts as strings ("150.500") to avoid float drift; the PSP driver converts to provider format (MyFatoorah/Tap accept KWD 3 dp natively); rounding half-up at final computation only; UI always renders 3 dp (e.g. د.ك 150.500). A currency table (code, decimals, symbol AR/EN) is in place day one for KSA (SAR, 2 dp) and UAE (AED, 2 dp) expansion. |
MVP |
| SRS-PAY-052 |
Reconciliation report: nightly job pulls the PSP settlement/transaction feed and matches against local paid/processed records by provider reference. Output per day: matched count/amount, unmatched-local, unmatched-provider, fee totals, refund totals. Mismatches open a reconciliation exception for finance admin; the report is exportable CSV/XLSX from the admin portal. |
MVP |
Commission & vendor settlement — MVP
Runs alongside subscriptions and deposits from launch. Commission and settlement are internal allocations of the deposit already collected through the PSP — not a separate customer charge. See the commission model.
| ID |
Requirement |
Phase |
| SRS-PAY-053 |
Commission capture: on booking confirmation (SRS-BKG-007) the platform records a commission of 5% of the booking total_amount (rate configurable per the Confirmed Decisions) against the paid deposit. Because the deposit (default 20%) always exceeds the commission, the full commission is captured from the deposit upfront — no separate vendor charge. commission_amount = round(total_amount × rate, 3) KWD, half-up. |
MVP |
| SRS-PAY-054 |
Basic vendor settlement: after commission capture, and once the booking is outside the refund-request window, the system computes vendor share = deposit_amount − commission_amount and schedules a payout to the vendor's registered payout account (IBAN). Settlement of a disputed booking is frozen until Operations resolves it. |
MVP |
| SRS-PAY-055 |
Settlement records & statements: every commission and payout is an append-only ledger entry (booking ref, deposit, commission, vendor share, status pending | settled | frozen | reversed). Vendors get a downloadable settlement statement (PDF, bilingual AR RTL + EN, KWD 3 dp); finance admins get a settlement report reconciled against PSP payouts. Refunds reverse the related commission/settlement via new ledger entries, never edits. |
MVP |
Scale / Vision
| ID |
Requirement |
Phase |
| SRS-PAY-060 |
Wallet: customer credit balance (refund-to-wallet option with instant availability, promo credits); wallet applicable to deposits; full ledger with per-entry provenance. |
Scale |
| SRS-PAY-061 |
Promo codes applied at checkout (percentage/fixed, usage caps) — owned by Growth, redeemed here. |
Scale |
| SRS-PAY-062 |
Automated escrow with milestone release: full-amount (100%) collection held in a platform escrow balance and released to the vendor per timeline milestones, with automated payout batching. Basic commission capture and vendor settlement already ship in the MVP (SRS-PAY-053/054/055); this item is the heavier escrow machinery only. |
Vision |
Business rules
- Webhooks are the single source of payment truth; UI redirects only drive optimistic display.
- Payment and refund records are append-only — corrections happen via new records (refund/credit note), never edits.
- One approval per refund; approver must hold the finance role and cannot approve a refund they requested (segregation of duties).
- Provider fees are recorded per transaction when reported; reconciliation treats fee-inclusive settlement correctly.
- Amounts are always positive; direction is expressed by record type (payment vs refund).
- Invoice numbering is per legal entity per year, gapless, assigned inside the
paid transaction (DB sequence with lock).
- No card data, PSP secrets in code, or logging of full webhook payloads containing PII — payloads stored redacted.
- Platform commission is captured only from a deposit already collected on-platform and never exceeds the deposit; the vendor's settled share is
deposit − commission. Commission and settlement entries are append-only; refunds reverse them via new entries.
Validation rules
| Field |
Rule |
amount |
string decimal, regex ^\d{1,9}\.\d{3}$ for KWD, > 0, ≤ payable's due amount |
purpose |
enum: booking_deposit, subscription, subscription_renewal |
Idempotency-Key |
UUID v4, required on POST /payments and /refunds |
| Webhook |
valid provider signature; known event type; provider ref resolvable to a local payment |
Refund amount |
0 < amount ≤ (original − Σ processed refunds), 3 dp |
| Refund reason |
enum (policy_cancellation, vendor_fault, dispute_resolution, duplicate_charge, goodwill) + note ≤ 500 chars, note required for goodwill |
| Invoice locale |
ar, en presentation preference; document itself always bilingual |
Commission rate |
configurable percentage, default 5%; commission_amount = round(total × rate, 3) KWD, > 0 and ≤ deposit_amount |
| Vendor share |
= deposit_amount − commission_amount, ≥ 0, 3 dp |
Permissions matrix
| Action |
Customer |
Vendor |
Admin support |
Admin ops |
Admin finance |
Admin super |
| Create deposit intent / pay |
✔ (own booking) |
✘ |
✘ |
✘ |
✘ |
✘ |
| Pay subscription |
✘ |
✔ (own) |
✘ |
✘ |
✘ |
✘ |
| View own payments/invoices |
✔ |
✔ |
✘ |
✘ |
✘ |
✘ |
| View all payments |
✘ |
✘ |
✔ read |
✔ read |
✔ |
✔ |
| Approve/deny refund |
✘ |
✘ |
✘ |
✘ |
✔ |
✔ |
| Execute manual refund (no booking flow) |
✘ |
✘ |
✘ |
✘ |
✔ (logged) |
✔ (logged) |
| Reconciliation & financial reports |
✘ |
✘ |
✘ |
✘ |
✔ |
✔ |
| View own settlements / statements |
✘ |
✔ (own) |
✘ |
✘ |
✔ |
✔ |
| Settlement reports |
✘ |
✘ |
✘ |
✘ |
✔ |
✔ |
| Switch PSP driver / method config |
✘ |
✘ |
✘ |
✘ |
✘ |
✔ |
State machine
stateDiagram-v2
[*] --> Initiated : intent created
Initiated --> Pending : checkout opened at PSP
Initiated --> Failed : intent expired (60 min)
Pending --> Paid : webhook success (verified)
Pending --> Failed : webhook failure / poller timeout 24h
Paid --> PartiallyRefunded : refund processed < full amount
Paid --> Refunded : refunds processed = full amount
PartiallyRefunded --> Refunded : remaining amount refunded
Failed --> [*]
Refunded --> [*]
Refund sub-lifecycle: requested → approved → processed | failed, or requested → denied (with approved → failed → retry loop, max 3 automatic retries then manual).
Edge cases
- Duplicate webhook delivery: unique provider-event-ID constraint no-ops the second; 200 returned so the PSP stops retrying.
- Webhook for an unknown reference: stored in a quarantine table, alerted, replayable after investigation.
- Customer pays twice (double-tab checkout): second success auto-refunds via
duplicate_charge system refund, both invoices + credit note issued.
- Refund exceeds PSP's refundable window (KNET refunds can require manual bank processing): refund record moves to
manual_processing with finance-admin task; customer messaging switches to "processed manually within 10 business days".
- PSP outage mid-checkout: intent stays
pending; poller resolves; user sees the verifying state (Booking error states).
- Amount mismatch (webhook amount ≠ intent amount): payment flagged
amount_mismatch, not marked paid, alert to finance — handles PSP-side currency/fee misconfig.
- Driver switch with in-flight
pending payments: old-driver webhooks continue to be accepted for 72 h post-switch; each payment stores its own provider value.
Empty / loading / error states
| Surface |
Empty |
Loading |
Error |
| Payment history (customer/vendor) |
"No payments yet" |
Skeleton rows |
Retry banner |
| Checkout hand-off |
— |
Spinner + "Redirecting to secure payment…" (PSP branding note for trust) |
"Could not start payment" + retry, intent reusable within expiry |
| Return/verify screen |
— |
Poll up to 60 s ("Confirming…") |
Fallback: "We'll notify you the moment it's confirmed" — never displays failure until PSP-confirmed failure |
| Refund status (customer) |
— |
— |
Timeline UI with current stage; failures shown as "processing is taking longer than usual" (internal alert fired) |
| Reconciliation (admin) |
"No exceptions for {date} — books match" |
Table skeleton |
Partial data warning + export disabled until complete |
Acceptance criteria
Data entities touched
payment_intents, payments, refunds, webhook_events (provider event ledger + quarantine), invoices, credit_notes, invoice_sequences, reconciliation_runs, reconciliation_exceptions, commissions, vendor_settlements, vendor_payout_accounts, settlement_statements, payment_methods_config, currencies; reads/writes coordinated by domain events with bookings, vendor_subscriptions; Scale adds wallets, wallet_entries.
POST /api/v1/payments/intents # body: purpose, payable, amount; Idempotency-Key required
GET /api/v1/payments/{id}
GET /api/v1/payments?payable_type=&payable_id=
GET /api/v1/payments/{id}/invoice # signed PDF URL
GET /api/v1/me/payments # role-scoped history
POST /api/v1/webhooks/payments/myfatoorah # public, signature-verified
POST /api/v1/webhooks/payments/tap # public, signature-verified
# refunds
POST /api/v1/refunds # created by booking/dispute flows
GET /api/v1/refunds/{id}
# commission & vendor settlement (MVP)
GET /api/v1/vendors/{vendorId}/settlements
GET /api/v1/vendors/{vendorId}/settlements/{id}/statement # signed PDF URL
# admin
GET /api/v1/admin/refunds?status=requested
POST /api/v1/admin/refunds/{id}/approve
POST /api/v1/admin/refunds/{id}/deny
GET /api/v1/admin/reports/reconciliation?date=YYYY-MM-DD
GET /api/v1/admin/reports/reconciliation/{runId}/export
GET /api/v1/admin/reports/settlements?date=YYYY-MM-DD