External Integrations¶
Every integration sits behind an interface owned by exactly one module. Provider SDK types, webhook payloads, and credentials never leak outside the owning module. All outbound calls are made from queued jobs unless the user is actively waiting on the result (e.g., creating a payment session).
| Integration | Owner module | Phase | Sandbox |
|---|---|---|---|
| MyFatoorah / Tap (PSP) | Financial | MVP | Yes — both provide full sandboxes incl. KNET test cards |
| Firebase Cloud Messaging | Communication | MVP | Yes — separate Firebase project per environment |
| Mailgun / SES (email) | Communication | MVP | Yes — Mailgun sandbox domain / SES sandbox mode |
| Google Maps / Places | Vendor, Event | MVP | Yes — API key with quota; billable above free tier |
| Google / Apple OAuth | Identity | MVP | Yes — test clients per environment |
| S3-compatible storage + CDN | Platform | MVP | Yes — MinIO locally, staging bucket |
| Meilisearch | Platform | MVP | Yes — local/staging instances, disposable indexes |
| WhatsApp Business API | Communication | Scale | Meta sandbox numbers |
| Google Calendar sync | Vendor | Scale | Yes — OAuth test project |
Payment Service Provider¶
Purpose. All money movement: vendor subscription charges, booking deposit payments, refunds. Methods: KNET (primary), Visa/Mastercard, Apple Pay, Google Pay. Currency at launch: KWD, 3 decimal places — amounts are always sent to the PSP in KWD with exactly 3 dp (e.g., 150.000), matching our DECIMAL(12,3) storage (see tables.md).
Provider. MyFatoorah or Tap Payments (final selection during integration spike). Both support: hosted payment page (redirect) and embedded iframe, KNET + cards + wallets, webhooks, refunds, and sandbox environments with KNET test credentials.
Phase. MVP.
Flow¶
sequenceDiagram
participant C as Client (web/app)
participant API as Laravel API (Financial)
participant PSP as MyFatoorah / Tap
C->>API: POST /api/v1/payments (Idempotency-Key)
API->>API: create Payment row (status=pending)
API->>PSP: create payment session (amount KWD 3dp, callback URLs)
PSP-->>API: session id + payment URL
API-->>C: payment_url (hosted page / iframe src)
C->>PSP: completes KNET / card / wallet flow
PSP-->>C: redirect to success/failure callback URL
PSP->>API: POST /api/v1/webhooks/psp (signed)
API->>API: verify signature, verify amount & currency,<br/>idempotent transition pending → succeeded/failed
API->>API: emit PaymentSucceeded / PaymentFailed
Never trust the redirect. The client redirect updates UI only; the payment status transitions exclusively on (a) the verified webhook, or (b) a server-to-server status query (GetPaymentStatus) used by the reconciliation job.
Webhook verification¶
- Verify the provider signature header (HMAC-SHA256 over raw body with the webhook secret — exact header name per provider adapter).
- Reject if timestamp skew > 5 minutes (replay protection) where the provider supplies one.
- Look up our
paymentsrow by our reference (we always passpayment.public_idas the merchant reference); reject unknown references. - Verify
amountandcurrencymatch the stored payment. Mismatch → markflagged, alert ops, do not confirm. - Process idempotently: a webhook for an already-final payment is acknowledged with 200 and ignored.
- Always return 200 fast; heavy work (booking confirmation, notifications) is dispatched to the
paymentsqueue.
Refunds¶
- Initiated by admin (MVP) via
POST /api/v1/admin/refunds; customer-requested refunds create arefund_requestedbooking state first (see Booking SRS). - Full or partial (KWD 3dp). Refund row references the original payment; provider refund ID stored on completion.
- Refund status also confirmed by webhook/reconciliation, never assumed from the API call's synchronous response.
Failure & retry strategy¶
| Failure | Handling |
|---|---|
| Session creation fails (5xx/timeout) | Retry ×2 with backoff in-request (total < 8 s), then return PAYMENT_PROVIDER_UNAVAILABLE; payment row marked failed |
| Webhook never arrives | Reconciliation job: every 15 min query status of pending payments older than 10 min; expire after 24 h |
| Duplicate webhook | Idempotent no-op (payment already final) |
| Refund API failure | Queued job retries (backoff up to 12 h), then dead-letter + ops alert; refund stays processing |
Abstraction interface¶
interface PaymentProviderInterface
{
/** Create a hosted-page session. Amount is minor-unit-safe Money (KWD, 3dp). */
public function createPaymentSession(PaymentIntentData $intent): PaymentSession; // url, provider_ref
public function getPaymentStatus(string $providerRef): ProviderPaymentStatus;
public function refund(string $providerRef, Money $amount, string $idempotencyKey): ProviderRefundResult;
/** Verify signature and parse the raw webhook into a provider-neutral event. */
public function parseWebhook(Request $request): PspWebhookEvent; // throws InvalidSignatureException
}
Adapters: MyFatoorahProvider, TapProvider. Selected per country via config (payments.providers.KW = myfatoorah).
Firebase Cloud Messaging (Push)¶
Purpose. Push notifications to Flutter apps (and web push later): new quotation received, RFQ received (vendor), message received, booking/payment status changes, subscription expiry warnings.
Phase. MVP.
Design.
- Device tokens registered via
POST /api/v1/notifications/devices(token, platform, locale); stored per user, pruned on FCMUNREGISTEREDerrors. - One notification engine in the Communication module: a
Notificationis persisted first (in-app inbox is source of truth), then fan-out jobs deliver via enabled channels (push, email) respecting user channel preferences. - FCM HTTP v1 API with service-account auth; localized title/body chosen by the recipient's locale (
ar/en) at send time; deep-link payload (type,entity_id) for client routing.
Failure & retry. Queued (notifications queue), 3 retries with backoff; UNREGISTERED/INVALID_ARGUMENT deletes the token; FCM outage degrades to in-app + email only — push is never load-bearing for correctness.
Sandbox. Separate Firebase projects for local/staging/production; staging apps point at the staging project.
Interface sketch.
interface PushChannelInterface
{
public function send(PushMessage $message, DeviceTokenCollection $tokens): PushResult; // per-token outcomes
}
Email Provider (Mailgun / SES)¶
Purpose. Transactional email: OTP codes, email verification, welcome, quotation received, booking confirmations, payment receipts, subscription invoices/renewal reminders, admin alerts.
Phase. MVP. (Marketing/digest campaigns: Scale, likely a separate tool.)
Design.
- Laravel mail with the Mailgun (or SES) transport; provider swap is a config change.
- Bilingual templates; direction (RTL/LTR) and language chosen per recipient locale. From-domain
mail.yam3at.comwith SPF, DKIM, DMARC configured before launch. - OTP emails bypass user notification preferences (security-critical); everything else respects preferences.
Failure & retry. Queued, 5 retries over ~2 h; bounce/complaint webhooks mark addresses suppressed; OTP email failure surfaces to the client as OTP_DELIVERY_FAILED so the user can retry with SMS/phone (per Auth SRS).
Sandbox. Mailgun sandbox domain / SES sandbox (verified recipients only); local dev uses Mailpit — no real mail leaves a developer machine.
Interface. Laravel's Mailer contract is sufficient; no custom abstraction beyond notification-channel classes.
Google Maps / Places¶
Purpose. (a) Vendor onboarding: place autocomplete + map pin for branch locations; (b) listing pages: static map/embed; (c) event location entry; (d) distance sorting in search (lat/lng fed to Meilisearch _geo).
Phase. MVP (autocomplete + geocoding + static display). Distance-based "near me" search: MVP if timeline allows, else first Scale iteration.
Design.
- Client-side: Places Autocomplete (session tokens to control billing) in web and Flutter; the client submits
place_id+ display text. - Server-side: geocoding job resolves/validates
place_id→ lat/lng, formatted address (ar + en vialanguageparam), stored onvendors/events. Server never trusts client-provided coordinates alone. - API keys: separate browser key (HTTP-referrer restricted), Android/iOS keys (app-restricted), and server key (IP restricted). Daily quota alarms.
Failure & retry. Geocoding job retries ×3; on persistent failure the record keeps the text address and is flagged for admin review — maps degrade to text, nothing blocks onboarding. Autocomplete outage degrades to free-text address entry.
Sandbox. No true sandbox; a dev API key with low quota caps + billing alerts.
Interface sketch.
interface GeocodingServiceInterface
{
public function resolvePlace(string $placeId, string $lang = 'ar'): ResolvedPlace; // lat, lng, formatted ar/en, city match
}
Google / Apple OAuth (Social Sign-in)¶
Purpose. Customer registration/login with Google and Apple (Apple mandatory on iOS when social login is offered). Vendors use email/phone + business verification instead.
Phase. MVP.
Design.
- Token-verification pattern, not server-side redirect: clients obtain an ID token natively (Google Identity Services / Sign in with Apple), then call
POST /api/v1/auth/socialwith{provider, id_token}. - Server verifies the token: signature against provider JWKS (cached),
aud= our client IDs,iss,exp. Extract stable subject (sub), email, email-verified flag. - Account linking: match on provider
subfirst, then verified email; new users are created withprovider+provider_subrecorded. Apple's private-relay emails are supported (email may be relay or absent on re-auth —subis the durable key).
Failure & retry. JWKS fetch failures fall back to cached keys (24 h); invalid tokens → AUTH_INVALID_SOCIAL_TOKEN. No retries — auth is synchronous.
Sandbox. Separate OAuth clients per environment; Apple requires the paid developer account for Sign in with Apple even in staging.
S3-Compatible Storage & CDN¶
Purpose. All binary assets: vendor gallery images and videos, package images, commercial license documents (private), RFQ/quotation/message attachments (private), review photos, CMS assets.
Phase. MVP.
Design.
- Two buckets:
yam3at-public(listing media, served via CDN with long cache TTLs and image-resizing at the edge or via pre-generated variants) andyam3at-private(licenses, attachments — no public ACLs, ever). - Uploads are direct-to-S3 via pre-signed PUT URLs issued by
POST /api/v1/media(declares filename, mime, size; server validates policy per context and returns the signed URL + amediarow inpendingstate). Client uploads, then confirms; a queued job validates the object (real content-type sniffing, size, image sanity), generates thumbnails, strips EXIF, and flips status toready. See Security — file uploads. - Private files are served only via short-lived (≤ 5 min) pre-signed GET URLs after an authorization check (e.g., only conversation participants can fetch a message attachment).
Failure & retry. Upload confirmation job retries ×3; objects never confirmed within 24 h are garbage-collected. CDN origin failure falls back to direct signed S3 URLs (feature flag).
Sandbox. MinIO in Docker Compose locally; dedicated staging bucket with lifecycle rules that purge after 30 days.
Interface. Laravel Storage (S3 driver) + an internal MediaService (create, confirm, attach(mediaId, entity), signedUrl).
Meilisearch Indexing Pipeline¶
Purpose. Instant, typo-tolerant, faceted, bilingual search over vendors, services, and packages; geo sorting; featured boosting.
Phase. MVP.
Design.
- Indexes:
vendors,services,packages(per-country suffix when expansion arrives:vendors_kw). - Documents are denormalized projections, not table dumps: a vendor document carries
name_ar/en, category ids + names, city,_geo, rating aggregate, review count, min price, plan tier (for featured boost),is_approved. - Sync via Laravel Scout queued (
searchqueue): model observers dispatch index/update/delete jobs; rating recomputation and approval events also trigger re-index via domain-event listeners (VendorApproved,ReviewPublished). - Only
approved+ active-subscription-visible entities are indexed; suspension/rejection triggers immediate delete-from-index. - Relevance config in code (committed settings files applied by a deploy task): searchable attributes ordered (
name_ar,name_en,category_names,description_*), filterable (category_ids,city_id,price_min,rating,_geo), sortable (rating,price_min,created_at), synonyms list for Arabic orthographic variants, ranking rules withfeatured_rankbeforetypo.
Failure & retry. Index jobs retry ×5 with backoff; Meilisearch down → search endpoint returns SEARCH_UNAVAILABLE with a degraded MySQL LIKE fallback for basic category browsing (flagged, temporary). Full rebuild at any time: php artisan scout:import (index is disposable, MySQL is the source of truth).
Sandbox. Local/staging instances; indexes rebuilt by seeders.
Interface sketch.
interface SearchIndexerInterface
{
public function upsert(SearchableDocument $doc): void;
public function remove(string $index, string $documentId): void;
public function search(SearchQuery $query): SearchResults; // used by the Marketplace search facade
}
Related pages: Architecture Overview · ADRs · Security · API Conventions · Endpoints