@tallyui/pos 0.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +270 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- package/src/tax/tax-provider.test.tsx +0 -51
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { addRxPlugin, type MigrationStrategies, type RxJsonSchema } from 'rxdb';
|
|
2
|
+
import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
|
|
3
|
+
import type { PosOrder } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Version 1 adds the optional `sessionId` (ADR-032); nothing else changed from version 0.
|
|
7
|
+
* Version 2 adds three optional fields and changes nothing else: `lateSessionId` (ADR-032, late
|
|
8
|
+
* sale), and ADR-065's `display` and `taxByRate`, declared ahead of the job that writes them so a
|
|
9
|
+
* till migrates once. Create the collection with `posOrderCollection()`, never with this schema
|
|
10
|
+
* alone: RxDB refuses a version above 0 without its migration strategies.
|
|
11
|
+
*/
|
|
12
|
+
export const posOrderSchema: RxJsonSchema<PosOrder> = {
|
|
13
|
+
version: 2, primaryKey: 'id', type: 'object', additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
id: { type: 'string', maxLength: 36 },
|
|
16
|
+
commandId: { type: 'string', maxLength: 36 },
|
|
17
|
+
createdAt: { type: 'string', maxLength: 40 },
|
|
18
|
+
updatedAt: { type: 'string' },
|
|
19
|
+
currency: { type: 'string' },
|
|
20
|
+
pricesIncludeTax: { type: 'boolean' },
|
|
21
|
+
subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' },
|
|
22
|
+
taxMinor: { type: 'integer' }, totalMinor: { type: 'integer' },
|
|
23
|
+
syncStatus: { type: 'string', enum: ['pending', 'applied', 'rejected'], maxLength: 10 },
|
|
24
|
+
note: { type: 'string' }, registerId: { type: 'string' }, sessionId: { type: 'string' }, cashierRef: { type: 'string' },
|
|
25
|
+
lines: { type: 'array', items: {
|
|
26
|
+
type: 'object', properties: {
|
|
27
|
+
id: { type: 'string', maxLength: 36 }, productId: { type: 'string' }, variantId: { type: 'string' },
|
|
28
|
+
name: { type: 'string' }, sku: { type: 'string' }, quantity: { type: 'integer' },
|
|
29
|
+
unitPriceMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, netMinor: { type: 'integer' },
|
|
30
|
+
taxLines: { type: 'array', items: {
|
|
31
|
+
type: 'object', properties: { code: { type: 'string' }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' } },
|
|
32
|
+
required: ['ratePpm', 'taxMicros'],
|
|
33
|
+
} },
|
|
34
|
+
},
|
|
35
|
+
required: ['id', 'productId', 'name', 'sku', 'quantity', 'unitPriceMinor', 'discountMinor', 'netMinor', 'taxLines'],
|
|
36
|
+
} },
|
|
37
|
+
payments: { type: 'array', items: {
|
|
38
|
+
type: 'object', properties: {
|
|
39
|
+
id: { type: 'string', maxLength: 36 }, method: { type: 'string', enum: ['cash', 'external'] },
|
|
40
|
+
amountMinor: { type: 'integer' }, tenderedMinor: { type: 'integer' }, changeMinor: { type: 'integer' }, reference: { type: 'string' },
|
|
41
|
+
}, required: ['id', 'method', 'amountMinor'],
|
|
42
|
+
} },
|
|
43
|
+
customer: { type: ['object', 'null'], properties: { id: { type: 'string' }, name: { type: 'string' }, email: { type: 'string' } } },
|
|
44
|
+
serverRefs: { type: 'object', properties: {
|
|
45
|
+
orderId: { type: 'string' }, displayId: { type: 'string' }, totalMinor: { type: 'integer' },
|
|
46
|
+
}, required: ['orderId', 'totalMinor'] },
|
|
47
|
+
warnings: { type: 'array', items: {
|
|
48
|
+
type: 'object', properties: { code: { type: 'string', maxLength: 64 } },
|
|
49
|
+
required: ['code'], additionalProperties: true,
|
|
50
|
+
} },
|
|
51
|
+
error: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } }, required: ['code', 'message'] },
|
|
52
|
+
lateSessionId: { type: 'string' },
|
|
53
|
+
// The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
|
|
54
|
+
display: { type: 'object', additionalProperties: false, properties: {
|
|
55
|
+
currency: { type: 'string' }, exponent: { type: 'integer' }, taxInclusive: { type: 'boolean' },
|
|
56
|
+
subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, taxMinor: { type: 'integer' },
|
|
57
|
+
totalMinor: { type: 'integer' }, orderDiscountMinor: { type: 'integer' },
|
|
58
|
+
lines: { type: 'array', items: {
|
|
59
|
+
type: 'object', additionalProperties: false, properties: {
|
|
60
|
+
lineId: { type: 'string' }, amountMinor: { type: 'integer' },
|
|
61
|
+
discounts: { type: 'array', items: {
|
|
62
|
+
type: 'object', additionalProperties: false,
|
|
63
|
+
properties: { discountId: { type: 'string' }, label: { type: 'string' }, amountMinor: { type: 'integer' } },
|
|
64
|
+
required: ['discountId', 'amountMinor'],
|
|
65
|
+
} },
|
|
66
|
+
}, required: ['lineId', 'amountMinor', 'discounts'],
|
|
67
|
+
} },
|
|
68
|
+
}, required: ['currency', 'exponent', 'taxInclusive', 'subtotalMinor', 'discountMinor', 'taxMinor', 'totalMinor', 'orderDiscountMinor', 'lines'] },
|
|
69
|
+
taxByRate: { type: 'array', items: {
|
|
70
|
+
type: 'object', additionalProperties: false, properties: {
|
|
71
|
+
ratePpm: { type: 'integer' }, code: { type: 'string' }, label: { type: 'string' }, netMinor: { type: 'integer' },
|
|
72
|
+
amountMinor: { type: 'integer' }, grossMinor: { type: 'integer' },
|
|
73
|
+
}, required: ['ratePpm', 'netMinor', 'amountMinor', 'grossMinor'],
|
|
74
|
+
} },
|
|
75
|
+
},
|
|
76
|
+
required: ['id', 'createdAt', 'currency', 'pricesIncludeTax', 'lines', 'subtotalMinor', 'discountMinor', 'taxMinor',
|
|
77
|
+
'totalMinor', 'payments', 'customer', 'syncStatus', 'commandId', 'updatedAt'],
|
|
78
|
+
indexes: ['createdAt', 'syncStatus', ['syncStatus', 'createdAt']],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The `pos_orders` collection config, with its migration strategies. Open the collection with
|
|
83
|
+
* `addPosOrderCollection(db)`, which uses this and settles the migration safely; adding this config
|
|
84
|
+
* directly leaves RxDB's own open path. `pos_orders` holds sales not yet sent, so no step may drop a
|
|
85
|
+
* document: versions 1 and 2 only add optional fields, so every order from version 0 or 1 passes
|
|
86
|
+
* unchanged.
|
|
87
|
+
*
|
|
88
|
+
* Within one run, RxDB 16.21 keeps an order that fails the new schema's validation: with a
|
|
89
|
+
* validating storage the migration stops with DM4 and the order stays in the older version's
|
|
90
|
+
* storage; without one it is copied as is. **Across runs, RxDB's own open path can lose it**: a
|
|
91
|
+
* failed run can leave its checkpoint past that order, and the next run then removes the older
|
|
92
|
+
* version's storage without copying it. `addPosOrderCollection` resets that checkpoint, so use it
|
|
93
|
+
* (`migration.test.ts`).
|
|
94
|
+
*/
|
|
95
|
+
export function posOrderCollection(): { schema: RxJsonSchema<PosOrder>; migrationStrategies: MigrationStrategies } {
|
|
96
|
+
// addRxPlugin ignores a plugin it already has.
|
|
97
|
+
addRxPlugin(RxDBMigrationSchemaPlugin);
|
|
98
|
+
const identity = (doc: PosOrder) => doc;
|
|
99
|
+
return { schema: posOrderSchema, migrationStrategies: { 1: identity, 2: identity } };
|
|
100
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CommandError, CommandServerRefs, CommandWarning, PaymentMethodKind } from '@tallyui/core';
|
|
2
|
+
import type { DisplayTotals } from '../order/types';
|
|
3
|
+
|
|
4
|
+
export type PosOrderSyncStatus = 'pending' | 'applied' | 'rejected';
|
|
5
|
+
|
|
6
|
+
export interface PosOrderLine {
|
|
7
|
+
id: string;
|
|
8
|
+
productId: string;
|
|
9
|
+
variantId?: string;
|
|
10
|
+
name: string;
|
|
11
|
+
sku: string;
|
|
12
|
+
quantity: number;
|
|
13
|
+
unitPriceMinor: number;
|
|
14
|
+
/** Line discounts plus the allocated order-discount share, in the line's own tax mode (ADR-062). */
|
|
15
|
+
discountMinor: number;
|
|
16
|
+
netMinor: number;
|
|
17
|
+
taxLines: Array<{ code?: string; ratePpm: number; taxMicros: string }>;
|
|
18
|
+
/** This line's own tax mode; set only when it was converted from the store's (ADR-038 amendment). */
|
|
19
|
+
taxInclusive?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PosOrderPayment {
|
|
23
|
+
id: string;
|
|
24
|
+
method: PaymentMethodKind;
|
|
25
|
+
amountMinor: number;
|
|
26
|
+
tenderedMinor?: number;
|
|
27
|
+
changeMinor?: number;
|
|
28
|
+
reference?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PosOrder {
|
|
32
|
+
id: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
currency: string;
|
|
35
|
+
pricesIncludeTax: boolean;
|
|
36
|
+
lines: PosOrderLine[];
|
|
37
|
+
subtotalMinor: number;
|
|
38
|
+
discountMinor: number;
|
|
39
|
+
taxMinor: number;
|
|
40
|
+
totalMinor: number;
|
|
41
|
+
payments: PosOrderPayment[];
|
|
42
|
+
customer: { id?: string; name?: string; email?: string } | null;
|
|
43
|
+
note?: string;
|
|
44
|
+
registerId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The register session the sale was taken in (ADR-032): its closure counts this order.
|
|
47
|
+
* Local only: `toOrderCreateEnvelope` never sends it.
|
|
48
|
+
*/
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Set only by `useSale`'s late-sale path (ADR-032): the session the sale was taken for, which
|
|
52
|
+
* refused the stamp after the money was taken. Such an order has no `sessionId`, so no closure
|
|
53
|
+
* counts it. Local only: `toOrderCreateEnvelope` never sends it.
|
|
54
|
+
*/
|
|
55
|
+
lateSessionId?: string;
|
|
56
|
+
/** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. Not written yet. */
|
|
57
|
+
display?: DisplayTotals & { currency: string; exponent: number };
|
|
58
|
+
/** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). Not written yet. */
|
|
59
|
+
taxByRate?: Array<{ ratePpm: number; code?: string; label?: string; netMinor: number; amountMinor: number; grossMinor: number }>;
|
|
60
|
+
cashierRef?: string;
|
|
61
|
+
syncStatus: PosOrderSyncStatus;
|
|
62
|
+
commandId: string;
|
|
63
|
+
serverRefs?: CommandServerRefs;
|
|
64
|
+
warnings?: CommandWarning[];
|
|
65
|
+
error?: CommandError;
|
|
66
|
+
updatedAt: string;
|
|
67
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function format(ms: number, bytes: Uint8Array): string {
|
|
2
|
+
let timestamp = BigInt(ms);
|
|
3
|
+
for (let i = 5; i >= 0; i--) {
|
|
4
|
+
bytes[i] = Number(timestamp & 0xffn);
|
|
5
|
+
timestamp >>= 8n;
|
|
6
|
+
}
|
|
7
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
8
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
9
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
10
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createUuidv7(options?: {
|
|
14
|
+
now?: () => number;
|
|
15
|
+
randomBytes?: (n: number) => Uint8Array;
|
|
16
|
+
}): () => string {
|
|
17
|
+
let lastMs = -Infinity;
|
|
18
|
+
let lastRand = 0n;
|
|
19
|
+
return () => {
|
|
20
|
+
const randomBytes = options?.randomBytes;
|
|
21
|
+
if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error('uuidv7: no random source');
|
|
22
|
+
let ms = Math.max((options?.now ?? Date.now)(), lastMs);
|
|
23
|
+
let rand = lastRand + 1n;
|
|
24
|
+
if (ms === lastMs && rand === 2n ** 74n) ms = lastMs + 1;
|
|
25
|
+
if (ms !== lastMs) {
|
|
26
|
+
const bytes = randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
27
|
+
rand = (BigInt(bytes[6] & 0x0f) << 8n) | BigInt(bytes[7]);
|
|
28
|
+
rand = (rand << 6n) | BigInt(bytes[8] & 0x3f);
|
|
29
|
+
for (let i = 9; i < 16; i++) rand = (rand << 8n) | BigInt(bytes[i]);
|
|
30
|
+
}
|
|
31
|
+
lastMs = ms;
|
|
32
|
+
lastRand = rand;
|
|
33
|
+
const bytes = new Uint8Array(16);
|
|
34
|
+
for (let i = 15; i >= 9; i--) {
|
|
35
|
+
bytes[i] = Number(rand & 0xffn);
|
|
36
|
+
rand >>= 8n;
|
|
37
|
+
}
|
|
38
|
+
bytes[8] = Number(rand & 0x3fn);
|
|
39
|
+
rand >>= 6n;
|
|
40
|
+
bytes[7] = Number(rand & 0xffn);
|
|
41
|
+
bytes[6] = Number(rand >> 8n);
|
|
42
|
+
return format(ms, bytes);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const monotonic = createUuidv7();
|
|
47
|
+
|
|
48
|
+
/** RFC 9562 UUIDv7: monotonic with no arguments; stateless with an explicit timestamp or random source. */
|
|
49
|
+
export function uuidv7(now?: number, randomBytes?: (n: number) => Uint8Array): string {
|
|
50
|
+
if (now === undefined && randomBytes === undefined) return monotonic();
|
|
51
|
+
const ms = now ?? Date.now();
|
|
52
|
+
if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error('uuidv7: no random source');
|
|
53
|
+
return format(ms, randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ProductTraits } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filters product documents by a cashier's search term, through traits, so
|
|
5
|
+
* it works the same on every backend.
|
|
6
|
+
*
|
|
7
|
+
* Every word of the term must appear in the product name, SKU or barcode
|
|
8
|
+
* (case-insensitive, any order). A term that exactly equals a barcode or SKU
|
|
9
|
+
* returns just those products, so a scanner hit is never buried among name
|
|
10
|
+
* matches. An empty term returns the input unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export function searchProducts<Doc>(docs: Doc[], term: string, traits: ProductTraits<Doc>): Doc[] {
|
|
13
|
+
const query = term.trim().toLowerCase();
|
|
14
|
+
if (!query) return docs;
|
|
15
|
+
|
|
16
|
+
const codes = (doc: Doc) =>
|
|
17
|
+
[traits.getSku(doc), traits.getBarcode(doc)]
|
|
18
|
+
.filter((code): code is string => Boolean(code))
|
|
19
|
+
.map((code) => code.toLowerCase());
|
|
20
|
+
|
|
21
|
+
const exact = docs.filter((doc) => codes(doc).includes(query));
|
|
22
|
+
if (exact.length) return exact;
|
|
23
|
+
|
|
24
|
+
const words = query.split(/\s+/);
|
|
25
|
+
return docs.filter((doc) => {
|
|
26
|
+
const haystack = [traits.getName(doc).toLowerCase(), ...codes(doc)].join(' ');
|
|
27
|
+
return words.every((word) => haystack.includes(word));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RxCollection } from 'rxdb';
|
|
2
|
+
import { map, type Observable } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
import { STOCK_LEVELS_LAST_PASS } from '@tallyui/core';
|
|
5
|
+
|
|
6
|
+
// The pure helpers live in core so components can use them without RxDB.
|
|
7
|
+
export { withStockOverlay, getProductStock } from '@tallyui/core';
|
|
8
|
+
|
|
9
|
+
/** The `stock_levels` collection as a live map of key to stock value. */
|
|
10
|
+
export function stockOverlay$(collection: RxCollection): Observable<Map<string, unknown>> {
|
|
11
|
+
return collection.find().$.pipe(
|
|
12
|
+
map((rows) => new Map(rows.map((row) => [row.primary, row.get('value')] as [string, unknown]))),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* When the overlay was last confirmed by a successful pass (ISO 8601), or
|
|
18
|
+
* undefined before any pass. Read from the collection's local document, so a
|
|
19
|
+
* tab or screen that does not hold the runner can show it too.
|
|
20
|
+
*/
|
|
21
|
+
export function stockOverlayAsOf$(collection: RxCollection): Observable<string | undefined> {
|
|
22
|
+
return collection.getLocal$<{ completedAt: string }>(STOCK_LEVELS_LAST_PASS).pipe(
|
|
23
|
+
map((doc) => doc?.get('completedAt') as string | undefined),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import type { Order } from '../order/types';
|
|
1
|
+
import type { Discount, Order } from '../order/types';
|
|
2
|
+
import { roundMicrosToMinor, taxLinesByRate } from '../tax/exact';
|
|
2
3
|
import type { ReceiptConfig, ReceiptData } from './types';
|
|
3
4
|
|
|
4
5
|
export function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptData {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
// Drops the shared helper's `netMinor` (the Z report's own use): the receipt's tax summary
|
|
7
|
+
// never showed it, and an exact-shape test elsewhere in this package pins that.
|
|
8
|
+
const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels).map(
|
|
9
|
+
({ label, code, ratePpm, amountMinor }) => ({ label, code, ratePpm, amountMinor }),
|
|
10
|
+
);
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// Every receipt line is shown in the order's mode. A line whose price had the other mode is
|
|
13
|
+
// converted by its tax share as the order total rounds it: the rounded running tax sum,
|
|
14
|
+
// started from the exclusive lines the order already rounds, so the shares add up exactly.
|
|
15
|
+
let taxSoFar = order.lineItems.reduce(
|
|
16
|
+
(sum, li) => li.taxInclusive || li.priceTaxModeConverted ? sum : sum + BigInt(li.taxMicros), 0n);
|
|
17
|
+
const lineTotals = order.lineItems.map((li) => {
|
|
18
|
+
if (!li.priceTaxModeConverted) return li.netMinor;
|
|
19
|
+
const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
|
|
20
|
+
taxSoFar += BigInt(li.taxMicros);
|
|
21
|
+
return li.taxInclusive ? li.netMinor - share : li.netMinor + share;
|
|
22
|
+
});
|
|
23
|
+
const label = (d: Discount) => d.label ?? d.couponCode ?? `${d.type} discount`;
|
|
16
24
|
|
|
17
25
|
return {
|
|
18
26
|
header: {
|
|
@@ -23,30 +31,36 @@ export function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptDa
|
|
|
23
31
|
cashier: config.cashier,
|
|
24
32
|
register: config.register,
|
|
25
33
|
},
|
|
26
|
-
lineItems: order.lineItems.map((li) => ({
|
|
34
|
+
lineItems: order.lineItems.map((li, index) => ({
|
|
27
35
|
name: li.name,
|
|
28
36
|
sku: li.sku,
|
|
29
37
|
quantity: li.quantity,
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
unitPriceMinor: li.unitPriceMinor,
|
|
39
|
+
lineTotalMinor: lineTotals[index],
|
|
40
|
+
// Line plus allocated order discounts, in the line's own mode; lineTotalMinor is already after it (ADR-062).
|
|
41
|
+
...(li.discountMinor > 0 ? { discountMinor: li.discountMinor } : {}),
|
|
42
|
+
displayAmountMinor: order.display.lines[index].amountMinor,
|
|
43
|
+
displayDiscounts: order.display.lines[index].discounts.map((row, i) => ({ label: label(li.discounts[i]), amountMinor: row.amountMinor })),
|
|
32
44
|
})),
|
|
33
45
|
discounts: order.discounts.map((d) => ({
|
|
34
|
-
label:
|
|
35
|
-
|
|
46
|
+
label: label(d),
|
|
47
|
+
amountMinor: d.amountMinor,
|
|
36
48
|
})),
|
|
49
|
+
orderDiscountMinor: order.display.orderDiscountMinor,
|
|
37
50
|
totals: {
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
taxInclusive: order.display.taxInclusive,
|
|
52
|
+
subtotalMinor: order.display.subtotalMinor,
|
|
53
|
+
discountMinor: order.display.discountMinor,
|
|
40
54
|
taxLines,
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
taxMinor: order.display.taxMinor,
|
|
56
|
+
totalMinor: order.display.totalMinor,
|
|
43
57
|
},
|
|
44
58
|
payments: order.payments.map((p) => ({
|
|
45
59
|
method: p.method,
|
|
46
|
-
|
|
60
|
+
amountMinor: p.amountMinor,
|
|
47
61
|
reference: p.reference,
|
|
48
62
|
})),
|
|
49
|
-
|
|
63
|
+
changeDueMinor: order.changeDueMinor,
|
|
50
64
|
footer: {
|
|
51
65
|
note: order.note || undefined,
|
|
52
66
|
barcode: order.id,
|
package/src/receipt/types.ts
CHANGED
|
@@ -2,8 +2,12 @@ export interface ReceiptLineItem {
|
|
|
2
2
|
name: string;
|
|
3
3
|
sku: string;
|
|
4
4
|
quantity: number;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
unitPriceMinor: number;
|
|
6
|
+
/** After every discount, including the line's order share, in the order's mode. Not the figure to print above the subtotal: use displayAmountMinor. */
|
|
7
|
+
lineTotalMinor: number;
|
|
8
|
+
discountMinor?: number; // this line's discounts, already taken off lineTotalMinor; absent when 0
|
|
9
|
+
displayAmountMinor: number; // before any discount, in the display mode; Σ = totals.subtotalMinor (ADR-063)
|
|
10
|
+
displayDiscounts: { label: string; amountMinor: number }[]; // this line's own discounts, in the display mode
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
export interface ReceiptData {
|
|
@@ -16,16 +20,18 @@ export interface ReceiptData {
|
|
|
16
20
|
register?: string;
|
|
17
21
|
};
|
|
18
22
|
lineItems: ReceiptLineItem[];
|
|
19
|
-
discounts: { label: string;
|
|
23
|
+
discounts: { label: string; amountMinor: number }[];
|
|
24
|
+
orderDiscountMinor: number; // the order discounts as one row, in the display mode; + Σ displayDiscounts = totals.discountMinor
|
|
20
25
|
totals: {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
taxInclusive: boolean; // from order.display: tax is added (false) or included (true)
|
|
27
|
+
subtotalMinor: number; // order.display.subtotalMinor: before discounts, in the display mode
|
|
28
|
+
discountMinor: number; // order.display.discountMinor: every discount, in the display mode (>= 0)
|
|
29
|
+
taxLines: { label: string; code?: string; ratePpm: number; amountMinor: number }[];
|
|
30
|
+
taxMinor: number; // order.display.taxMinor
|
|
31
|
+
totalMinor: number; // order.display.totalMinor
|
|
26
32
|
};
|
|
27
|
-
payments: { method: string;
|
|
28
|
-
|
|
33
|
+
payments: { method: string; amountMinor: number; reference?: string }[];
|
|
34
|
+
changeDueMinor: number;
|
|
29
35
|
footer: {
|
|
30
36
|
note?: string;
|
|
31
37
|
barcode?: string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "closure-fixture",
|
|
3
|
+
"session_id": "session-fixture",
|
|
4
|
+
"register_id": "register-fixture",
|
|
5
|
+
"store_key": "1",
|
|
6
|
+
"business_day": "2026-09-11",
|
|
7
|
+
"closed_by": "1",
|
|
8
|
+
"corrections_count": 0,
|
|
9
|
+
"number": 42,
|
|
10
|
+
"opened_at": "2026-09-11T08:00:00Z",
|
|
11
|
+
"closed_at": "2026-09-11T17:00:00Z",
|
|
12
|
+
"till_expected": { "cash": 18000, "card": 12000 },
|
|
13
|
+
"expected": { "cash": 18000, "card": 12000 },
|
|
14
|
+
"counted": { "cash": 17800, "card": 12000 },
|
|
15
|
+
"variance": { "cash": -200, "card": 0 },
|
|
16
|
+
"period_sales_total_minor": 25000,
|
|
17
|
+
"period_refunds_total_minor": 5000,
|
|
18
|
+
"perpetual_sales_total_minor": 525000,
|
|
19
|
+
"perpetual_refunds_total_minor": 25000,
|
|
20
|
+
"unsynced_count": 2,
|
|
21
|
+
"unsynced_total_minor": 1200,
|
|
22
|
+
"software_version": "preview",
|
|
23
|
+
"breakdowns": {
|
|
24
|
+
"currency": "USD",
|
|
25
|
+
"transaction_count": 12,
|
|
26
|
+
"refund_count": 2,
|
|
27
|
+
"cashiers": [{ "id": "1", "name": "Alex" }],
|
|
28
|
+
"register_name": "Main register",
|
|
29
|
+
"opened_by": "1",
|
|
30
|
+
"approved_by": "1",
|
|
31
|
+
"opened_by_name": "Alex",
|
|
32
|
+
"closed_by_name": "Alex",
|
|
33
|
+
"approved_by_name": "Alex",
|
|
34
|
+
"payment_methods": {
|
|
35
|
+
"cash": { "sales_minor": 13000, "refunds_minor": 5000 },
|
|
36
|
+
"card": { "sales_minor": 12000, "refunds_minor": 0 }
|
|
37
|
+
},
|
|
38
|
+
"tax_rates": {
|
|
39
|
+
"vat": { "name": "VAT 20%", "net_minor": 16667, "tax_minor": 3333, "gross_minor": 20000 }
|
|
40
|
+
},
|
|
41
|
+
"opening_float": { "expected_minor": 10000, "counted_minor": 10000, "variance_minor": 0 },
|
|
42
|
+
"movements": [
|
|
43
|
+
{
|
|
44
|
+
"id": "movement-fixture",
|
|
45
|
+
"type": "paid_out",
|
|
46
|
+
"amountMinor": 500,
|
|
47
|
+
"reason": "Petty cash",
|
|
48
|
+
"created_at_gmt": "2026-09-11T10:00:00Z",
|
|
49
|
+
"created_by": "1",
|
|
50
|
+
"voids": null,
|
|
51
|
+
"voided_by": "sample-void"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"order_ids": [],
|
|
56
|
+
"movement_ids": [],
|
|
57
|
+
"server_closure_id": null,
|
|
58
|
+
"printed_at": null,
|
|
59
|
+
"print_count": 0,
|
|
60
|
+
"number_retried": false
|
|
61
|
+
}
|