Skip to content

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; paidrefunded | 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 requestedfinance-admin approvalapproved → 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

  1. Webhooks are the single source of payment truth; UI redirects only drive optimistic display.
  2. Payment and refund records are append-only — corrections happen via new records (refund/credit note), never edits.
  3. One approval per refund; approver must hold the finance role and cannot approve a refund they requested (segregation of duties).
  4. Provider fees are recorded per transaction when reported; reconciliation treats fee-inclusive settlement correctly.
  5. Amounts are always positive; direction is expressed by record type (payment vs refund).
  6. Invoice numbering is per legal entity per year, gapless, assigned inside the paid transaction (DB sequence with lock).
  7. No card data, PSP secrets in code, or logging of full webhook payloads containing PII — payloads stored redacted.
  8. 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

  • End-to-end sandbox deposit on both drivers (MyFatoorah and Tap) with each method (KNET, Visa/MC, Apple Pay, Google Pay where sandbox-supported) results in paid with correct KWD 3 dp amounts.
  • Replayed webhook (same event ID, 10×) produces exactly one state change and one PaymentSucceeded event.
  • Killing the webhook listener during a sandbox payment still yields paid within 30 minutes via the poller.
  • Refund of 16.667 KWD from a 33.333 deposit leaves the payment partially_refunded with 16.666 refundable remaining; over-refund attempts return 422.
  • Invoice PDF renders correctly in RTL Arabic and English in one document, with gapless sequential numbering under 50 concurrent payments (load test).
  • Nightly reconciliation over a seeded day with one injected mismatch produces exactly one exception with correct classification.
  • A finance admin cannot approve a refund they created; a support admin cannot see the refund queue at all.
  • A confirmed booking with total 100.000 KWD and a 20% deposit captures commission 5.000 KWD and schedules a vendor settlement of 15.000 KWD; a full deposit refund reverses both via ledger entries, netting to zero, and the vendor statement shows the reversal.
  • A booking inside the refund-request window keeps its settlement frozen until Operations resolves it.

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