Building a POS app
Wiring the connector, database, store settings, pricing, background checks, status, live tab and selling into a platform POS app
Every platform POS app -- the Medusa POS today, a Vendure POS next -- wires the same TallyUI pieces in the same order. This page is that wiring, backend-agnostic where the connector allows it and calling out Medusa/Vendure differences where it doesn't. It isn't a tester or store-setup guide for any one app; see that app's own repo for those.
Sign in
A connector that supports email and password exposes auth.signIn, which exchanges credentials for a token; auth.getHeaders then turns stored credentials into request headers. The two combine into the SyncContext that every other call in this guide takes. A connector without signIn (an API-key connector, like Medusa's default) skips straight to getHeaders with the key the user entered.
import type { SyncContext, TallyConnector } from '@tallyui/core';
declare const connector: TallyConnector, baseUrl: string;
const { auth } = connector;
if (!auth.signIn) throw new Error(`${auth.type} has no email/password sign-in`);
const { token } = await auth.signIn(baseUrl, { email: 'demo@example.com', password: 'demo-password' });
const headers = auth.getHeaders({ token });
const context: SyncContext = { connectorId: connector.id, baseUrl, headers };The database
createTallyDatabase({ connector, storage }) builds an RxDB database whose collections come straight from the connector's schemas, plus a stock_levels overlay when the connector reconciles stock. A collection the app creates itself still needs migration strategies for every schema version bump, or RxDB refuses it; connectorCollection(schema) builds that config (ADR-060 amendment 9).
The app also opens pos_orders, the local store of finalized sales, with addPosOrderCollection(db). It adds the collection with posOrderCollection()'s migration strategies and resolves only once every order has migrated. The collection holds sales not yet sent, so never create it from posOrderSchema alone (RxDB refuses a schema above version 0 without its strategies), with connectorCollection, whose bumps drop documents, or by adding posOrderCollection() yourself.
If an order fails the new schema's validation, addPosOrderCollection rejects with RxDB's DM4 error once the migration has stopped, and every order stays in the old version's storage. Surface the error, and retry by calling it again or reopening the database. Never delete anything to get past it.
Rolling a till back to a build with an older pos_orders schema version, then upgrading it again, is unsupported. For example, a till that goes from version 1 back to version 0 and then on to version 2 holds two older versions at once. Its first addPosOrderCollection then rejects with DM4, and the next call migrates the rest. No order is lost.
import { createTallyDatabase, connectorCollection, getStorage } from '@tallyui/database';
import { addPosOrderCollection } from '@tallyui/pos';
import type { TallyConnector } from '@tallyui/core';
import type { RxJsonSchema } from 'rxdb';
declare const connector: TallyConnector, deviceSettingsSchema: RxJsonSchema<unknown>;
const db = await createTallyDatabase({ connector, storage: getStorage() });
await db.addCollections({ device_settings: connectorCollection(deviceSettingsSchema) });
const posOrders = await addPosOrderCollection(db);Store settings
useStoreSettings({ connector, context, loadChoice, saveChoice }) resolves the store's currency, tax and pricing once after sign-in, and re-resolves only when connector or context change by identity -- so memoise both. A 'choose' state means the store sells in more than one place; render StoreSettingsChoiceScreen with its choices and hand its onSubmit straight to store.choose.
import { useMemo, type ReactNode } from 'react';
import { createMedusaConnector } from '@tallyui/connector-medusa';
import { useStoreSettings } from '@tallyui/pos';
import { StoreSettingsChoiceScreen } from '@tallyui/components';
import type { StoreSettingsChoice } from '@tallyui/core';
declare const baseUrl: string, headers: Record<string, string>;
declare const loadChoice: () => StoreSettingsChoice | undefined, saveChoice: (choice: StoreSettingsChoice) => void;
function StoreGate({ children }: { children: ReactNode }) {
// One connector per store session: a new one when the store changes.
const connector = useMemo(() => createMedusaConnector(), [baseUrl]);
const context = useMemo(() => ({ connectorId: connector.id, baseUrl, headers }), [connector, baseUrl, headers]);
const store = useStoreSettings({ connector, context, loadChoice, saveChoice });
if (store.state === 'choose') {
return <StoreSettingsChoiceScreen choices={store.choices} initial={store.initial} onSubmit={store.choose} />;
}
return store.state === 'ready' ? children : null;
}Mount a single <PortalHost /> (from @tallyui/primitives) once at the app's root, after navigation -- above StoreGate, not inside a screen -- or dialogs, popovers and sheets render under the screen's own header instead of above it.
Pricing and tax
withPricingContext(context, settings) folds the resolved settings' opaque pricing context into the SyncContext that startReplication uses, and taxProviderProps(settings) turns the same settings into <TaxProvider>'s rates and inclusivity. Medusa needs that pricing context for its calculated-price replication; Vendure instead takes settings.pricesIncludeTax, plus vendureGlobalStockSettings(context)'s channel defaults, as options to createVendureConnector.
import type { ReactNode } from 'react';
import { startReplication } from '@tallyui/database';
import { withPricingContext, taxProviderProps, TaxProvider } from '@tallyui/pos';
import type { SyncContext, StoreSettings, TallyConnector } from '@tallyui/core';
import type { TallyDatabase } from '@tallyui/database';
declare const connector: TallyConnector, db: TallyDatabase;
declare const context: SyncContext, settings: StoreSettings;
declare const children: ReactNode;
startReplication({
collection: db.products,
adapter: connector.replication!.products!,
context: withPricingContext(context, settings),
});
function Totals() {
return <TaxProvider {...taxProviderProps(settings)}>{children}</TaxProvider>;
}Background checks
startStockReconcile, startIdReconcile and startFingerprintReconcile (for reconcile.prices and reconcile.calculatedPrices) each read the backend and enqueue what disagrees with the local documents. Medusa and Vendure each export their own base-price interval as *_PRICE_RECONCILE_INTERVAL_MS from their reconcile/prices module rather than from the package root, so pass it as a literal or take the runner's 24-hour default; the calculated-price runner's own MEDUSA_CALCULATED_PRICE_RECONCILE_INTERVAL_MS is exported. A call to reconcileStock(), reconcileIds() or reconcile() right after a sale, or on foreground, folds into any pass already running rather than waiting for the interval (#108).
startIdReconcile and startFingerprintReconcile are wrappers over the catalogue runner below (#248), so they share its rules:
- A persisted daily gate. A pass runs at a gate check (after
startDelayMs, then every hour, or everyintervalMs / 2when that is shorter) only when none has completed withinintervalMs, or a stopped pass is waiting to resume. The gate lives in a local document on the collection, so a restart does not run a pass. Create the collection withcreateTallyDatabaseorconnectorCollection, which turn local documents on. - One gate per runner. Two fingerprint runners on one collection need their own
stateId, ascalculated-pricesbelow. - A request budget, no page cap. At most 30 adapter calls in any minute;
maxPagesis accepted but ignored, so a large catalogue never truncates. - Refetch as it goes, delete only after a whole pass. A difference is enqueued with its page. A deletion waits for a pass that ran from the first page to the last in one go, and the reconcile feed's by-id re-read still decides it: a product that comes back is re-delivered, not deleted. A pass resumed after an error never deletes, and the fingerprint runner never deletes.
A connector with reconcile.catalogue gets the catalogue check directly: one walk of the whole catalogue that refetches what differs and deletes only what the adapter's required confirmGone confirms, under the mass-delete brake. Its corrections reach the collection through the connector's reconcile feed and the pull, never a local write. A till error stops it until reconcile() (call it after sign-in); a store error skips it until the next gate check; a transient error retries with backoff. log receives what each pass refetched, deleted and kept, and why a pass stopped or was skipped; there is no cashier notice. Call reconcile() after a sale, on foreground, after sign-in, and when shouldReconcileAfterGap says the last successful pull is more than 6 hours old.
import {
startStockReconcile, startIdReconcile, startFingerprintReconcile, startCatalogueReconcile, shouldReconcileAfterGap,
} from '@tallyui/database';
import { MEDUSA_CALCULATED_PRICE_RECONCILE_INTERVAL_MS } from '@tallyui/connector-medusa';
import type { TallyConnector, SyncContext } from '@tallyui/core';
import type { TallyDatabase } from '@tallyui/database';
declare const connector: TallyConnector, db: TallyDatabase;
declare const context: SyncContext, reSync: () => void, lastPullAt: number | undefined;
startStockReconcile({ collection: db.stock_levels, adapter: connector.reconcile!.stock!, context });
startIdReconcile({ collection: db.products, adapter: connector.reconcile!.ids!, context, reSync });
// 24h, matching *_PRICE_RECONCILE_INTERVAL_MS in each connector's reconcile/prices module.
startFingerprintReconcile({ collection: db.products, adapter: connector.reconcile!.prices!, context, reSync, intervalMs: 24 * 60 * 60 * 1000 });
startFingerprintReconcile({
collection: db.products, adapter: connector.reconcile!.calculatedPrices!, context, reSync,
intervalMs: MEDUSA_CALCULATED_PRICE_RECONCILE_INTERVAL_MS, stateId: 'calculated-prices',
});
// A connector with a catalogue adapter (#248):
const catalogue = startCatalogueReconcile({
collection: db.products, adapter: connector.reconcile!.catalogue!, context, reSync,
log: (event) => console.info('catalogue reconcile', event),
});
if (shouldReconcileAfterGap(lastPullAt)) catalogue.reconcile();Status
ConnectorStatus shows the fingerprint runner's own state: unsoldCount={state.lastResult?.unreported} for products the sales channel doesn't sell, and unsoldStale={!isFingerprintResultCurrent(state)} so a result older than the last failure reads as stale rather than current. ProductGrid hides those same unsellable products by default; pass showUnsellable to show them anyway.
import { ConnectorStatus, ProductGrid, ProductCard } from '@tallyui/components';
import { isFingerprintResultCurrent } from '@tallyui/database';
import type { FingerprintReconcileState } from '@tallyui/database';
declare const state: FingerprintReconcileState, products: unknown[];
function Status() {
return (
<>
<ConnectorStatus
name="Medusa"
status="connected"
unsoldCount={state.lastResult?.unreported}
unsoldStale={!isFingerprintResultCurrent(state)}
/>
<ProductGrid items={products} renderItem={(doc) => <ProductCard doc={doc} />} />
</>
);
}One live tab
Exactly one tab is ever live (ADR-061); startLiveTab's onPark runs when this tab hands over, and must stop the background runners, close every database with the storage worker still alive (capped, since a healthy close takes milliseconds), then call storage.terminate() -- terminating first leaves the next open hanging. See the Live Tab page for the full protocol, the parked screen and the hand-over timing; this guide doesn't repeat it.
import { startLiveTab } from '@tallyui/database';
import type { TallyDatabase } from '@tallyui/database';
declare const storeId: string, databases: TallyDatabase[];
declare const storage: { terminate(): void }, stopRunners: () => void;
const PARK_CLOSE_LIMIT_MS = 3_000;
const liveTab = startLiveTab({
scope: storeId,
onPark: async () => {
stopRunners();
await Promise.race([
Promise.all(databases.map((db) => db.close())),
new Promise((resolve) => setTimeout(resolve, PARK_CLOSE_LIMIT_MS)),
]);
storage.terminate();
},
});Selling
createOrderBuilder({ currency, taxContext }) builds up an order's lines, discounts and payments behind order$ and getSnapshot(). Record a cash payment's amountMinor as the full amount tendered, not a capped amount -- finalizeOrder caps it at the balance due and writes out tenderedMinor and changeMinor itself. finalizeOrder(order, { capabilities: context.capabilities }) turns a snapshot into a PosOrder ready for the outbox, and throws when the order carries a discount and the store's order.create capability is below 2 -- an old plugin can't take a discounted sale yet (ADR-062) -- or when a reference it passes through unchanged is over 255 characters or contains a NUL: registerId, cashierRef, a line's variant or product id, a payment's reference, and at capability 3 a line discount's id or a tax code. The server would refuse those, so the sale is refused before it's recorded; long product and discount names are shortened in the stored order instead, which is sent unchanged. stampSession(order, sessionId, sessions) is the only sanctioned way to put a sale in a register session: it checks the session is still open or counting and returns the order stamped with its id, closing the gap a stale, unchecked sessionId would leave. createOrderOutbox({ collection, transport, deviceId }), fed by createHttpCommandTransport, sends finalized orders and retries with backoff until the backend accepts them.
import { createOrderBuilder, finalizeOrder, stampSession, createOrderOutbox, createHttpCommandTransport } from '@tallyui/pos';
import type { TaxContext, PosOrder, RegisterSessionCollection } from '@tallyui/pos';
import type { SyncContext } from '@tallyui/core';
import type { RxCollection } from 'rxdb';
declare const taxContext: TaxContext, posOrders: RxCollection<PosOrder>, context: SyncContext;
declare const deviceId: string, getHeaders: () => Record<string, string>;
declare const sessions: RegisterSessionCollection, sessionId: string;
const order = createOrderBuilder({ currency: 'USD', taxContext });
order.addLine({ productId: 'prod_1', name: 'Espresso', unitPrice: { amount: 350, currency: 'USD' } });
order.addPayment({ method: 'cash', amountMinor: 350 });
// throws only when the order carries a discount and the store's capability is below 2 (ADR-062)
const draft = finalizeOrder(order.getSnapshot(), { capabilities: context.capabilities });
const posOrder = await stampSession(draft, sessionId, sessions); // checks the session is still live
const outbox = createOrderOutbox({
collection: posOrders,
transport: createHttpCommandTransport({ baseUrl: 'https://my-medusa-backend.com', getHeaders }),
deviceId,
});
outbox.start();