Skip to content

Backend and Integrations

Current Backend Shape

The standalone emali2-pos-service owns the transactional POS boundary. It serves customers, engagement, suppliers, goods receipts, inter-store transfers, physical stock counts, item images, stock, and store reconciliation from POS-owned tables and private object storage. emali2-core remains a read-only synchronization and historical migration source. A platform refresh cannot overwrite a row after POS ownership is established, and the POS service contains no catch-all platform proxy.

Current responsibilities already implemented:

  • terminal bootstrap
  • tenant-owned store operating profiles for ISO country/currency, BCP 47 locale, IANA timezone, fiscalization policy, and prioritized provider bindings
  • one-time terminal activation with server-verified proof of possession of the submitted EC/RSA key, followed by per-request proof for protected terminal mutations, public-key binding, tenant-scoped heartbeat, and management-scoped revocation
  • cashier sessions
  • sales and tender collection
  • stock-reserved electronic tender handoff and normalized provider-result application
  • provider-authoritative cancellation of outstanding customer-app prompts with capture-race reconciliation
  • manager-authorized full or partial refunds with line-accurate credit notes
  • immutable provider-statement import and country-neutral capture/refund matching
  • receipts
  • tenant-scoped daily store and terminal reconciliation from POS-owned sales and tenders
  • provider-neutral, manager-confirmed fiscalization reconciliation that never retries an unknown provider write
  • durable KRA OSCU sale and credit-note fiscalization lifecycle, behind the certification-gated etims-oscu profile
  • POS-owned customer profiles and loyalty read models
  • POS-owned loyalty, reward, promotion, and tender-policy configuration
  • idempotent customer and engagement writes with audit and transactional outbox events
  • inventory items, stock, and movement
  • private item-image upload, integrity validation, retrieval, replacement, and deletion
  • tenant-scoped catalog list, duplicate detection, and atomic idempotent bulk import
  • POS-specific supervisor credentials, online override authorization, failed-attempt throttling, and durable manager audit
  • supplier master data and atomic goods receiving
  • atomic inter-store stock transfers with deterministic store locking
  • conflict-safe physical counts with expected-balance snapshots, exact-scope submission, cancellation, and atomic STOCK_COUNT movements
  • durable supplier-change, goods-receipt, transfer, and stock-count outbox events

Terminal activation contract v1.1 signs a deterministic, versioned binary payload containing the normalized one-time code, public-key thumbprint, device metadata and declared capabilities. Verification occurs before challenge lookup or consumption, returns one generic invalid-proof response, and stores neither the private key nor a reusable copy of the proof.

Terminal request-proof contract v1 now protects Android and hardware-POS heartbeat, shift open/cash movement/normal close, sale create/collect/pending-tender cancellation/void/refund, and supervisor-override authorization. The signed canonical payload binds the terminal UUID, HTTP method, path, exact body SHA-256, timestamp, fresh nonce, and idempotency key. The service verifies the enrolled EC/RSA public key, resource ownership and a five-minute clock window, then atomically claims the hashed nonce in the POS-owned terminal_request_nonces table. A replay therefore fails across replicas and restarts, and every proof failure uses the same generic 401 contract without exposing a terminal identifier or verification detail. Administrator close-and-submit remains outside device proof so recovery is possible when a terminal is missing or broken. Browser WEB terminals use a non-exportable P-256 WebCrypto key stored in IndexedDB and sign the same canonical request contract; bearer-token authorization still applies to every route.

Once request proof succeeds, the service binds the JWT's opaque sid, falling back to session_state, and subject to that exact terminal. It stores no access token or refresh token. Revocation changes the terminal state and inserts one durable revocation job per bound identity session in the same transaction. After commit, POS calls a POS_SERVICE-only Core endpoint to verify the exact session still belongs to the recorded subject and terminate it at the identity provider. ALREADY_ABSENT is an idempotent completion; temporary platform or IAM failures retain only a sanitized error class and retry from PostgreSQL.

Physical count lifecycle routes are:

  • GET/POST /api/v1/pos/stores/{storeId}/stock-counts
  • GET /api/v1/pos/stores/{storeId}/stock-counts/{countId}
  • POST /api/v1/pos/stores/{storeId}/stock-counts/{countId}/submit
  • POST /api/v1/pos/stores/{storeId}/stock-counts/{countId}/cancel

Opening snapshots the expected balance for every selected item. Submission must contain exactly that scope and fails with 409 Conflict if any balance changed after opening; the operator cancels and opens a fresh count instead of overwriting an intervening sale, receipt, adjustment, or transfer.

Supervisor overrides use a POS-specific PIN rather than a platform transaction PIN. Only an actively projected store manager can receive a credential. The server records approved and denied decisions, throttles a credential after five failed attempts in 15 minutes, and never returns a PIN hash to Android. Sensitive overrides therefore require a live POS connection and fail closed when offline.

Legacy POST /api/v1/pos/terminals/register, POST /api/v1/pos/payments/resolve, and POST /api/v1/pos/payments/pay are retained only as explicit 410 Gone responses. They cannot reach the platform. Organization, card-present, and merchant onboarding APIs are platform-owned; the backoffice uses VITE_PLATFORM_API_BASE_URL for those calls while ordinary POS operations use VITE_API_BASE_URL.

Integration Boundaries

Country-neutral store configuration

Country behavior is configuration, not a fork of the POS domain. Management reads and updates a store profile at GET/PUT /api/v1/pos/stores/{storeId}/operating-profile. Cashier bootstrap contains the safe client view but never includes credential references. The full management view may store only secret://, vault://, or external-secrets:// references; raw client IDs, client secrets, keys, and tokens are rejected.

The profile owns ISO country and currency codes, BCP 47 locale, IANA timezone, fiscalization policy, and enabled provider bindings for CUSTOMER_APP_PAYMENT, MOBILE_MONEY, CARD_PRESENT, FISCALIZATION, and RECEIPT_DELIVERY. A cashier shift must use the configured currency. Web and Android expose only enabled capabilities, and the API rejects a client-supplied provider override that is not enabled for that store. Mobile-money adapters register by provider code through a provider-neutral coordinator boundary; the Kenya-specific Daraja adapter advertises MPESA_DARAJA and cannot accidentally handle another country's provider code.

Each installed payment adapter also publishes its safe runtime operations in cashier bootstrap. supportedOperations is empty unless that exact capability and providerCode are installed in the current service. The platform customer- app adapter advertises CANCEL_PENDING_PAYMENT; a configured provider with no authoritative cancellation implementation remains usable for collection but does not expose cancellation in a cashier client. Countries do not enable or disable this behavior.

The core contract never uses a provider brand as a tender type. Public APIs, events, web clients and Android send MOBILE_MONEY, WALLET, QR, CARD_PRESENT or another generic category, while providerCode selects the configured country/provider adapter. Legacy stored MPESA and EMALI values are normalized when read and are not emitted by current clients.

Executable domain code depends only on MobileMoneyPort; there is no M-PESA domain alias. A mobile-money result keeps the provider's response/result code separate from the configured adapter identity: providerCode records the stable binding such as MPESA_DARAJA, while the adapter attempt records values such as Daraja result code 0. The same rule applies to customer-app payment prompts, so a status word such as CAPTURED is not mistaken for a provider. The simulator exercises UGX and a non-Kenyan customer reference through the generic mobile-money boundary; Kenya-only number, currency and callback rules are enforced only inside the opt-in Daraja adapter.

Kenya reference deployment checklist

Kenya is a deployment profile, not a POS domain assumption. Before enabling a Kenyan store:

  1. Confirm the merchant's tax-invoice obligation and selected eTIMS solution. KRA currently states that persons engaged in business must onboard and issue electronic tax invoices. KRA describes OSCU for always-online system-to- system invoicing and VSCU for bulk or intermittently connected invoicing. Enable only the certified adapter selected for that taxpayer; a terminal advertised as “eTIMS ready” is not evidence that Emali2 is certified.
  2. Complete KRA sandbox testing, taxpayer mapping, invoice/cancellation test cases and reconciliation before changing fiscalizationRequired from false. Keep another country's tax integration in a separate adapter and provider binding.
  3. Determine whether the merchant and Emali2 act as data controller, processor, or both for customer, employee and payment-routing data. Record the controller/processor contract, retention, subject-rights, incident and cross-border-transfer controls, and complete any required ODPC registration.
  4. Route mobile-money and card acceptance only through the merchant's approved provider/acquirer integration. Emali2 POS must not present itself as a payment service provider, store cardholder data, or infer authorization from a device marketed as a “PDQ”.

Current primary references: KRA eTIMS overview, KRA system-to-system integration, ODPC registration guidance, and CBK payment-service-provider authorization.

Main Platform

Platform service authentication

The checked test and production manifests remain explicitly on the transitional CLIENT_SECRET_POST mode. The service also supports PRIVATE_KEY_JWT: it reads one PKCS#8 DER key from a read-only file, accepts ES256/P-256 or RS256/RSA-2048+ only, and sends a 60-second assertion bound to the exact token endpoint with a fresh JWT id and no client secret.

For a planned migration, generate the default RSA-3072 identity on a secured operator workstation outside the repository:

identity_dir="$(mktemp -d)"
POS_PLATFORM_CLIENT_KEY_ID="pos-test-$(date -u +%Y%m%d)-v1" \
  ./scripts/generate-pos-platform-client-identity.sh "${identity_dir}"

The generator also writes client-jwks.json, containing one RS256 public key with the exact configured kid. Validate it locally with the reconciler's default PLAN action. During an approved window, run its confirmed APPLY action with the generated JWKS and Keycloak administrator environment. The reconciler refuses to create a missing client, verifies the existing least-privilege service-account shape, uploads only the public JWKS, preserves unrelated client settings, enforces RS256 and a 60-second maximum assertion age, and never reads the client-secret endpoint. It preflights the public key before the authenticator switch and re-registers it immediately afterward to account for Keycloak 26.6 credential-attribute update semantics. Its AUDIT action is GET-only, while ROLLBACK_TO_SECRET has a separate confirmation guard.

Create a dedicated key-only Kubernetes Secret through ssh root@damplabs.com, mount private-key.pkcs8.der read-only at /var/run/secrets/emali2/platform-client/private-key.pkcs8.der, and configure:

EMALI2_PLATFORM_CLIENT_AUTHENTICATION_METHOD=PRIVATE_KEY_JWT
EMALI2_PLATFORM_CLIENT_ASSERTION_PRIVATE_KEY_PATH=/var/run/secrets/emali2/platform-client/private-key.pkcs8.der
EMALI2_PLATFORM_CLIENT_ASSERTION_KEY_ID=<registered key id>
EMALI2_PLATFORM_CLIENT_ASSERTION_ALGORITHM=RS256

Remove the entire EMALI2_PLATFORM_CLIENT_SECRET environment entry in the same approved change window. Mixed configuration fails startup deliberately. Keep the old identity-provider setting available for rollback until the POS pod is ready and the sanitized health, merchant-only bootstrap, country-neutral profile, provider-capability, catalog, reconciliation and no-agent-dependency preflight passes. Only then delete the old Kubernetes Secret key. Never generate or rotate the live identity as part of an ordinary application rollout.

Keycloak documents signed-JWT service-account authentication and supports client public-key registration through an imported key or JWKS URL in its Server Administration Guide.

POS needs synchronous and asynchronous access to:

  • organizations
  • organization units
  • user assignment
  • roles and service accounts
  • Emali payment confirmation
  • ledger and settlement reference data

Preferred internal pattern:

  • service-account API calls for synchronous checks
  • outbox or event propagation for asynchronous updates

Merchant machine integrations

Merchant ERP, ecommerce and stock-sync clients use a dedicated MERCHANT_INTEGRATION machine role. The identity provider proves who the client is; POS independently decides which merchant and stores it may access. Flyway v34 adds a POS-owned registry keyed by the token's exact azp/client identifier. Each active binding has one organization, one or more explicit stores and either OPERATE or MANAGE access. Empty/global store scope is deliberately unsupported, mismatched azp and client_id claims fail closed, and a client identifier cannot be rebound to another organization.

Merchant administrators manage only the POS authorization binding:

Method Path Purpose
GET /api/v1/pos/integration-clients List bindings in the caller's organization
PUT /api/v1/pos/integration-clients Create or replace an exact client/store binding
DELETE /api/v1/pos/integration-clients/{clientId} Revoke the binding immediately

These endpoints never create, read, rotate or return a client secret/private key. Provision the confidential client in Keycloak (prefer private_key_jwt), give it the pos-api audience and only the MERCHANT_INTEGRATION role, then register the same client id in POS. A token with that role cannot fall through to ordinary merchant claims when its POS binding is missing, inactive or out of scope. The first enabled machine flows are catalog reads and proof-bearing shift/sale operations; terminal mutations still require the enrolled terminal's request signature.

For transitional client_secret deployments, the guarded k8s/keycloak/setup-merchant-integration-client.sh helper defaults to a no-write PLAN, accepts a secret only from a non-symlink 0600 file, and requires an explicit confirmation for APPLY. It disables browser, password, implicit, authorization-service and full-scope access; enables only the service account; assigns merchant_integration; and adds the pos-api audience. AUDIT performs no mutation. Keycloak includes the secret field in ordinary client-list representations, so the helper strips it immediately from every lookup and never prints or retains it. It never calls Keycloak's dedicated secret endpoint. The POS binding remains a separate administrator action.

POS now refreshes every already-projected organization through the confidential service-account API on a configurable 15-minute interval. Scans use bounded batches and a PostgreSQL advisory lock, so only one replica performs a refresh at a time. The adapter accepts only units typed MERCHANT, excludes agent and biller assignments, and rejects a response whose organization identity differs from the requested tenant. Failures preserve the last successful snapshot and retry after a shorter interval; stored/logged failure details contain only the exception class, never an upstream response body. Platform events can later reduce latency, but the scheduled scan remains the missed-event reconciliation path.

Split tenders and stock ownership

The POS service accepts a positive tender leg up to the server-owned outstanding balance. The first partial cash or electronic leg reserves the entire basket stock exactly once. Captured legs advance paidAmount, reduce outstandingAmount, and leave the sale PARTIALLY_PAID; only the final leg changes the sale to PAID, commits the existing reservation, creates the receipt, and emits sale-completion events. One unresolved electronic leg is allowed at a time, so cash fallback remains blocked while a provider result is pending. The service enforces the store's split-enabled flag, maximum-tender count, enabled tender families, and card/cash or wallet/loyalty combination rules after locking the sale; these are not client-only controls. A failed retry releases stock only when no earlier leg was captured.

Cashiers can call POST /api/v1/pos/sales/{saleId}/tenders/{tenderId}/cancel for a pending customer-app tender. The POS calls the configured provider adapter and applies only its authoritative result: a confirmed cancelled, denied, expired or failed prompt becomes a failed tender, while a payment that completed first remains captured. Providers that cannot safely cancel, including the current Daraja adapter, are rejected instead of being relabelled locally. Cashier Web and Android show the action only when the selected binding advertises CANCEL_PENDING_PAYMENT. This operation is not a refund or reversal of a captured payment.

Full and partial refunds allocate against the original captured tender balances in capture order and persist that immutable routing in sale_refund_tender_allocations. A partial request must name the original sale items and returned quantities; only a request for the full remaining balance may omit them, in which case the server selects every remaining line. Flyway v29 persists the authoritative merchandise, tax and quantity snapshots in sale_refund_lines. This makes a selected stock return, credit-note line and fiscal tax amount describe the same merchandise instead of treating a partial refund as an unallocated cash amount. Repeated partial refunds consume only the remaining line quantities and tender balances. A partly consumed tender stays CAPTURED; the sale stays PAID until the cumulative refund reaches the paid amount.

All-cash refunds reverse only their allocated drawer amount. A sale containing an allocated electronic leg is accepted only when the exact generic capability and configured providerCode has an installed adapter advertising REFUND_CAPTURED_PAYMENT; an electronic tender outside the current partial allocation does not unnecessarily block the request.

Electronic work is persisted in electronic_refund_jobs and dispatched one leg at a time. A pending provider result is queried through the same adapter; an unknown write outcome moves the job and refund to MANUAL_REVIEW and is never blindly retried. Cash, stock, tender balances, sale status, the credit note, fiscalization job and pos.sale.refunded event remain unchanged until every provider leg succeeds, then commit together. A rejected first leg leaves the paid sale unchanged, cancels later unsubmitted legs and permits a fresh controlled request. A rejection after an earlier leg succeeded requires manual review. The originating shift cannot close while one of its electronic refunds is pending or under review.

Management can inspect provider-safe evidence at GET /api/v1/pos/payment-refunds/jobs; payment credentials and customer routing data are not stored in this queue or returned by the endpoint. The idempotent management-only POST /api/v1/pos/payment-refunds/jobs/{jobId}/resolve records only a manager-confirmed terminal success or rejection. Its evidence type is constrained to provider portal, provider support or settlement report, its evidence reference must be opaque and cannot be a customer phone number, and confirming success requires the provider refund reference. It never retries a provider write. The transition writes merchant-scoped audit history plus pos.payment.refund.reconciled; neither the evidence reference nor provider refund reference enters that event. A confirmed success resumes later sequential legs or local atomic finalization, while a confirmed rejection leaves the paid sale unchanged unless earlier legs already succeeded, in which case the refund remains blocked for partial-outcome handling.

The simulator supplies country-neutral card and mobile-money refund adapters for automated testing. Daraja and the platform customer-app provider continue to fail closed until their production refund APIs and operational approval paths are implemented and certified.

Provider settlement reconciliation

POS reconciliation is deliberately separate from provider settlement execution. A card-present service or another licensed provider remains responsible for clearing, payout, fees and provider-side adjustments. POS owns only an immutable view of whether a normalized statement line matches one of its captured tenders or completed electronic refunds.

Managers and confidential integration clients can import a report at POST /api/v1/pos/provider-settlement-reports; management reads use the same collection route and GET /api/v1/pos/provider-settlement-reports/{reportId}. The store operating profile must already enable the exact generic capability and providerCode, and the report currency must equal the store's ISO 4217 operating currency. Imports are limited to 1,000 lines, idempotent, tenant scoped and serialized per merchant/provider. A report is RECONCILED only when every capture or refund line has one unambiguous local match with the same amount and currency; all other cases remain explicit exceptions.

Statement lines accept PAYMENT_REFERENCE, TRANSACTION_REFERENCE or REFUND_REFERENCE as transient matching input. The raw value is never stored, logged, audited, emitted or returned. POS retains only its SHA-256 digest for matching and an eight-character display suffix; the digest is not exposed by the API. Responses contain safe fixed exception text and opaque local UUIDs. The report-level outbox event pos.payment.settlement.reconciled contains only provider/capability routing, date, currency, counts, status and decimal totals. It contains no report reference, raw entry reference, digest or customer data.

Signed close-of-day packs

POST /api/v1/pos/sessions/{sessionId}/close-and-submit now seals an immutable server snapshot in the same database transaction as the submitted shift. The pack is POS-owned and country-neutral: it records the shift currency, sales, refunds processed on the shift, generic tender categories, immutable drawer movements, fiscal artifact states, counted cash variance, and explicit integrity exceptions without embedding KRA, M-PESA, or another country's names in the core model.

The service serializes the versioned payload once, stores the exact canonical JSON, computes SHA-256, and signs those exact UTF-8 bytes with a Secret-backed Ed25519 private key. It persists the detached signature, key id, verification key, audit entry, and pos.shift.close-pack.sealed outbox event. A missing key fails close-and-submit atomically; it never produces an unsigned final pack. Retrieval verifies the stored digest, signature, and configured trust anchor before returning data. The owning cashier or a store manager can call:

  • GET /api/v1/pos/reports/close-of-day?sessionId={uuid} for the pack;
  • GET /api/v1/pos/reports/close-of-day/signing-key for the current public verification key and key id.

Consumers verify payloadSha256 and signatureBase64 against the exact canonicalPayload. Key rotation must retain the prior public key as a trusted verification key for the corresponding historical key id before the active key is changed.

Emali mobile-app collection

Core already owns the customer approval and wallet-payment flow. A merchant collection request resolves an active customer by a country-neutral customer reference, sends push, in-app, and websocket approval notifications, and lets the registered customer device approve or deny the exact till, amount, currency, and reference. Approval calls the existing merchant-payment service. The authorization becomes CONSUMED inside the same database transaction as the successful payment; a failed payment is rolled back and the authorization becomes CANCELLED. This makes CONSUMED the only safe captured signal for POS.

The standalone POS is wired through a narrow store-scoped internal contract in Core. The deployed emali2-pos-service service account uses its dedicated machine role and is not granted organization-administrator access to the browser-facing merchant-collection route. The internal contract is:

  • POST /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests
  • GET /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}
  • POST /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}/cancel
  • POST /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}/refunds
  • GET /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}/refunds/{refundReference}
  • accept only POS_SERVICE, SERVICE_PROVIDER, or SYSTEM_ADMIN service tokens;
  • resolve the store inside the organization and verify that tillNumber belongs to that same store before creating or returning a request;
  • accept the country-neutral customerReference, tillNumber, amount, ISO 4217 currency, reference, idempotencyKey, optional walletId, and optional spendingCategory. customerMsisdn remains a deprecated wire alias for older clients;
  • return authorizationId, status, decision note, expiry, approval and consumption timestamps, plus the exact non-sensitive payment fields;
  • submit refunds only for a consumed authorization whose original captured merchant-payment ledger belongs to the path store; serialize concurrent partial refunds, cap cumulative principal at the captured amount, and bind every idempotency key to the exact refund request;
  • return only a provider code, opaque refund reference, normalized status, amount, currency and occurrence time for refunds;
  • never return wallet balances, device proof material, notification tokens, or customer identity beyond a masked customer reference.

Balance evidence uses user-facing, self-scoped reads rather than expanding this service contract. A customer token reads GET /api/v1/wallets/me/balance. A different merchant token may read GET /api/v1/org/merchant-collection-requests/balance?tillNumber=... only with org.merchants.view and authorization for that exact till. The response contains organization/store scope, current major-unit balance, ISO currency and snapshot time; it omits the till number, ledger account ID and ledger owner reference. POS never receives either user token or either balance.

The deployed POS integration follows this lifecycle:

  1. Cashier selects the customer-app wallet provider, enters the customer's configured routing reference, and confirms a positive tender leg no greater than the outstanding amount. The prompt is online-only.
  2. POS creates its stock-reserved PENDING tender and a durable collection attempt in the same local transaction. The raw customer reference is encrypted while queued and deleted after dispatch; only a masked value remains on the tender.
  3. A worker posts the request with an idempotency key derived from the POS tender reference. The returned authorization UUID becomes the immutable provider reference.
  4. The customer app receives the existing Emali approval notification and approves or denies using its registered-device proof and app step-up.
  5. POS polls the scoped internal status endpoint with bounded backoff. Map PENDING and APPROVED to local pending, CONSUMED to captured, and DENIED, EXPIRED, or CANCELLED to failed. Feed that result through the existing normalized payment-provider-result service so stock, receipt, audit, and outbox behavior remains identical to other electronic tenders.
  6. Refresh the sale after every status transition and stop polling at a terminal state. A later platform.merchant-payment.updated.v1 event may enrich the tender with the platform transaction reference, but it must not be required to determine capture.
  7. If the provider still reports pending after its advertised expiry, or its status cannot be confirmed within POS_CUSTOMER_APP_MAXIMUM_PENDING_AGE, POS moves only the dispatch attempt to manual review and stops automatic polling. The tender remains pending, which continues to block cash fallback until an authorized merchant manager verifies the provider record. GET /api/v1/pos/customer-payment-attempts lists the tenant-scoped queue, and POST /api/v1/pos/customer-payment-attempts/{attemptId}/resolve records one independently verified captured or failed outcome with constrained evidence and provider occurrence time. Resolution reuses the normalized local provider-result transition but never sends a provider prompt, query, cancellation or refund.
  8. If the customer changes payment method while the request is outstanding, the cashier asks POS to cancel it. Core locks the authorization and changes only PENDING or APPROVED to CANCELLED; an authorization already CONSUMED remains consumed, and POS keeps the tender captured.
  9. For a captured return, POS follows the original tender-allocation order and submits an idempotent refund against the original authorization UUID. Core debits the merchant settlement ledger and credits the customer wallet for principal only. POS completes the local credit note only after the provider reports success; an indeterminate write is quarantined for reconciliation.

The lookup contract is internationally portable: canonical E.164 references work without a country switch. Historical Eswatini local aliases remain read-only compatibility inputs; they are not a default for another country. A market that permits national-format entry must supply that market's normalization adapter or require E.164 at the client boundary.

Tests cover service-role security, idempotent dispatch, immediate and delayed consumption, denial, approved-but-not-consumed state, retryable platform failure, restart recovery, encrypted-reference cleanup, exact provider-tender selection, sale-state invariants, principal refund accounting, cumulative partial-refund limits, adapter error handling, and client behavior. Merchant UAT must still exercise an actual customer approval, denial, expiry, insufficient funds, and captured refund. Cash fallback is blocked while the request is PENDING or APPROVED.

Core must not copy a wallet, ledger, provider or integration exception into the customer authorization note. A failed approved payment is changed to CANCELLED with one stable retry/alternate-method message, while logs retain only the authorization identifier and exception class. Tests inject a sensitive-looking provider failure and prove that its detail is absent from the customer/POS-visible cancellation note.

Offline batch replay and conflict control

POST /api/v1/pos/sync/batches accepts at most 50 ordered mutations. The exact body is covered by the enrolled terminal's request proof, and every item has a separate durable idempotency key. Supported operations are shift open, sale create, cash collect, cash refund and shift close. Electronic tender initiation is deliberately excluded because provider authorization is online-only.

Every item returns ACCEPTED, ALREADY_APPLIED, RETRYABLE or REJECTED; independent later items continue after an earlier failure, while a mutation waiting on a temporarily unavailable same-batch dependency remains RETRYABLE. Rejected items write only payload/idempotency hashes plus safe operational metadata to pos.sync_conflicts. Store managers use the Backoffice work queue to request a retry or discard with an audited note. A discard prevents that exact terminal mutation key from executing later, while an accepted replay closes the conflict. Android signs the exact serialized batch, applies every item result locally, and pulls retry/discard decisions only for its enrolled terminal. Room stores the safe conflict id and outcome code, never the server body or reusable credentials.

Object Storage

Use a private S3-compatible store for:

  • item images
  • future receipt artifacts
  • future label assets or exported operational packs

Item-image metadata is POS-owned in PostgreSQL; bytes stay in a private bucket. The service accepts only JPEG, PNG, or WebP signatures up to 5 MB, records a SHA-256 digest, and serves the current asset through an opaque, immutable URL on the POS API host. Object-store credentials never reach a browser or Android.

The test manifest uses single-node SeaweedFS 4.39 because it remains an actively maintained Apache-2.0 S3 implementation. The former MinIO community repository was archived in April 2026 and now describes community distribution as source-only, so a legacy MinIO server image is not pinned for a new test environment. The Java adapter remains S3-compatible and can target a managed S3 service for production.

Acquirer

The acquiring boundary is the separately deployed emali2-card-present service, not Core and not the POS database. Its test API is https://card-api.test.emali2.damplabs.com; in-cluster POS calls use http://emali2-card-present-service:8822 with the confidential emali2-pos-service client. The public issuer remains the identity truth while both services fetch Keycloak signing keys over the private cluster network. Every approved browser, native and machine caller receives an access-token-only pos-api audience. The POS resource server validates that audience alongside issuer, signature and token time, so a valid token intended for another Emali2 API cannot be reused against POS; realm roles and tenant checks remain separate authorization layers after authentication succeeds.

The first implemented integration is deliberately read-only:

  • card-present stores the POS terminal UUID and store ID as opaque strings;
  • POS checks the caller's local enrolled-terminal/store scope before looking up the card terminal;
  • GET /api/v1/pos/terminals/{terminalId}/card-present-terminal returns only terminal ID, model, lifecycle status, firmware, heartbeat and readiness;
  • a missing mapping is 404, upstream failure is a safe 502, and unauthenticated direct access to the card registry is 401;
  • no service shares a database or creates a foreign key into another service.

The certified payment device or vendor SDK must call the card-present payment boundary directly. POS may create a non-sensitive handoff/request reference and receive a normalized result, but it must never transport or persist full PAN, PIN, track data, EMV cryptograms or acquiring keys.

Keep the card-present boundary responsible for:

  • merchant onboarding
  • terminal provisioning
  • authorize
  • capture
  • void
  • refund
  • settlement pull
  • reconciliation pull

External Webhooks

Use signed webhooks for external consumers only:

  • ERP
  • ecommerce
  • accounting or BI exports
  • partner systems

Payment providers do not write anonymous callbacks directly into sale state. A provider-specific adapter validates the external callback or status query, then enters the normalized provider-result boundary. A separately deployed adapter posts to POST /api/v1/pos/payment-provider-results using an OAuth confidential client and a stable Idempotency-Key; the in-process Daraja adapter invokes the same application service without crossing an HTTP boundary. POS ignores provider-supplied totals, binds the provider request reference once, stores the financial transaction/receipt reference separately, and uses its own tender amount and currency.

Daraja M-PESA Express (optional Kenya adapter)

The mpesa-daraja profile now implements OAuth token caching, STK Push initiation, STK status query, and callback normalization. Checkout writes a stock-reserved tender plus a durable dispatch attempt in the same PostgreSQL transaction. A worker makes the network request only after commit. The customer MSISDN is AES-256-GCM encrypted while queued and removed after dispatch; only a masked value is retained on the tender.

Each callback URL contains the attempt UUID and a constant-time-verified HMAC. A successful callback must include a receipt number and exactly match the POS-owned tender amount. Missing or mismatched financial data enters MANUAL_REVIEW and cannot complete or release the sale. Network uncertainty during initiation also enters manual review rather than retrying an STK prompt or allowing cash fallback. Status queries are safe to retry. A callback that arrives after a successful query may enrich the captured tender with the M-PESA receipt number, but cannot change its status or CheckoutRequestID.

KRA eTIMS OSCU (optional Kenya adapter)

The etims-oscu profile implements the KRA OSCU v2.0 /saveTrnsSalesOsdc boundary for always-online server fiscalization. Completed sales and line-allocated full or partial refunds allocate a merchant-store numeric invoice sequence and create a durable job in the same PostgreSQL transaction as the receipt or credit note. The catalog and sale line snapshot use a country-neutral fiscal item profile: provider code, item classification, item code, packaging and quantity units, tax category/rate, and bounded adapter-extension attributes. The KRA adapter validates that this neutral profile contains the complete KRA mapping it requires. Later catalog edits therefore cannot alter an issued document, while another country can supply its own codes without changing the catalog or sale schema. Flyway v25 backfills the former KRA columns into this profile; older KRA-shaped write requests are accepted only by a deprecated compatibility bridge and current responses expose the neutral profile.

Accepted responses retain the KRA current/total receipt numbers, internal data, receipt signature, control-unit time, result code and message. These fields are merged into structured receipt data and the printable payload. A credit note waits until the original sale is accepted, then sends that accepted numeric invoice as orgInvcNo.

HTTP timeouts, interrupted DISPATCHING jobs and KRA overlap code 994 enter MANUAL_REVIEW. They are not automatically retried because KRA may have accepted the request even when the response was lost. Management can inspect the queue at GET /api/v1/pos/fiscalization/jobs. A manager may record an externally verified accepted or rejected result through POST /api/v1/pos/fiscalization/jobs/{jobId}/resolve, using an Idempotency-Key plus an opaque provider-portal, support, certified-report, or fiscal-device evidence reference. The mutation is merchant/store scoped, updates the fiscal job and receipt or credit note atomically, writes an audit entry and emits pos.fiscalization.updated. It never invokes the provider and there is deliberately no blind retry endpoint.

The adapter is disabled by default. Enabling it requires a Secret-backed, per-store TIN, two-character branch ID and KRA communication key, plus the official sandbox/certification process. No OSCU credential or request body is returned to Android or either web client.

Digital receipt verification

An authenticated cashier or terminal may request GET /api/v1/pos/receipts/{receiptId}/digital-link. The service applies the same exact-store authorization used by the rest of POS and returns a capability URL only for a receipt in that store.

The customer-facing lookup is GET /api/v1/pos/public/receipts/{token}. The token carries an opaque receipt identifier plus an HMAC-SHA-256 signature; it is verified before any database lookup. Only paid or refunded receipts are public. The response contains store display name, receipt number and time, generic goods, totals, currency, generic tender types and refund state. It deliberately excludes customer, cashier, terminal, organization, provider and external transaction references. The public endpoint uses no-store, no-referrer, noindex, nofollow and nosniff headers on both success and error responses.

POS_RECEIPT_LOOKUP_HMAC_KEY_BASE64 is a POS-only secret with at least 256 bits of entropy. POS_RECEIPT_PUBLIC_BASE_URL must be HTTPS outside local development. These settings are country-neutral; jurisdictional fiscal verification remains an adapter concern and is not embedded in the link.

API Groups

Key POS-facing groups:

  • /api/v1/pos/**
  • /api/v1/org-inventory/**
  • /api/v1/pos/inventory/suppliers
  • /api/v1/pos/inventory/receipts
  • /api/v1/pos/inventory/transfers
  • /api/v1/pos/stores/{storeId}/stock-counts

Supplier and receipt writes require an Idempotency-Key. The legacy /api/v1/org-inventory/suppliers and /api/v1/org-inventory/receipts aliases terminate in the same POS application services; they do not proxy to the platform. Receipt creation stores the receipt header and lines, increases stock, writes audit history, and emits pos.inventory.changed plus pos.inventory.goods-receipt.recorded in one PostgreSQL transaction. Transfers similarly write the transfer header and lines, source TRANSFER_OUT, destination TRANSFER_IN, audit entry, two inventory-change events, and pos.inventory.stock-transfer.completed atomically. Store locks are acquired in numeric order so simultaneous reverse transfers do not deadlock. Stock-count lifecycle transitions emit pos.inventory.stock-count.updated; a submitted count also commits its inventory-change events in the same transaction.

Recommended future grouping after extraction:

  • pos-service
  • inventory and catalog
  • sales and tenders
  • sessions and terminals
  • customers and loyalty
  • reporting read models
  • emali2-card-present
  • terminal registry and TMS
  • payment request lifecycle
  • settlement and reconciliation
  • underwriting and chargebacks

Tender Rules

  • Wallet/customer-app tenders reconcile with platform transactions
  • Cash remains POS-only operationally
  • External card should remain distinct from CARD_PRESENT when it is not using an acquirer integration
  • Loyalty points act as a controlled tender inside POS, with customer attachment and policy enforcement
  1. printer bridge and cash drawer settings
  2. barcode scanner hardware setup
  3. KRA sandbox registration, OSCU certification and an operator-assisted reconciliation workflow
  4. certified mobile-money reversal and settlement reconciliation adapters, including M-PESA only for Kenyan merchants that enable it
  5. card-present result adapter
  6. promotion execution engine
  7. customer receipt delivery
  8. ecommerce sync
  9. ERP and accounting export