Skip to main content
Version: Latest

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.

FieldValue
ProviderGeidea (KSA/GCC payment gateway)
Integration mode covered hereMobile SDK (Create Session / Payment Intent flow)
StatusImplementation completed — Simulated E2E passed — Real sandbox validation pending
Last Updated2026-07-11
Source packagesShumoul.Payments.Geidea, Shumoul.Payments.Infrastructure, Shumoul.Payments.Domain, Shumoul.Payments.Contracts, Shumoul.Payments.Abstractions (repo Shumoul.Saas.Payments)
ERP integration pointShumoul.Saas.ApiShumoul.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:

PackageRole
Shumoul.Payments.DomainEntities + EF Core configurations for both the tenant ApplicationDbContext and the host-level SharedDbContext
Shumoul.Payments.ContractsDTOs shared across the framework — requests/results, never provider-specific
Shumoul.Payments.AbstractionsInterfaces every provider and every cross-cutting service implements (IPaymentProvider, IPaymentCallbackVerifier, IPaymentSecretStore, …)
Shumoul.Payments.InfrastructureProvider-agnostic orchestration — PaymentIntentService, PaymentMethodProviderBindingResolver, PosPaymentConfigService
Shumoul.Payments.GeideaThe 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

EntityDbContextRole
PaymentProviderDefinitionSharedDbContext (host)One row per provider (GEIDEA) — name, whether Mobile SDK/External Terminal/SoftPOS/Payment Links are supported by this provider at all
PaymentProviderCountryProfileSharedDbContext (host)Country/environment-specific API URLs (ApiBaseUrl, CreateSessionPath, StatusQueryPath) and capability flags (SupportsMobileSdk, SupportsApplePay, SupportsGooglePay, SupportsMada, …) — one row per (provider, country, currency, Sandbox/Production)
PaymentProviderAccountApplicationDbContext (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
PaymentMethodProviderBindingApplicationDbContext (tenant)Which PaymentProviderAccount a given PaymentMethod uses, at device/branch/tenant-default resolution priority
PosElectronicPaymentIntentSharedDbContext (host)The "PaymentIntent" itself — one row per payment attempt, tracks status/response codes/RRN/masked PAN and the links to OrderPayment/SalesInvoicePayment
PaymentIntentEventSharedDbContext (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:

  1. A GEIDEA PaymentMethod — the tenant's own payment-method row (Is_External = true), same as any other payment method in the ERP.
  2. A PaymentProviderAccount — created for a tenant/branch, referencing a PaymentProviderCountryProfile by (ProviderKey, CountryCode, Currency, Environment), with MerchantPublicKey and a ApiPasswordSecretRef pointing into the secret store — never a plaintext password column.
  3. A PaymentMethodProviderBinding — links the PaymentMethod to the PaymentProviderAccount, at one of three resolution priorities: exact device binding → branch binding → tenant default.
  4. POS reads all of this through one API: GET /api/pos/payments/config?branchId=&deviceId= (IPosPaymentConfigService / PosPaymentConfigService). For each visible, active PaymentMethod, it resolves the binding (PaymentMethodProviderBindingResolver.ResolveAsync) and returns:
    • IsConfigured — true only if an active binding and active account and active country profile all exist
    • ProviderKey, 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:

  1. Flutter POS calls POST PaymentIntent create (IPaymentIntentService.CreateAsync), with the PaymentMethodId, branch/device/local-invoice/local-payment-attempt identifiers, amount, and currency.
  2. Backend resolves the provider binding, decrypts the account's API password, and calls GeideaPaymentProvider.CreateSessionAsync — which builds and signs the Create Session request via GeideaSignatureService.GenerateSignature and posts it to Geidea.
  3. sessionId (and orderId, if returned) come back and are stored on the PosElectronicPaymentIntent row; status moves CreatedSessionCreated.
  4. Flutter SDK returns a client result to the POS app, which reports it via ConfirmClientResultAsync. A client-reported Success moves the intent to Processing only — never directly to Paid (see §1.5).
  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.

A callback or status-query result is only trusted as Paid when all of the following hold:

#ConditionEnforced in
1order.status normalizes to SuccessGeideaResponseNormalizer
2order.detailedStatus normalizes to PaidGeideaResponseNormalizer
3order.responseCode == "000"GeideaResponseNormalizer
4order.detailedResponseCode == "000"GeideaResponseNormalizer
5If 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-onlyGeideaResponseNormalizer
6Amount matches PaymentIntent.Amount (1-cent/fils tolerance)PaymentIntentService
7Currency matches PaymentIntent.Currency (case-insensitive)PaymentIntentService
8Signature verified, or Sandbox/Production trust policy allows an unverified callbackPaymentIntentService + 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):

ScenarioEndpoint
ProviderOrderId already known on the intentGET {ApiBaseUrl}/{StatusQueryPath}/{orderId}
Only MerchantReferenceId knownGET {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:

  • ProviderTransactionId
  • AuthCode (Geidea's authCode/authorizationCode)
  • Rrn
  • MaskedPan (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:

RowPayment methodAmountPaymentIntentId
1Cash50 SAR(none — Cash needs no provider)
2Geidea60 SARPaymentIntentId = A
3Geidea40 SARPaymentIntentId = 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 be Paid, and that ExpectedAmount/ExpectedCurrency match the intent's own values. Sets PosElectronicPaymentIntent.SyncedOrderId / SyncedOrderPaymentId.
  • LinkToInvoicePaymentAsync — called when the Order is converted to a Sales Invoice. Requires the intent to already be LinkedToOrderPayment. Sets SyncedInvoiceId / 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:

IndexTableGuarantees
UX_OrderPayments_PaymentIntentIdOrderPayments (tenant DB)One PaymentIntentId can never appear on more than one OrderPayment row
UX_SalesInvoicePayments_PaymentIntentIdSalesInvoicePayments (tenant DB)Same guarantee for invoice payments
UX_PosElectronicPaymentIntents_Tenant_MerchantReferenceIdPosElectronicPaymentIntents (host DB)One MerchantReferenceId per tenant — used for callback correlation
UX_PosElectronicPaymentIntents_Tenant_Branch_Device_LocalInvoice_LocalAttemptPosElectronicPaymentIntents (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 applePayConfig set on the client, and Apple Pay must be enabled for the merchant account in the Geidea Merchant Portal.
  • PaymentProviderAccount carries ApplePayMerchantId, ApplePayMerchantName, and ApplePayNetworks (comma-separated card network list, e.g. visa,masterCard,amex) — surfaced read-only to POS through PosPaymentMethodConfigDto, gated on PaymentProviderCountryProfile.SupportsApplePay.

Google Pay:

  • PaymentProviderCountryProfile.SupportsGooglePay and PaymentProviderAccount.GooglePayMerchantId exist in the schema and are wired through to POS config — the metadata plumbing is complete.
  • SupportsGooglePay is 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 SalesInvoiceServiceonly 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 reach Paid — one via the Sandbox unverified-callback trust policy, the other via a simulated provider status-query confirmation.
  • Both intents correctly link through LinkedToOrderPaymentLinkedToInvoice, 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 GeideaCreateSessionAsync/QueryStatusAsync are backed by a fake provider in this test, not GeideaApiClient against 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 documented pgw/api/v1/direct/order shape, but not yet confirmed against a live call)
  • A real callback payload sample captured from a live sandbox transaction — to confirm whether order.detailedStatus and order.detailedResponseCode are 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, sessionId returned, SDK launches)
  • Real callback received from Geidea and verified by GeideaCallbackVerifier (IsVerified = true)
  • Status query verified against a real Geidea order (GET by orderId and by MerchantReferenceId both exercised)
  • Paid decision validated against a real Geidea success payload — specifically confirming detailedStatus/detailedResponseCode presence (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)
  • CallbackBaseUrl configured to a public, HTTPS-reachable endpoint (Geidea cannot call back to localhost)
  • 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