@retalia/pos-sdk
v0.3.3
Published
Framework-free HeadlessAPI client and shared models for Adaptive POS hosts.
Keywords
Readme
@retalia/pos-sdk
Framework-free HeadlessAPI client and shared models for Adaptive POS hosts. Plain TypeScript, no dependencies: works in Angular, React, Vue or no framework at all. Each host wraps it in its own framework glue (DI provider, hooks, composables).
UI stays in @retalia/pos-components. This package owns HTTP/session concerns only — components
must never import it; the host calls the SDK, then passes display state into the UI.
@retalia/pos-components -> display models
@retalia/pos-sdk -> API / session
components -X-> sdk
sdk -X-> componentsInstall
npm i @retalia/pos-sdkUsage
Create one client per app and share it:
import { createPosClient } from '@retalia/pos-sdk';
export const pos = createPosClient({
apiBaseUrl: 'https://headless.example', // '' when the host proxies /api
});
// Start-up: the till signs in, then the shop config loads with the till's pos id.
const till = await pos.till.signIn({
shopId: readCookie('contextShopId'),
portalRefreshToken: readCookie('portalRefreshToken'),
});
await pos.configStore.load();
// Clerk sign-in: checks the config, the clerk and opens the first sale.
try {
const { clerk, envelope } = await pos.session.signIn('100', '1234');
} catch (error) {
if (error instanceof PosSignInError) showMessage(error.reason); // 'invalidCredentials', …
}The SDK sends the till's token, the shop and the signed-in clerk on every request, and signs the till in again on a 401. Hosts only read their portal cookies and turn failure reasons into messages.
| | |
| --- | --- |
| createPosClient(options) | Returns { till, session, commands, dayEnd, auth, configService, configStore, activeDevice, activeDeviceService, cardPayments, request, config }. Options are PosApiConfig plus transport, cache, tokenStorage, activeDeviceStorage and defaultActiveDevice. |
| till (PosTill) | The till's own sign-in. signIn({ shopId, portalRefreshToken }) refreshes the POS token stored for the shop (same till), or signs in with the portal refresh token (a new till); never throws, resolves with the state. posId, accessToken, failure, getState(), subscribe(listener). Failures: noShop, noPortalSession, tillUnreachable, tillSignInFailed, noPosId. |
| session (PosSession) | The clerk. signIn(clerkNumber, password) → Promise<{ clerk, envelope }>, rejects with PosSignInError (reason: a till failure, configUnavailable, invalidCredentials, sessionExpired, signInFailed or openSaleFailed). clerk, signOut(), blocker() (why no clerk can sign in yet). |
| commands (PosCommandsClient) | addItem / changeQuantity / clearBasket / newSale → Promise<PosStateEnvelope>; tenderCash / tenderCard → Promise<PosTenderResult> ({ envelope, receipt }). Optional { idempotencyKey, signal }. Send the till's armId on tender: the API blocks tender with till-required when neither the command nor the basket has one. |
| commands card tender | startPayment({ basketId, amount?, deviceId, terminalType }) → Promise<CardPaymentResult> ({ envelope, receipt, sessionId, serviceId, responseCode, message }); the Idempotency-Key is the sessionId. cardPaymentProgress({ sessionId, serviceId? }) checks the terminal once (no key). completePayment({ sessionId }) finishes the sale after Success. responseCode is 'InProgress' \| 'Success' \| 'Failure'. |
| dayEnd (DayEndClient) | Day end (cash statement) for one till, manager only. xReport(request) / preview(request) → Promise<DayEndReport> (save nothing); close(request, { idempotencyKey }) closes the till and returns the Z report; latestZReport(connectorId) for a reprint. request is { connectorId, localTime?, counts? }. |
| new DayEndSession(client.dayEnd, getTill) | One cash statement for the till (getTill() → { connectorId, connectorName } or null). start(), count(key, inDrawer, toBank), close() (keeps one idempotency key for retries), dismiss(). Framework-free: getState() → { open, loading, closing, error, report }, subscribe(listener). Never throws; failures set error. Helpers: dayEndLineKey, dayEndCount, isDayEndCashLine, selectDayEndClosed. |
| new DayEndReports(client.dayEnd, getTill) | xReport() and lastZReport() for printing. Resolve with { ok: true, report } or { ok: false, message } (no till, not a manager, offline, no Z yet). |
| renderXzReportHtml(report, { tillName? }) | An 80 mm X/Z report page as an HTML string, for the host to print (e.g. through the browser and the printer driver). Touches no DOM. |
| renderPrintableXzReportHtml(printable, { currency, isCopy? }) | The same page from RBO's printable report alone, e.g. the Z report from lastZReport() for a reprint. |
| new DayEndPrinter(client.dayEnd, getTill, print, { getCurrency }) | Prints the X report (printXReport()), a copy of the last Z report (reprintLastZReport()) and the Z report after a close (printClosed(report)). print(html) is the host's printer, e.g. pos-components' PosBrowserPrinter. Each resolves with a message for staff, or '' once printed. |
| selectBasketItemCount(envelope) | Items in the basket for display (e.g. "£49.95 · 3 items"): the lines' quantities, leaving out struck-through lines. HeadlessAPI sends no count, so hosts use this rather than counting themselves. 0 with no envelope. |
| auth (ClerkAuthService) | The clerk check session.signIn uses. signIn(clerkNumber, password) → Promise<ClerkSession>, rejects with ClerkAuthError (reason). Password check in the SDK (stub until ID-647). |
| cardPayments (CardPaymentService) | start(basketId, { amount?, sessionId?, signal? }) on the active device; waitForFinalResult(started, { intervalMs = 2000, timeoutMs = 120000, signal, onProgress }) polls one request at a time until Success / Failure (local Failure on timeout, retries network errors and 5xx, rejects with AbortError when signal aborts); complete(payment) with an idempotency key derived from the session. |
| activeDevice (ActiveDeviceStore) | The till's payment terminal { deviceId, terminalType, name? }, kept in localStorage. Defaults to DEFAULT_ACTIVE_DEVICE (ff-ff-fb-64-4b-5c, 10 = Adyen). getState(), subscribe(listener), setDevice(device), reset(); selectors selectActiveDevice / selectCardPaymentDevice. Options activeDeviceStorage and defaultActiveDevice change where it's kept and the default. |
| activeDeviceService (ActiveDeviceService) | usePaymentDevice(paymentDevice) picks a shop-config device (by mac, else deviceId); startPaymentCommand(basketId, { amount?, armId? }) adds the device to the command and throws ActiveDeviceError when none is set. |
| configStore (PosConfigStore) | Shop config for the till. load(), clear(), getState(), subscribe(listener), plus snapshot getters (localCurrency, paymentTypes, …) and pure select* functions. |
| request | Authenticated call for endpoints the SDK doesn't wrap yet. Rejects with PosApiError (status, body). |
Lower-level exports
Most hosts only need createPosClient. These are exported for hosts that need finer control or
want to build their own client:
| | |
| --- | --- |
| PosConfigService | getConfig(posId, shopId?) → Promise<PosConfig> (GET /api/v1.0/Config?posId=…). One-off fetch with no caching or state; use configStore instead unless you need a raw call. |
| IndexedDbPosConfigCache | The default offline config cache used by configStore. Implements PosConfigCache (read(key) / write(key, entry)); never throws, and does nothing where IndexedDB is unavailable. Pass your own PosConfigCache as cache to store it elsewhere. |
| stripConfigSecrets(config) | Returns a copy of a PosConfig that is safe to keep in browser storage. Used before every cache write. |
| LocalStoragePosTokenStorage | The default tokenStorage: the till's tokens per shop in localStorage. Pass { key: (shopId, name) => … } to keep an existing key layout. Implement PosTokenStorage (read(shopId) / write(shopId, tokens)) to store them elsewhere. |
| passwordGrant / refreshGrant / tokensFrom / refreshTokenRejected | The POST /token grants and response handling till uses. |
| createPosRequest(config, transport?, credentials?) | Builds the authenticated request function the clients share (base URL, auth/shop/clerk headers, 401 renewal). credentials supplies the till token, clerk and renewal. createPosClient calls this for you; use it directly only to wire clients by hand. |
| readAccessToken(config) / readShopContext(config) | The host's Bearer token / shop id, else the tokenCookieName / shopContextCookieName cookie, else null. |
| readPosId(config) | The host's getPosId override, trimmed; null when blank or not set. |
| readCookie(name) | Reads a browser cookie by name; null when missing or outside a browser. |
| DEFAULT_POS_API_CONFIG | Default PosApiConfig: apiBaseUrl: '', tokenCookieName: 'portalApiToken', shopContextCookieName: 'contextShopId'. |
Headers (what used to be the interceptor)
Every SDK request goes through one function that prefixes apiBaseUrl and attaches:
Authorization: Bearer …: the host'sgetAccessToken(e.g. a local dev token), else the till's token fromtill.signIn, else theportalApiTokencookieShop-Context:getShopContext, else thecontextShopIdcookieClerk-Context: the clerk signed in throughsession.signIn
POST /token is left alone. A header the caller sets explicitly is never overridden.
On a 401 the SDK signs the till in again with what the last till.signIn was given (one shared
renewal for concurrent 401s) and, if that works, retries the request once with fresh headers.
A host that supplies its own token and never calls till.signIn gets the 401.
configStore.load() uses getPosId when the host sets it, else the till's pos id.
Upgrading from 0.1
onUnauthorizedis gone: the till renews itself. Callpos.till.signIn(…)at start-up instead of your ownPOST /token.getClerkContextandreadClerkContextare gone: sign clerks in withpos.session.signIn(or setClerk-Contextyourself throughrequest).getPosIdis now only an override; without it the till's pos id is used.getAccessTokenreturning null now falls back to the till's token before the cookie.
Custom transport
transport defaults to fetch. Pass your own (url, init) => Promise<Response> to add
logging/tracing or route calls through another HTTP client.
Framework glue
The config store is a plain subscribable store with immutable snapshots, so it plugs into any framework:
// Angular
const state = signal(pos.configStore.getState());
pos.configStore.subscribe((s) => state.set(s));
const localCurrency = computed(() => selectLocalCurrency(state()));
// React
const state = useSyncExternalStore(
(cb) => pos.configStore.subscribe(cb),
() => pos.configStore.getState(),
);
// Vue
const state = shallowRef(pos.configStore.getState());
pos.configStore.subscribe((s) => (state.value = s));Angular code that prefers Observables can wrap any call with rxjs from(...).
Build and test
From the workspace root:
npm run package:sdk # tsc → dist/pos-sdk (ESM + .d.ts)
npm run test:sdk # vitest
npm run publish-sdkPublish dist/pos-sdk, not the workspace root.
