@medusapos/medusa-plugin
v0.2.4
Published
Tally POS commands for Medusa: idempotent order ingestion from offline registers.
Readme
Medusa POS plugin
Medusa 2.21 plugin for POST /tally/v1/commands to ingest POS orders exactly once.
This is a standalone npm package outside the pnpm workspace.
Install
Requires a Medusa 2.21.0 backend (@medusajs/framework and @medusajs/medusa peer dependencies at 2.21.0) and Node ^20.19.0 || >=22.12.0.
In the store's backend directory, run:
npm install @medusapos/medusa-pluginAdd this entry to the plugins list in medusa-config.ts, keeping any existing plugins:
plugins: [{ resolve: '@medusapos/medusa-plugin', options: {} }]Then run:
npx medusa db:migrateRestart the backend. Optional options (salesChannelId, locationId, shippingOptionId), till setup, and running more than one Medusa instance are covered in the quickstart.
Develop
Run from this directory:
npm ci
npm run typecheck
npm run test:unit -- --maxWorkers=2
npm run test:integration:modules -- --maxWorkers=1
npm run test:integration:http -- --maxWorkers=1
npm run db:generate
npm run buildModule integration tests need local Postgres; set DB_HOST, DB_USERNAME, and DB_PASSWORD.
The test helper defaults to role postgres; use DB_USERNAME=claude if that is your local role.
Set MEDUSA_DISABLE_TELEMETRY=true and XDG_CONFIG_HOME=$PWD/.medusa/xdg for Medusa commands.
HTTP integration tests boot the apps in integration-tests/app and integration-tests/plugin-app
and create/drop their own temporary databases; they do not use the dev store.
For npm run test:integration:http, CI uses random ports; outside CI each checkout gets its own block of 4 ports (in 40000–47999, one per jest worker, so at most 4 workers), printed at the start of the run, and setting PORT overrides it. A port held on 127.0.0.1, ::1, 0.0.0.0 or :: stops the run before any test, naming the port and each busy address. Running jest <spec> directly (without the npm script, so without TEST_TYPE) skips the port block and this check.
The HTTP test script builds the plugin first so plugin-app loads its published output.
Running more than one Medusa instance
More than one Medusa instance requires a shared locking provider: Redis (@medusajs/medusa/locking-redis, below) or
Postgres advisory locks (@medusajs/medusa/locking-postgres, provider id locking-postgres). The plugin serialises stock updates through
Medusa's locking module, and its default in-memory provider only locks inside one process, so concurrent sales on
two instances can lose stock updates and oversell (integration-tests/http/stock-lock.spec.ts). While the in-memory
provider is active, the tally-locking-check job logs a warning once at startup; the plugin still starts. After
switching to locking-postgres, run npx medusa db:migrate: it ships a migration that creates its locking table
(@medusajs/locking-postgres/dist/migrations/Migration20241009222919_InitialSetupMigration.js).
modules: [{ resolve: '@medusajs/medusa/locking', options: { providers: [{ resolve: '@medusajs/medusa/locking-redis',
id: 'locking-redis', is_default: true, options: { redisUrl: process.env.LOCKING_REDIS_URL } }] } }]Command endpoint
Register the plugin in the tester's medusa-config.ts:
plugins: [{
resolve: '@medusapos/medusa-plugin',
options: {
// Optional: salesChannelId, locationId, shippingOptionId
},
}]Run npx medusa db:migrate. Add the hosted POS origin to both ADMIN_CORS and
AUTH_CORS. Have a shipping option at the POS stock location on the POS products' shipping
profile, or set shippingOptionId.
The optional salesChannelId and locationId select the POS sales channel and stock location.
Sign in as a Medusa admin user through /auth/user/emailpass and send its JWT as
Authorization: Bearer <jwt> (an authenticated admin session is also accepted).
Send POST /tally/v1/commands with X-Tally-Protocol: 1 and JSON
{ commands: CommandEnvelope[] } containing 1 to MAX_COMMANDS commands: order.create (version 1, 2, 3, 4 or 5)
or the five register commands (version 1; see Registers).
MAX_COMMANDS and MAX_BODY_BYTES (src/api/tally/v1/commands/process.ts) are the contract's limits. The endpoint parses only application/json; a body of any other content type isn't read and gets a 400.
Every envelope includes id (1–64 characters), type (one of the six command types), version, object payload, string createdAt
and deviceId, and a safe integer attempt of at least 1.
A 200 { results: CommandResult[] } returns one result per command in the same order:
applied, duplicate with the original serverRefs and warnings, or rejected.
Reusing an id with a different payload rejects it with
idempotency_mismatch; a stored rejection replays as rejected.
A recorded order.create id answers its recorded result before version, shape, bounds and fiscal checks; string bounds are email 254 and other strings 255 (customerId 64 and sessionId 36), with no NUL allowed before replay.
invalid_payload rejects a malformed payload shape before claiming, with the validation errors in the message. The shape is strict: a field the command's declared version doesn't know is refused by its path (for example lines[2].discountMinr), and the keys of a register command's counted and tillExpected must be cash or external (ADR 0012). For a register command, it also rejects what the current state refuses after the claim: an unknown session, a missing or already-voided void target, an id that belongs to another session, or a closure whose registerId isn't its session's drawer. Neither kind is stored in the ledger, so a resend is checked again.
store_configuration rejects a sale the store can't take yet, before any write. Examples: no sales channel, no stock location or address, a payload.locationId or plugin option locationId that names no stock location or one not assigned to the sale's sales channel, no shipping option at the location for the products' shipping profile, or a sale that mixes shipping profiles. The message names what to fix, it isn't stored, and the same command applies once the store is fixed (ADR 0004). When the sale resumes an order a crashed attempt already started, the same failures are a transient 503 instead, because writes have already happened.
unsupported_currency, like store_configuration, isn't stored, so the same command can apply once the store is fixed.
unknown_variant is stored: a line whose variant or product is deleted, whose product isn't published, or whose product isn't in the sale's sales channel stays rejected even if the product is published or added to the channel later.
An unsupported version is a per-command unsupported_version with error.data naming the highest supported version (orderCreate or register).
400: unsupported protocol ({ code: 'unsupported_protocol' }) or invalid envelope.401: no valid admin authentication.413 { code: 'batch_too_large', maxCommands: MAX_COMMANDS, message }: more thanMAX_COMMANDScommands;413 { code: 'body_too_large', maxBytes: MAX_BODY_BYTES, message }: a JSON body overMAX_BODY_BYTESbytes. Nothing is claimed; split the batch and resend. The parser reads and discards the whole oversized upload before it answers, so put a proxy body limit in front of the store to stop large uploads early.409 { code: 'in_progress', id }: this command is already being processed.503 { code: 'transient', id, message }: execution failed; its claim is released for retry.
A 409 or 503 stops the batch at that command. Retry the whole batch; earlier
completed commands replay as duplicates. Network errors, 5xx, and 429 are also
retryable. Preflight OPTIONS needs no authentication; CORS uses ADMIN_CORS and
allows Authorization, Content-Type, and X-Tally-Protocol.
Version 2 (TallyUI ADR-062) adds discounts: lines[].discountMinor (the line's discount in its
own tax mode) and discountMinor, their sum; both positive when present. Version 2 without
discountMinor is invalid_payload. Each discounted line gets one code-less Medusa line-item
adjustment, "POS discount", in the line's tax mode, so tax is charged on the discounted amount.
Version 3 (TallyUI ADR-065) adds optional fields:
displayandtaxByRate, both or neither: the receipt's own figures, stored as sent intally_pos_totalsv2 as the fiscal record (ADR 0012);sessionId, the register session, stored astally_session_id;customer.customerId, the Medusa customer. When that customer exists, the order uses it without the till's email; otherwise the order uses the till's email as before.tally_customer_idis stored either way.
GET /tally/v1/info returns { "contracts": { "order.create": [1, 2, 3, 4, 5], "register": [1] } }, with the same
authentication and CORS as the command endpoint.
It also returns "taxRounding": { "granularity": "per_order", "mode": "half_away_from_zero" }: the store rounds tax once per order, half away from zero, so the till computes tax the same way (TallyUI #309).
Order creation workflow
runOrderCreate(container, command, options?, ledger?) is exported from @medusapos/medusa-plugin/workflows.
The command endpoint passes ledger (the claim token and any top-ups a crashed attempt
applied) so stock top-ups are recorded on the command's ledger row (ADR 0003 amendment);
without it, nothing is recorded there.
It resolves Medusa data, validates with the pure planner, and runs tallyOrderCreateWorkflow:
draft → convert → collect the POS total → mark paid → fulfill → complete.
Each draft item is tax-inclusive per its line's optional taxInclusive, else the payload's pricesIncludeTax.
The payment collection uses totalMinor exactly, including when Medusa's unrounded
tax total differs; the result reports the rounded server total and any total_mismatch warning.
Payments use the system provider, which moves no money. Later failures compensate
the order and inventory; capture itself has no refund compensation.
Options salesChannelId, locationId, and shippingOptionId override the store/channel
defaults and the automatic shipping option; payload locationId takes precedence.
The automatic shipping option is the location's earliest one on the sale's products' shipping profile.
A sale's products may use at most one shipping profile (products without one are ignored);
otherwise it's a store_configuration rejection. An explicit
shippingOptionId must use the products' profile.
Stock never rejects an offline sale: availability is checked at the sale location, shortfalls
are temporarily added before the draft and taken back after fulfillment, with compensation.
Stock ends at original minus sold and may go negative. Each short variant gets an
insufficient_stock warning whose quantity is the shortfall, after any total_mismatch.
Replays trust only completed live orders with the same metadata.tally_client_id.
Half-made orders resume conversion, payment, remaining fulfillment, recorded stock take-back,
and completion on the same order, never cancelling or deleting it; resume errors throw for retry.
A stock refusal from Medusa is treated as a race and retried, never a rejection.
Known limit: a channel with several stock locations may reserve at another location than
the sale's; the dev store has one. See the stock ADR.
Command execution
executeOrderCreate(container, command, options?) in workflows/tally-order-create fingerprints
and claims the command, then holds a Postgres session advisory lock for its clientOrderId
on a dedicated connection through the run and completion. It re-checks the claim token
after acquiring the lock, so an expired worker cannot start after a re-claim.
Outcomes are result (applied, duplicate with original references and warnings, or rejected),
in_progress (retryable 409), and transient (503 with the thrown error's message).
Failures release the claim for retry; a completed order is deduplicated on the next run.
The connection is always unlocked and released; a process crash drops the lock.
Ledger module
The tally_ledger module stores commands in tally_command with columns id
(the client's UUIDv7 idempotency key), type, fingerprint (SHA-256 of canonical
JSON of { type, version, payload }), claim_token, status, nullable JSON result, and
automatic created_at, updated_at, and deleted_at timestamps.
Statuses are in_progress (the default), applied, rejected, and needs_admin.
claim({ id, type, fingerprint }, context?) atomically inserts or re-claims an
in_progress command after a 120 s lease on updated_at, only for the same fingerprint.
It returns { claimed: true, claimToken, command } on success, otherwise
{ claimed: false, command }. A concurrent release before the read raises retryable CONFLICT.
complete(id, claimToken, result, context?) validates a TallyUI CommandResult
with matching id and applied or rejected status, then writes only for the current token.
Invalid results raise INVALID_DATA; unknown ids raise NOT_FOUND; finished or lost claims raise NOT_ALLOWED.
Stored results are parsed with the same validator when returned by claim or complete.
release(id, claimToken, context?) hard-deletes only an in_progress command
with the current token. Stale tokens, finished commands and unknown ids are left alone.
See the lease ADR.
needs_admin: a reclaimed order.create that finds its order in a state the recipe never creates (a refund, a partial
capture, a non-system payment provider or a canceled fulfilment) stops before any write. The row is parked with
needs_admin_reason ({ orderId, clientOrderId, detail }) and one error log line, claim never reclaims it, and the till gets 409 in_progress
on every resend. The script builds to .medusa/server/src/scripts/tally-ledger-resolve.js (checked after npm run build);
from the store's directory an admin runs npx medusa exec node_modules/@medusapos/medusa-plugin/.medusa/server/src/scripts/tally-ledger-resolve.js <commandId> apply|reject [message]:
apply (order fixed) makes the row reclaimable at once, so the next resend resumes it. reject first takes back any stock
top-up the plugin made for the sale and hasn't reversed (ADR 0003), then cancels the sale's live order with
cancelOrderWorkflow, then stores a platform_error rejection (data.platformCode: 'TALLY_ADMIN_REJECTED') that resends
replay; a new command id for the same sale then creates a new order. If the take-back or the cancel fails (for example an
uncanceled fulfilment), the script logs it, exits non-zero and leaves the row needs_admin: clean the order up by hand and
run reject again, not apply: an apply would leave the cancelled order unmarked, and it would count next to the new one.
Cancelling the order by hand is fine: reject still takes back the plugin's top-up. See the ADR 0003 amendment of 2026-09-29.
reject marks the canceled order tally_rejected, and register figures skip it.
Backfill for rejects made before the marking. A store needs it only if an admin ran tally-ledger-resolve reject
before the release that added the tally_rejected marker (#121): those cancelled orders still count in register figures.
The script is manual; nothing runs it automatically. On a real store, plan the run with the store's owner, because it changes
the figures of sessions that may already be closed. Run the dry run first, from the store's backend directory:
npx medusa exec node_modules/@medusapos/medusa-plugin/.medusa/server/src/scripts/tally-ledger-backfill-rejected.js
npx medusa exec node_modules/@medusapos/medusa-plugin/.medusa/server/src/scripts/tally-ledger-backfill-rejected.js apply
npx medusa exec node_modules/@medusapos/medusa-plugin/.medusa/server/src/scripts/tally-ledger-backfill-rejected.js undoIt marks only each rejection's own canceled order and warns about one that is not canceled. Without an argument it is a dry run that writes nothing;
apply writes tally_rejected and tally_rejected_by: 'backfill', and undo removes both from the orders apply marked.
The mode is a plain word, not --apply: medusa exec refuses a dashed option (Unknown argument: apply) and drops one given after --.
Each run logs each order and each affected register session's expected figures and sales count before and after.
Reading the dry run:
- one
command …, order …, session …: <method> <amount>line per order it would mark; - one
session … (open|closed): expected … -> …; salesCount … -> …line per affected session, where a closed session also showsvariance … -> …, the change an owner will see on that session's report; skipped: no orderIdlines for rejections it can't tie to an order (never guessed);- a final
would mark <n> order(s), skipped <m> (…).
would mark 0 means there's nothing to do. After apply, a second dry run says would mark 0, and undo puts the figures back.
These commands are the ones integration-tests/backfill-seed/run.sh runs in CI against a seeded store (there from the plugin's
own path rather than node_modules/…).
A new command id for the same live clientOrderId copies the original applied result, including its warnings,
and stores it for duplicate replays; a superseded original copies its applied successor's result.
A fresh lease or needs_admin row answers 503 without storing the new command; a busy sale lock or lost takeover
answers 409. A stale lease is taken over, the order resumes under the new command, and the original becomes
superseded by it, so resending the original returns the new result as a duplicate. A rejected original with
a live order answers 503 and logs an error; an order without a ledger row resumes as orphan recovery.
Registers
TallyUI registers c2 syncs a till's drawer to the store: sessions, cash movements and closures (the Z). The contract is TallyUI ADR-068, and the plugin's design is ADR 0019.
- Five commands go through the command endpoint and its ledger, at version 1:
register.session.open,register.session.transition,register.movement.record,register.movement.voidandregister.closure.submit. - The
tally_registermodule stores them intally_register,tally_register_session,tally_register_movementandtally_register_closure.- Unique indexes enforce one non-closed session per register, one closure per session, unique closure numbers and one void per movement.
- Row locks in the service enforce gap-free closure numbers and no movement after its session's closure.
- Business refusals are per-command rejections, stored in the ledger so a replay returns them:
register_session_already_open, witherror.data.sessionId;register_session_closed, with no data;register_closure_exists, witherror.data.closureId;register_closure_number_invalid, witherror.data.counters.
- Commands apply in the order received.
atandcreatedAtare never compared. Any transition is accepted except one out ofclosed, and a same-status transition is a no-op. Every operation is replay-safe by its till-minted id. - Server figures:
- session results carry
session.expected(per tender) andsession.salesCount; register.closure.submitresults carryclosure.expectedandclosure.variance(counted − expected), with nosalesCount.- They're derived from the orders'
tally_paymentsand the session's movements. - Orders count when completed, archived or canceled.
- Before the closure, only orders whose
tally_session_idis the session count, which meansorder.createv3 sales with asessionId. A v1 or v2 sale doesn't show up until the closure. - After the closure, the closure's
orderIdsdecide. - The till's own
tillExpectedandcountedare stored unchanged as the fiscal record.
- session results carry
GET /tally/v1/registers/{id}(the drawer id) returns{ counters, session? }: the register's counters, plus its latest session (the non-closed one first) withexpectedandsalesCount. An unknown register is a404. It uses the same admin authentication and CORS as the command endpoint.
The naming trap: order.create's registerId, stored as order metadata tally_register_id, is the till's device id (ADR 0017). The register commands, the tally_register tables and /tally/v1/registers/{id} mean the drawer. Never join the two.
Experimental sync (G4)
This is a change journal and two read routes for TallyUI's G4 sync experiment. The design is ADR 0020, and the event evidence is the G4 events spike. It's experimental and unversioned: the route shapes and the sync: [1] capability may change without a version bump until TallyUI's G2 names the driver interface. It's off by default.
The switch is the plugin option
experimentalSync: true. When it's off, the subscriber writes nothing, both routes answer404, and/tally/v1/infodoesn't listsync. The dev store reads it fromTALLY_EXPERIMENTAL_SYNC=1.The
tally_syncmodule keeps:- a
tally_changejournal (a gap-freebigserialseq, the collection, the object id, andupsertordelete); - a one-row
tally_sync_state(the epoch and the price-list watermark).
The first route call mints the epoch and backfills one
upsertrow per live product, once. There's no retention in G4.- a
What feeds the journal:
- One subscriber listens to the module entity events for products, variants, options and option values, prices, inventory levels and items, and to the product↔sales-channel, variant↔price-set and variant↔inventory-item link events. It resolves each to its product ids. The op is
deletewhen the product is soft-deleted,upsertotherwise. - Price lists emit no event when they're updated, change status, or start or end. A watcher covers them: it runs on every
/changes/tick(at most once every 5 s per process) and every minute as a scheduled job. It journals the products of any price list whoseupdated_atpassed the watermark, or whosestarts_atorends_atfell since the last run.
- One subscriber listens to the module entity events for products, variants, options and option values, prices, inventory levels and items, and to the product↔sales-channel, variant↔price-set and variant↔inventory-item link events. It resolves each to its product ids. The op is
GET /tally/v1/changes?since=&limit=&collections=&epoch=returns{ epoch, head, horizon, changes: [{ seq, collection, id, op, revision }], more }. A cursor from another epoch, one ahead ofhead, orsince > 0without anepoch, gets410 { code: 'cursor_expired', epoch, head }.GET /tally/v1/changes/tick?since=&epoch=returns304when nothing changed, otherwise{ epoch, head, horizon }.Both routes use the same admin authentication and CORS as the command endpoint.
Only writes made by the server (or its workers) are journaled. Events reach subscribers in the process that wrote. On the local bus they never leave it, and on the Redis bus they're dropped at the emitter when that process has no subscriber for them (spike findings).
medusa execscripts are not journaled. On the local bus, 5 of 5 direct price writes frommedusa execcommitted, and none reached the journal: no subscriber ran before the script exited.Catch up with a rescan after an import, a script or raw SQL, from the store's backend directory:
npx medusa exec ./node_modules/@medusapos/medusa-plugin/.medusa/server/src/scripts/tally-sync-rescan.js <since>.sinceis a required ISO timestamp that must include a timezone (Zor±hh:mm, e.g.2026-09-29T10:00:00Z): use the time the import or script started, a little earlier to be safe; duplicates are harmless. It journals every product whose row, variants, prices, inventory items or levels, options, option values, inventory-item and price-set links, or sales-channel links have anupdated_atordeleted_ataftersince. A soft-deleted product getsdelete, any otherupsert. It doesn't cover price lists, which are the watcher's job (any/changes/tickruns it), or hard-deleted rows. WithexperimentalSyncoff it does nothing, and before the first route call it logsjournal not initialized. The path above assumes the plugin is installed under the backend's ownnode_modules; in a hoisted monorepo, pointmedusa execat wherever@medusapos/medusa-pluginresolves instead, for example withnode -p "require.resolve('@medusapos/medusa-plugin/package.json')"(documentation only, not something we run).Catalogue changes made by a script reach tills through a rescan, or sooner through a later edit of the same products. Before initialization, the install backfill covers them.
A demo reset clears the journal and the sync state, so the next route call mints a new epoch. Every till's cursor then answers
410 cursor_expired, and the till resyncs.
Journal ordering: every journal write takes one advisory lock, so seq order is commit order and a cursor never skips a row. The lock wait is capped at 10 s. An event that times out is retried once, then logged as tally_sync: journal lock timeout: … dropped with its event name and id.
Not covered, so these reach tills only through a later edit (or, from P3, the digest audit):
- writes from
medusa execscripts or any other process that doesn't run the server, and raw SQL; - rows Medusa hard-deletes (a price removed through a price-set update, option values, product↔option links), when no sibling event fires. Every admin workflow measured does fire one.
- a price-list change whose transaction commits longer than 10 s after its
updated_at(the watermark lagsnow()by 10 s); - an event dropped after its lock-timeout retry;
- an event lost if the process crashes between the commit and the subscriber (ADR 0020, E2.1).
