1. Geidea Payments Integration — Payment Intent, POS, and Split-Payment Flow
Status: Implementation completed. Simulated E2E passed. Real Geidea sandbox validation pending.
Everything described in this chapter is implemented and covered by automated tests (unit-level and a simulated end-to-end flow — see §1.10). No call has yet been made against a real Geidea sandbox endpoint, and no real Geidea callback has been received or verified. Do not treat this integration as production-certified until every item in §1.11 and §1.12 is closed.
Who it's for: Backend Developers integrating a new payment provider · Flutter/POS Client Developers · QA Engineers validating a real Geidea sandbox · Technical Partners reviewing the integration before go-live.
| Field | Value |
|---|---|
| Provider | Geidea (KSA/GCC payment gateway) |
| Integration mode covered here | Mobile SDK (Create Session / Payment Intent flow) |
| Status | Implementation completed — Simulated E2E passed — Real sandbox validation pending |
| Last Updated | 2026-07-11 |
| Source packages | Shumoul.Payments.Geidea, Shumoul.Payments.Infrastructure, Shumoul.Payments.Domain, Shumoul.Payments.Contracts, Shumoul.Payments.Abstractions (repo Shumoul.Saas.Payments) |
| ERP integration point | Shumoul.Saas.Api — Shumoul.Infrastructure (EF migrations, seeders), Shumoul.Application (Order/Invoice payment linking) |
1.1 Overview
Why Geidea was integrated
Shumoul's POS runs on Flutter and needs to accept card payments (and, going forward, Apple Pay / Google Pay) at the point of sale without a merchant-owned card terminal being the only option. Geidea is the payment gateway chosen for this: it offers a Mobile SDK that a Flutter POS client embeds directly, so a cashier can take a card payment on the same device running the POS app.
Supported business scenario
The scenario this integration is built for is offline-capable POS with online payment authorization:
- Flutter POS creates local/offline orders. The POS app can build an Order, add items, and record payment rows entirely on-device, even with an intermittent connection — this is unchanged by this integration and is not something Geidea participates in.
- Payment authorization is online. The one part of the flow that must reach the network is the moment a card is charged: creating a Geidea session, running the SDK's card-entry UI, and getting Geidea's authorization back.
PosElectronicPaymentIntent(referred to throughout as "PaymentIntent") is the bridge between that one online moment and the rest of the (otherwise offline-first) POS/ERP data model. The POS asks the ERP backend to create a PaymentIntent, the backend talks to Geidea, and everything downstream (linking to the Order's payment rows, then to the Sales Invoice) happens against this PaymentIntent record, not against Geidea directly.
1.2 Architecture
Shumoul.Saas.Payments — a standalone module
The entire payments framework lives in its own repository and package family, independent of the ERP's own release cycle — the same standalone-module pattern used by every other framework on this platform (see Platform Architecture). Five NuGet packages make up the framework:
| Package | Role |
|---|---|
Shumoul.Payments.Domain | Entities + EF Core configurations for both the tenant ApplicationDbContext and the host-level SharedDbContext |
Shumoul.Payments.Contracts | DTOs shared across the framework — requests/results, never provider-specific |
Shumoul.Payments.Abstractions | Interfaces every provider and every cross-cutting service implements (IPaymentProvider, IPaymentCallbackVerifier, IPaymentSecretStore, …) |
Shumoul.Payments.Infrastructure | Provider-agnostic orchestration — PaymentIntentService, PaymentMethodProviderBindingResolver, PosPaymentConfigService |
Shumoul.Payments.Geidea | The one provider implementation that exists today: GeideaPaymentProvider, GeideaCallbackVerifier, GeideaResponseNormalizer, GeideaApiClient, GeideaSignatureService |
Provider-agnostic payment framework
PaymentIntentService (Infrastructure) never references anything Geidea-specific. It resolves an
IPaymentProvider by ProviderKey (via IPaymentProviderResolver) and calls only the abstract
CreateSessionAsync / QueryStatusAsync / ParseCallbackAsync members of IPaymentProvider. Adding a
second provider in the future means implementing that interface plus IPaymentCallbackVerifier — no change
to PaymentIntentService, PosPaymentConfigService, or any ERP-side Order/Invoice code.
Geidea provider implementation
GeideaPaymentProvider implements IPaymentProvider for the Mobile SDK / Create-Session flow. It never
implements the Direct API card flow and never touches raw card data (PAN/CVV/track data) — the Mobile SDK
handles card entry entirely on the client device; this backend only ever sees a masked PAN and card scheme
after the fact.
Core entities
| Entity | DbContext | Role |
|---|---|---|
PaymentProviderDefinition | SharedDbContext (host) | One row per provider (GEIDEA) — name, whether Mobile SDK/External Terminal/SoftPOS/Payment Links are supported by this provider at all |
PaymentProviderCountryProfile | SharedDbContext (host) | Country/environment-specific API URLs (ApiBaseUrl, CreateSessionPath, StatusQueryPath) and capability flags (SupportsMobileSdk, SupportsApplePay, SupportsGooglePay, SupportsMada, …) — one row per (provider, country, currency, Sandbox/Production) |
PaymentProviderAccount | ApplicationDbContext (tenant) | A tenant or branch's actual account with Geidea — MerchantPublicKey, ApiPasswordSecretRef (never the plaintext password), Apple Pay/Google Pay merchant metadata, which modes are enabled for this specific account |
PaymentMethodProviderBinding | ApplicationDbContext (tenant) | Which PaymentProviderAccount a given PaymentMethod uses, at device/branch/tenant-default resolution priority |
PosElectronicPaymentIntent | SharedDbContext (host) | The "PaymentIntent" itself — one row per payment attempt, tracks status/response codes/RRN/masked PAN and the links to OrderPayment/SalesInvoicePayment |
PaymentIntentEvent | SharedDbContext (host) | Append-only audit trail — every status transition, callback, and status-query attempt, with verification metadata (never the API password) |
PosElectronicPaymentIntent lives in the shared/host database, not a tenant database, because Geidea's
callback arrives with no tenant context resolved (no subdomain to resolve a tenant from) — it is looked up
by provider-assigned business keys (MerchantReferenceId, ProviderSessionId, ProviderOrderId) instead.
See §1.8 for how this crosses back into tenant data.
1.3 Configuration Flow
Setting up Geidea for a tenant is a chain of four configuration objects:
- A
GEIDEAPaymentMethod— the tenant's own payment-method row (Is_External = true), same as any other payment method in the ERP. - A
PaymentProviderAccount— created for a tenant/branch, referencing aPaymentProviderCountryProfileby (ProviderKey,CountryCode,Currency,Environment), withMerchantPublicKeyand aApiPasswordSecretRefpointing into the secret store — never a plaintext password column. - A
PaymentMethodProviderBinding— links thePaymentMethodto thePaymentProviderAccount, at one of three resolution priorities: exact device binding → branch binding → tenant default. - POS reads all of this through one API:
GET /api/pos/payments/config?branchId=&deviceId=(IPosPaymentConfigService/PosPaymentConfigService). For each visible, activePaymentMethod, it resolves the binding (PaymentMethodProviderBindingResolver.ResolveAsync) and returns:IsConfigured— true only if an active binding and active account and active country profile all existProviderKey,ProviderMode,CountryCode,Currency,Environment- Capability flags:
SupportsMobileSdk,SupportsExternalTerminal,SupportsSoftPos,SupportsApplePay,SupportsGooglePay,SupportsMada - Apple Pay / Google Pay client metadata (
ApplePayMerchantId,ApplePayMerchantName,ApplePayNetworks,GooglePayMerchantId) — see §1.9
Branch/device resolution
PaymentMethodProviderBindingResolver.ResolveAsync picks exactly one binding per request, in this order:
1. binding.DeviceId == request.DeviceId (exact device override)
2. binding.BranchId == request.BranchId (branch-level default)
3. binding.IsDefault == true (tenant-wide default)
Modes
PaymentProviderMode has four values — MobileSdk, ExternalTerminal, SoftPos, PaymentLink. This
chapter covers Mobile SDK only (Create Session flow). External Terminal, SoftPOS, and Payment Links are
modeled in the enum and in PaymentProviderCapabilities/PaymentProviderCountryProfile for forward
compatibility but have no Geidea implementation behind them yet.
Secrets are never exposed
PaymentProviderAccount.ApiPasswordSecretRef is a reference string into the ERP's secret store
(IPaymentSecretStore.GetSecretAsync), resolved to a plaintext value only for the duration of a single
outbound call (Create Session, status query) or callback-verification attempt, and never logged, never
returned in an API response, and never persisted anywhere else. Every raw provider response/callback body is
passed through GeideaSanitizer / PaymentPayloadSanitizer before being stored, which redacts any field
whose name contains password, apipassword, signature, cvv, cvc, cardnumber, pan, track1,
track2, expirydate, expirymonth, or expiryyear.
1.4 Payment Flow
Flutter POS Shumoul.Saas.Api Geidea
| | |
|-- CreatePaymentIntentRequest ->| |
| |-- Create Session (signed) ----->|
| |<-- sessionId, orderId -----------|
|<-- sessionId, PaymentIntentId--| |
| | |
|-- (Mobile SDK card entry, using sessionId) -------------------->|
|<-- client SDK result (approved/declined) ------------------------|
|-- ConfirmClientResultAsync -->| |
| (never trusted alone — moves to Processing at most) |
| |<-- Geidea callback (async) ------|
| | or: backend polls status query |
| | PaymentIntent -> Paid only |
| | after trusted confirmation |
Step by step:
- Flutter POS calls
POSTPaymentIntent create (IPaymentIntentService.CreateAsync), with thePaymentMethodId, branch/device/local-invoice/local-payment-attempt identifiers, amount, and currency. - Backend resolves the provider binding, decrypts the account's API password, and calls
GeideaPaymentProvider.CreateSessionAsync— which builds and signs the Create Session request viaGeideaSignatureService.GenerateSignatureand posts it to Geidea. sessionId(andorderId, if returned) come back and are stored on thePosElectronicPaymentIntentrow; status movesCreated→SessionCreated.- Flutter SDK returns a client result to the POS app, which reports it via
ConfirmClientResultAsync. A client-reportedSuccessmoves the intent toProcessingonly — never directly toPaid(see §1.5). - The backend receives a Geidea callback, or (if callback confirmation is disabled or untrusted) polls
Geidea's status-query endpoint. Only a trusted, strictly-gated confirmation moves the intent to
Paid.
1.5 Callback and Trust Policy
Client result is not trusted alone
ConfirmClientResultAsync treats a client-reported Success as informational only — RRN, auth code, masked
PAN reported by the client SDK are recorded, but the intent's status becomes Processing, requiring provider
confirmation. This is deliberate: a compromised or buggy client should never be able to mark itself Paid.
Sandbox vs. Production callback trust policy
PaymentIntentService.HandleProviderCallbackAsync applies this policy before marking Paid:
var trustedByPolicy = amountMatches && currencyMatches &&
(verification.IsVerified
|| (isSandbox && _options.AllowUnverifiedCallbacksInSandbox)
|| (!isSandbox && !_options.RequireVerifiedCallbacksInProduction && _options.AllowUnverifiedCallbacksInProduction));
The Production override is a deliberate two-key switch — both RequireVerifiedCallbacksInProduction = false and AllowUnverifiedCallbacksInProduction = true must be set; flipping only one changes nothing.
This prevents an operator from relaxing Production trust by accident.
Geidea callback signature verification
GeideaCallbackVerifier implements Geidea's documented callback signature — the signature travels inside
the callback JSON payload, not in an HTTP header:
HMAC-SHA256(
key = MerchantAPIPassword,
data = MerchantPublicKey + OrderAmount + OrderCurrency + OrderId + Status + MerchantReferenceId + timeStamp
), Base64-encoded
Every field is read case-insensitively with Geidea's documented alias/casing variants (orderId /
Orderid / orderID; merchantReferenceId / MerchantReferenceId / MerchantRefrenceId; timeStamp /
timestamp; amount / orderAmount / totalAmount; currency / orderCurrency), and used verbatim
(no reformatting) so the recomputed signature matches what Geidea itself signed. A missing signature, missing
required field, or mismatched signature all report IsSupported = true, IsVerified = false with a specific
diagnostic reason — never a silently faked success.
Paid decision — the full gate
A callback or status-query result is only trusted as Paid when all of the following hold:
| # | Condition | Enforced in |
|---|---|---|
| 1 | order.status normalizes to Success | GeideaResponseNormalizer |
| 2 | order.detailedStatus normalizes to Paid | GeideaResponseNormalizer |
| 3 | order.responseCode == "000" | GeideaResponseNormalizer |
| 4 | order.detailedResponseCode == "000" | GeideaResponseNormalizer |
| 5 | If order.transactions[] is present: the selected transaction (a successful Pay/Capture/Sale transaction wins outright, otherwise the latest non-Authentication-only transaction) also shows responseCode == "000" and detailedResponseCode == "000", and is not itself Authentication-only | GeideaResponseNormalizer |
| 6 | Amount matches PaymentIntent.Amount (1-cent/fils tolerance) | PaymentIntentService |
| 7 | Currency matches PaymentIntent.Currency (case-insensitive) | PaymentIntentService |
| 8 | Signature verified, or Sandbox/Production trust policy allows an unverified callback | PaymentIntentService + GeideaCallbackVerifier |
If amount/currency don't match, the intent is never marked Paid regardless of everything else — it falls back to a provider status-query confirmation attempt (if enabled) or stays at its current status for manual reconciliation.
1.6 Status Query
GeideaPaymentProvider.QueryStatusAsync uses Geidea's documented fetch endpoints (GET, not POST):
| Scenario | Endpoint |
|---|---|
ProviderOrderId already known on the intent | GET {ApiBaseUrl}/{StatusQueryPath}/{orderId} |
Only MerchantReferenceId known | GET {ApiBaseUrl}/{StatusQueryPath}?MerchantReferenceId={ref} |
Both use HTTP Basic Auth (MerchantPublicKey:ApiPassword), no request body, and never log the password.
GeideaResponseNormalizer.NormalizeStatusQuery extracts, from the (possibly nested order.*) response
shape:
ProviderTransactionIdAuthCode(Geidea'sauthCode/authorizationCode)RrnMaskedPan(maskedCardNumber/maskedPan)CardScheme(cardScheme/brand/paymentMethod)PaymentMethodName(type/wallet/paymentMethod)
When order.transactions[] is present, the same successful-Pay/Capture-preferred, Authentication-only-never
selection logic from §1.5 picks which transaction these fields come from.
1.7 Split Payment Support
One Order can have any number of OrderPayment rows, and one PosElectronicPaymentIntent represents
exactly one payment row — never the whole order. The same PaymentMethod can be used more than once in a
single Order, each use getting its own PaymentIntent.
Example — a 150 SAR order split across three payment rows:
| Row | Payment method | Amount | PaymentIntentId |
|---|---|---|---|
| 1 | Cash | 50 SAR | (none — Cash needs no provider) |
| 2 | Geidea | 60 SAR | PaymentIntentId = A |
| 3 | Geidea | 40 SAR | PaymentIntentId = B |
Rows 2 and 3 use the same PaymentMethod (Geidea) but are two entirely independent PaymentIntents, each
with its own Geidea session, its own callback, and its own link to its own OrderPayment row. This is
exercised directly by the simulated E2E test (see §1.10).
1.8 OrderPayment and SalesInvoicePayment Linking
Once a PaymentIntent is Paid, it flows through two more linking steps, each idempotent:
Paid → LinkedToOrderPayment → LinkedToInvoice
LinkToOrderPaymentAsync— called when the (offline-created)Order's payment rows are synced to the backend. Requires the intent to bePaid, and thatExpectedAmount/ExpectedCurrencymatch the intent's own values. SetsPosElectronicPaymentIntent.SyncedOrderId/SyncedOrderPaymentId.LinkToInvoicePaymentAsync— called when the Order is converted to a Sales Invoice. Requires the intent to already beLinkedToOrderPayment. SetsSyncedInvoiceId/SyncedInvoicePaymentId.
Duplicate prevention through filtered unique indexes
Retrying either link call with the same target row is a no-op that returns AlreadyLinked = true, enforced
at the database level:
| Index | Table | Guarantees |
|---|---|---|
UX_OrderPayments_PaymentIntentId | OrderPayments (tenant DB) | One PaymentIntentId can never appear on more than one OrderPayment row |
UX_SalesInvoicePayments_PaymentIntentId | SalesInvoicePayments (tenant DB) | Same guarantee for invoice payments |
UX_PosElectronicPaymentIntents_Tenant_MerchantReferenceId | PosElectronicPaymentIntents (host DB) | One MerchantReferenceId per tenant — used for callback correlation |
UX_PosElectronicPaymentIntents_Tenant_Branch_Device_LocalInvoice_LocalAttempt | PosElectronicPaymentIntents (host DB) | One PaymentIntent per local payment attempt — a repeated CreateAsync call returns the existing intent instead of creating a duplicate Geidea session |
No EF foreign key between tenant ApplicationDb and SharedDb PaymentIntent
OrderPayment.PaymentIntentId and SalesInvoicePayment.PaymentIntentId are plain Guid columns with no
EF Core foreign key to PosElectronicPaymentIntent — because PosElectronicPaymentIntent lives in the
host-level SharedDbContext while OrderPayment/SalesInvoicePayment live in the tenant
ApplicationDbContext. A cross-database foreign key isn't possible in SQL Server regardless, so the link is
enforced purely at the application layer (LinkToOrderPaymentAsync/LinkToInvoicePaymentAsync), backed by
the unique indexes above to prevent duplicates.
1.9 Apple Pay and Google Pay Metadata
Apple Pay (Mobile SDK only — the Direct API card flow and Apple Pay Direct API are both out of scope for this integration):
- The Flutter SDK requires
applePayConfigset on the client, and Apple Pay must be enabled for the merchant account in the Geidea Merchant Portal. PaymentProviderAccountcarriesApplePayMerchantId,ApplePayMerchantName, andApplePayNetworks(comma-separated card network list, e.g.visa,masterCard,amex) — surfaced read-only to POS throughPosPaymentMethodConfigDto, gated onPaymentProviderCountryProfile.SupportsApplePay.
Google Pay:
PaymentProviderCountryProfile.SupportsGooglePayandPaymentProviderAccount.GooglePayMerchantIdexist in the schema and are wired through to POS config — the metadata plumbing is complete.SupportsGooglePayis not enabled anywhere in seed data and must not be enabled until Geidea confirms Mobile SDK availability for the target country. Per Geidea's own documentation, Google Pay requires activation by a Geidea Account Manager and current documentation suggests it may be Checkout-only — not confirmed as Mobile SDK-supported.- Do not claim Google Pay Mobile SDK support in any customer-facing material until Geidea confirms it.
1.10 Simulated E2E Test Result
A full split-payment flow was exercised end-to-end in
GeideaSandboxE2ESimulationTests.GeideaSandboxE2E_SplitPaymentFlow_CreatedToLinkedToInvoice, using the real
PaymentIntentService, PaymentMethodProviderBindingResolver, OrderService, and SalesInvoiceService —
only the outbound Geidea HTTP call itself is faked.
What this proves:
- Two PaymentIntents on one Order (60 SAR + 40 SAR, same Geidea
PaymentMethod, different attempts) both reachPaid— one via the Sandbox unverified-callback trust policy, the other via a simulated provider status-query confirmation. - Both intents correctly link through
LinkedToOrderPayment→LinkedToInvoice, alongside an unrelated Cash row, with no duplicate rows on retry. - No event payload or stored raw response ever contains the plaintext API password.
What this does NOT prove:
- No real outbound call was made to Geidea —
CreateSessionAsync/QueryStatusAsyncare backed by a fake provider in this test, notGeideaApiClientagainst a live endpoint. - No real Geidea callback was received — the callback payloads used are hand-constructed by the test, not a payload Geidea itself produced and signed.
- The internal code path (PaymentIntent → OrderPayment → SalesInvoicePayment) is verified; the actual network/Geidea-side behavior is not.
1.11 Pending Real Sandbox Validation
The following are required before this integration can be validated against a live Geidea sandbox, and were not available at the time this chapter was written:
- Sandbox
MerchantPublicKey - Sandbox
ApiPassword - Confirmed sandbox
ApiBaseUrl(currently an unconfirmed placeholder:https://api-test.ksamerchant.geidea.net/) - Confirmed
CreateSessionPath - Confirmed
StatusQueryPath(updated to the documentedpgw/api/v1/direct/ordershape, but not yet confirmed against a live call) - A real callback payload sample captured from a live sandbox transaction — to confirm whether
order.detailedStatusandorder.detailedResponseCodeare always present on a successful payment (the strict Paid gate in §1.5 requires both) - Callback signature confirmation against a real sandbox-signed payload
- A Flutter SDK sandbox payment runner/test app to actually exercise the Mobile SDK card-entry flow
- Geidea's published test card numbers
- Confirmation of whether Google Pay currently appears in the Mobile SDK (vs. Checkout-only) for KSA
- Confirmation of whether a SoftPOS/Tap-to-Phone SDK exists as a separate Geidea product
1.12 Production Readiness Checklist
- Real sandbox Create Session verified (session created,
sessionIdreturned, SDK launches) - Real callback received from Geidea and verified by
GeideaCallbackVerifier(IsVerified = true) - Status query verified against a real Geidea order (
GETby orderId and by MerchantReferenceId both exercised) - Paid decision validated against a real Geidea success payload — specifically confirming
detailedStatus/detailedResponseCodepresence (see §1.11) - OrderPayment/SalesInvoicePayment linking tested against a real Paid PaymentIntent, not just the simulated flow
- Secrets verified not logged anywhere (application logs,
PaymentIntentEvent.PayloadJson, stored raw response/callback columns) -
CallbackBaseUrlconfigured to a public, HTTPS-reachable endpoint (Geidea cannot call back tolocalhost) - Monitoring/alerting added for failed signature verification and unexpected callback volume
- Refund/reversal — explicitly out of scope for this integration and planned as a separate, dedicated piece of work
