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-oscuprofile - 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_COUNTmovements - 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-countsGET /api/v1/pos/stores/{storeId}/stock-counts/{countId}POST /api/v1/pos/stores/{storeId}/stock-counts/{countId}/submitPOST /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:
- 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.
- Complete KRA sandbox testing, taxpayer mapping, invoice/cancellation test
cases and reconciliation before changing
fiscalizationRequiredfromfalse. Keep another country's tax integration in a separate adapter and provider binding. - 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.
- 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-keyfor 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-requestsGET /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}POST /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}/cancelPOST /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}/refundsGET /api/internal/v1/organizations/{organizationId}/stores/{storeId}/merchant-collection-requests/{authorizationId}/refunds/{refundReference}- accept only
POS_SERVICE,SERVICE_PROVIDER, orSYSTEM_ADMINservice tokens; - resolve the store inside the organization and verify that
tillNumberbelongs to that same store before creating or returning a request; - accept the country-neutral
customerReference,tillNumber,amount, ISO 4217currency,reference,idempotencyKey, optionalwalletId, and optionalspendingCategory.customerMsisdnremains 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:
- 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.
- POS creates its stock-reserved
PENDINGtender 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. - A worker posts the request with an idempotency key derived from the POS tender reference. The returned authorization UUID becomes the immutable provider reference.
- The customer app receives the existing Emali approval notification and approves or denies using its registered-device proof and app step-up.
- POS polls the scoped internal status endpoint with bounded backoff. Map
PENDINGandAPPROVEDto local pending,CONSUMEDto captured, andDENIED,EXPIRED, orCANCELLEDto 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. - Refresh the sale after every status transition and stop polling at a
terminal state. A later
platform.merchant-payment.updated.v1event may enrich the tender with the platform transaction reference, but it must not be required to determine capture. - 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-attemptslists the tenant-scoped queue, andPOST /api/v1/pos/customer-payment-attempts/{attemptId}/resolverecords 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. - 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
PENDINGorAPPROVEDtoCANCELLED; an authorization alreadyCONSUMEDremains consumed, and POS keeps the tender captured. - 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-terminalreturns only terminal ID, model, lifecycle status, firmware, heartbeat and readiness;- a missing mapping is
404, upstream failure is a safe502, and unauthenticated direct access to the card registry is401; - 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 transactionsCashremains POS-only operationallyExternal cardshould remain distinct fromCARD_PRESENTwhen it is not using an acquirer integrationLoyalty pointsact as a controlled tender inside POS, with customer attachment and policy enforcement
Recommended Next Integrations
- printer bridge and cash drawer settings
- barcode scanner hardware setup
- KRA sandbox registration, OSCU certification and an operator-assisted reconciliation workflow
- certified mobile-money reversal and settlement reconciliation adapters, including M-PESA only for Kenyan merchants that enable it
- card-present result adapter
- promotion execution engine
- customer receipt delivery
- ecommerce sync
- ERP and accounting export