@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
@@ -0,0 +1,197 @@
1
+ {
2
+ "closure": {
3
+ "number": 42,
4
+ "has_sales": true,
5
+ "has_payment_methods": true,
6
+ "has_tax_rates": true,
7
+ "has_perpetual": true,
8
+ "has_movements": true,
9
+
10
+ "store_id": 1,
11
+ "opened_at_gmt": "2026-09-11 08:00:00",
12
+ "closed_at_gmt": "2026-09-11 17:00:00",
13
+ "opened_by": 1,
14
+ "closed_by": 1,
15
+ "approved_by": 1,
16
+ "expected": {
17
+ "cash": "180.0000",
18
+ "card": "120.0000"
19
+ },
20
+ "counted": {
21
+ "cash": "178.0000",
22
+ "card": "120.0000"
23
+ },
24
+ "variance": {
25
+ "cash": "-2.0000",
26
+ "card": "0.0000"
27
+ },
28
+ "period_sales_total": "250.0000",
29
+ "period_refunds_total": "50.0000",
30
+ "perpetual_sales_total": "5250.0000",
31
+ "perpetual_refunds_total": "250.0000",
32
+ "unsynced_count": 2,
33
+ "unsynced_total": "12.0000",
34
+ "breakdowns": {
35
+ "currency": "USD",
36
+ "payment_methods": [
37
+ {
38
+ "method": "cash",
39
+ "name": "Cash",
40
+ "sales": "130.0000",
41
+ "refunds": "50.0000",
42
+ "sales_display": "$130.00",
43
+ "refunds_display": "$50.00"
44
+ },
45
+ {
46
+ "method": "card",
47
+ "name": "Card",
48
+ "sales": "120.0000",
49
+ "refunds": "0.0000",
50
+ "sales_display": "$120.00",
51
+ "refunds_display": "$0.00"
52
+ }
53
+ ],
54
+ "tax_rates": [
55
+ {
56
+ "name": "VAT 20%",
57
+ "net": "166.6667",
58
+ "tax": "33.3333",
59
+ "gross": "200.0000",
60
+ "net_display": "$166.67",
61
+ "tax_display": "$33.33",
62
+ "gross_display": "$200.00"
63
+ }
64
+ ],
65
+ "opening_float": {
66
+ "expected": "100.0000",
67
+ "counted": "100.0000",
68
+ "variance": "0.0000",
69
+ "expected_display": "$100.00",
70
+ "counted_display": "$100.00",
71
+ "variance_display": "$0.00"
72
+ },
73
+ "movements": [
74
+ {
75
+ "created_at_gmt": "2026-09-11 10:00:00",
76
+ "type": "paid_out",
77
+ "amount": "5.0000",
78
+ "reason": "Petty cash",
79
+ "voided_by": "sample-void",
80
+ "amount_display": "$5.00",
81
+ "created_at": {"datetime": "Sep 11, 2026, 12:00", "date": "Sep 11, 2026", "time": "12:00", "datetime_short": "9/11/26, 12:00", "datetime_long": "September 11, 2026 at 12:00 GMT+2", "datetime_full": "Friday, September 11, 2026 at 12:00 Central European Summer Time", "date_short": "9/11/26", "date_long": "September 11, 2026", "date_full": "Friday, September 11, 2026", "date_ymd": "2026-09-11", "date_dmy": "11/09/2026", "date_mdy": "09/11/2026", "weekday_short": "Fri", "weekday_long": "Friday", "day": "11", "month": "09", "month_short": "Sep", "month_long": "September", "year": "2026"},
82
+ "type_label": "Paid out",
83
+ "voided": true
84
+ }
85
+ ],
86
+ "transaction_count": 12,
87
+ "refund_count": 2,
88
+ "cashiers": [
89
+ {
90
+ "id": 1,
91
+ "name": "Alex"
92
+ }
93
+ ],
94
+ "labels": {
95
+ "opened_by_name": "Alex",
96
+ "closed_by_name": "Alex",
97
+ "approved_by_name": "Alex"
98
+ }
99
+ },
100
+ "tenders": [
101
+ {
102
+ "name": "cash",
103
+ "label": "Cash",
104
+ "has_variance": true,
105
+ "variance_absolute_display": "$2.00",
106
+ "expected": "180.0000",
107
+ "counted": "178.0000",
108
+ "variance": "-2.0000",
109
+ "variance_label": "Short",
110
+ "expected_display": "$180.00",
111
+ "counted_display": "$178.00",
112
+ "variance_display": "-$2.00"
113
+ },
114
+ {
115
+ "name": "card",
116
+ "label": "Card",
117
+ "has_variance": false,
118
+ "variance_absolute_display": "$0.00",
119
+ "expected": "120.0000",
120
+ "counted": "120.0000",
121
+ "variance": "0.0000",
122
+ "variance_label": "Exact",
123
+ "expected_display": "$120.00",
124
+ "counted_display": "$120.00",
125
+ "variance_display": "$0.00"
126
+ }
127
+ ],
128
+ "period_sales_total_display": "$250.00",
129
+ "period_refunds_total_display": "$50.00",
130
+ "perpetual_sales_total_display": "$5,250.00",
131
+ "perpetual_refunds_total_display": "$250.00",
132
+ "unsynced_total_display": "$12.00",
133
+ "opened_at": {"datetime": "Sep 11, 2026, 10:00", "date": "Sep 11, 2026", "time": "10:00", "datetime_short": "9/11/26, 10:00", "datetime_long": "September 11, 2026 at 10:00 GMT+2", "datetime_full": "Friday, September 11, 2026 at 10:00 Central European Summer Time", "date_short": "9/11/26", "date_long": "September 11, 2026", "date_full": "Friday, September 11, 2026", "date_ymd": "2026-09-11", "date_dmy": "11/09/2026", "date_mdy": "09/11/2026", "weekday_short": "Fri", "weekday_long": "Friday", "day": "11", "month": "09", "month_short": "Sep", "month_long": "September", "year": "2026"},
134
+ "closed_at": {"datetime": "Sep 11, 2026, 19:00", "date": "Sep 11, 2026", "time": "19:00", "datetime_short": "9/11/26, 19:00", "datetime_long": "September 11, 2026 at 19:00 GMT+2", "datetime_full": "Friday, September 11, 2026 at 19:00 Central European Summer Time", "date_short": "9/11/26", "date_long": "September 11, 2026", "date_full": "Friday, September 11, 2026", "date_ymd": "2026-09-11", "date_dmy": "11/09/2026", "date_mdy": "09/11/2026", "weekday_short": "Fri", "weekday_long": "Friday", "day": "11", "month": "09", "month_short": "Sep", "month_long": "September", "year": "2026"}
135
+ },
136
+ "order": { "currency": "USD" },
137
+ "i18n": {
138
+ "x_report": "X-report",
139
+ "closure": "Closure",
140
+ "opened": "Opened",
141
+ "closed": "Closed",
142
+ "approver": "Approver",
143
+ "opening_float": "Opening float",
144
+ "expected": "Expected",
145
+ "counted": "Counted",
146
+ "variance": "Variance",
147
+ "tenders": "Tenders",
148
+ "tender": "Tender",
149
+ "cash_movements": "Cash movements",
150
+ "time": "Time",
151
+ "type": "Type",
152
+ "amount": "Amount",
153
+ "reason": "Reason",
154
+ "sales": "Sales",
155
+ "period_sales": "Period sales",
156
+ "period_refunds": "Period refunds",
157
+ "transactions": "Transactions",
158
+ "refunds": "Refunds",
159
+ "payment_method": "Payment method",
160
+ "cashiers": "Cashiers",
161
+ "tax_rates": "Tax rates",
162
+ "tax_rate": "Tax rate",
163
+ "net": "Net",
164
+ "tax": "Tax",
165
+ "gross": "Gross",
166
+ "perpetual_totals": "Perpetual totals",
167
+ "perpetual_sales": "Perpetual sales",
168
+ "perpetual_refunds": "Perpetual refunds",
169
+ "unsynced_sales": "sales not yet on the server",
170
+ "short": "Short",
171
+ "over": "Over",
172
+ "exact": "Exact",
173
+ "voided": "Voided",
174
+ "paid_in": "Paid in",
175
+ "paid_out": "Paid out",
176
+ "no_sale": "No sale",
177
+ "void": "Void",
178
+ "copy": "COPY"
179
+ },
180
+ "register": {
181
+ "name": "Main register"
182
+ },
183
+ "software": {
184
+ "name": "WCPOS",
185
+ "plugin_version": "preview"
186
+ },
187
+ "fiscal": {
188
+ "document_type": "closure",
189
+ "receipt_number": "42",
190
+ "is_sale_document": false,
191
+ "is_refund_document": false,
192
+ "is_closure_document": true,
193
+ "is_x_report": false,
194
+ "is_reprint": false,
195
+ "reprint_count": 0
196
+ }
197
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "timezone": "America/Los_Angeles",
3
+ "now": "2026-11-01T20:00:00Z",
4
+ "closures": [
5
+ {
6
+ "id": "before-fallback",
7
+ "number": 42,
8
+ "business_day": "2026-10-31",
9
+ "opened_at": "2026-10-31T23:00:00Z",
10
+ "closed_at": "2026-11-01T08:30:00Z"
11
+ },
12
+ {
13
+ "id": "after-fallback",
14
+ "number": 43,
15
+ "business_day": "2026-11-01",
16
+ "opened_at": "2026-11-01T07:00:00Z",
17
+ "closed_at": "2026-11-01T09:30:00Z"
18
+ }
19
+ ],
20
+ "corrections": [
21
+ {
22
+ "id": 1,
23
+ "type": "late_sale",
24
+ "created_at": "2026-11-01 08:45:00",
25
+ "actor": { "id": "1", "name": "Alex" },
26
+ "approver": null,
27
+ "reason": "Overnight sale synced",
28
+ "figures": {
29
+ "expected_delta": { "cash": 1 },
30
+ "sales_delta": 1,
31
+ "refunds_delta": 0
32
+ }
33
+ },
34
+ {
35
+ "id": 2,
36
+ "type": "recount",
37
+ "created_at": "2026-11-01 09:45:00",
38
+ "actor": { "id": "1", "name": "Alex" },
39
+ "approver": { "id": "2", "name": "Manager" },
40
+ "reason": "Drawer recounted after the clock change",
41
+ "figures": {
42
+ "counted": { "cash": 18000 },
43
+ "variance": { "cash": 0 }
44
+ }
45
+ }
46
+ ],
47
+ "settled": {
48
+ "expected": { "cash": 18001, "card": 12000 },
49
+ "counted": { "cash": 18000, "card": 12000 },
50
+ "variance": { "cash": -1, "card": 0 },
51
+ "period_sales_total_minor": 25001,
52
+ "period_refunds_total_minor": 5000,
53
+ "perpetual_sales_total_minor": 525001,
54
+ "perpetual_refunds_total_minor": 25000
55
+ },
56
+ "touched": [
57
+ "counted.cash",
58
+ "expected.cash",
59
+ "period_sales_total_minor",
60
+ "perpetual_sales_total_minor",
61
+ "variance.cash"
62
+ ]
63
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The register's closure and X-report documents (ADR-032): WCPOS's receipt-template envelope, so
3
+ * its shipped templates and renderer can be copied in later (ADR-032, backlog 48). Port
4
+ * provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `closure-document.ts`.
5
+ *
6
+ * Neutral changes: money is a2's integer minor units on input, from `Closure`'s/`RegisterSession`'s
7
+ * own fields AND from `breakdowns` (`writeClosure` freezes `payment_methods`, `opening_float`,
8
+ * `movements` and `tax_rates` in the same minor-unit convention as the closure row itself — it is
9
+ * not an opaque, already-decimal blob). `buildClosureDocument` converts every `*_minor`/`amountMinor`
10
+ * breakdown field to the decimal strings the envelope carries with `minorToDecimal` at
11
+ * `ClosureContext.exponent` (b1's `exportCsv` does the same); `number` is a2's `Closure.number`
12
+ * (`printed_number`/`server_number` don't exist); `store_id` is sourced from a2's `store_key`;
13
+ * date-fns/`@date-fns/tz` become `Intl`, following a2's `businessDayOf`; a cashier id stays a2's
14
+ * string id (not WCPOS's numeric one).
15
+ */
16
+ import { minorToDecimal } from './money';
17
+ import type { Closure, RegisterSession } from './schemas';
18
+
19
+ type Values = Record<string, unknown>;
20
+ /** `writeClosure`'s own `breakdowns.movements` shape (session-store.ts): every entry carries at
21
+ * least a string `id` and `reason`, so callers can read them without a cast. */
22
+ type BreakdownMovement = Values & { id: string; reason: string };
23
+
24
+ export type ClosureContext = {
25
+ store: Values;
26
+ currency: string;
27
+ timezone: string;
28
+ locale: string;
29
+ printedAt: string;
30
+ /** Minor-unit decimal places for every money field the envelope carries. */
31
+ exponent: number;
32
+ formatMoney: (value: string) => string;
33
+ i18n: Record<string, string>;
34
+ /** Overrides an X-report's `server_expected` tender map, in minor units. */
35
+ expected?: Record<string, number>;
36
+ /**
37
+ * `buildXReportDocument`'s own breakdowns merge only (unlike `buildClosureDocument`'s frozen
38
+ * `Closure.breakdowns`): its money fields are still expected as decimal strings, not minor
39
+ * units. Job c's X-report caller, once it exists, converts them before passing this in.
40
+ */
41
+ breakdowns?: Values;
42
+ };
43
+
44
+ const decimalMap = (minor: Record<string, number>, exponent: number): Record<string, string> =>
45
+ Object.fromEntries(Object.entries(minor).map(([key, value]) => [key, minorToDecimal(value, exponent)]));
46
+
47
+ /** `writeClosure`'s frozen minor-unit breakdown money (`payment_methods`, `tax_rates`,
48
+ * `opening_float`, `movements[].amountMinor`) → the decimal strings the envelope carries. */
49
+ function decimalizeBreakdowns(breakdowns: Values, exponent: number): Values {
50
+ const dec = (m: unknown) => (typeof m === 'number' ? minorToDecimal(m, exponent) : m);
51
+ const group = (key: string, pairs: [string, string][]) => {
52
+ const source = breakdowns[key] as Record<string, Values> | undefined;
53
+ return (
54
+ source &&
55
+ Object.fromEntries(
56
+ Object.entries(source).map(([id, entry]) => [
57
+ id,
58
+ { ...entry, ...Object.fromEntries(pairs.map(([from, to]) => [to, dec(entry[from])])) },
59
+ ]),
60
+ )
61
+ );
62
+ };
63
+ const openingFloat = breakdowns.opening_float as Values | undefined;
64
+ return {
65
+ ...breakdowns,
66
+ ...(breakdowns.payment_methods
67
+ ? { payment_methods: group('payment_methods', [['sales_minor', 'sales'], ['refunds_minor', 'refunds']]) }
68
+ : {}),
69
+ ...(breakdowns.tax_rates
70
+ ? { tax_rates: group('tax_rates', [['net_minor', 'net'], ['tax_minor', 'tax'], ['gross_minor', 'gross']]) }
71
+ : {}),
72
+ ...(openingFloat
73
+ ? {
74
+ opening_float: {
75
+ ...openingFloat, expected: dec(openingFloat.expected_minor), counted: dec(openingFloat.counted_minor),
76
+ variance: dec(openingFloat.variance_minor),
77
+ },
78
+ }
79
+ : {}),
80
+ ...(breakdowns.movements
81
+ ? { movements: (breakdowns.movements as BreakdownMovement[]).map((m) => ({ ...m, amount: dec(m.amountMinor) })) }
82
+ : {}),
83
+ };
84
+ }
85
+
86
+ // Server Receipt_Date_Formatter keys; absent dates remain empty, not today's date.
87
+ export function formatClosureDate(
88
+ value: string | null | undefined,
89
+ context: Pick<ClosureContext, 'timezone' | 'locale'>,
90
+ ) {
91
+ const date = value
92
+ ? new Date(/[Zz]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(' ', 'T')}Z`)
93
+ : null;
94
+ const timeZone = context.timezone === 'device' ? undefined : context.timezone;
95
+ const display = (settings: Intl.DateTimeFormatOptions) =>
96
+ date ? new Intl.DateTimeFormat(context.locale, { timeZone, ...settings, hour12: false }).format(date) : '';
97
+ const result: Record<string, string> = {
98
+ datetime: display({ dateStyle: 'medium', timeStyle: 'short' }),
99
+ date: display({ dateStyle: 'medium' }),
100
+ time: display({ timeStyle: 'short' }),
101
+ };
102
+ for (const style of ['short', 'long', 'full'] as const) {
103
+ result[`datetime_${style}`] = display({ dateStyle: style, timeStyle: style === 'short' ? 'short' : style });
104
+ result[`date_${style}`] = display({ dateStyle: style });
105
+ }
106
+ const parts = date
107
+ ? Object.fromEntries(
108
+ new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' })
109
+ .formatToParts(date)
110
+ .map(({ type, value: v }) => [type, v]),
111
+ )
112
+ : ({} as Record<string, string>);
113
+ result.date_ymd = date ? `${parts.year}-${parts.month}-${parts.day}` : '';
114
+ result.date_dmy = date ? `${parts.day}/${parts.month}/${parts.year}` : '';
115
+ result.date_mdy = date ? `${parts.month}/${parts.day}/${parts.year}` : '';
116
+ result.day = date ? parts.day : '';
117
+ result.month = date ? parts.month : '';
118
+ result.year = date ? parts.year : '';
119
+ for (const style of ['short', 'long'] as const) {
120
+ result[`weekday_${style}`] = display({ weekday: style });
121
+ result[`month_${style}`] = display({ month: style });
122
+ }
123
+ return result;
124
+ }
125
+
126
+ // Generic over the row so its own literal fields (e.g. `Closure`'s `unsynced_count: number`)
127
+ // survive the `money(row, [...])` spread below into the return type, instead of widening to
128
+ // `Values`'s `unknown` — the same reason `BreakdownMovement` types the movements mapping.
129
+ function envelope<R extends Values>(row: R, context: ClosureContext, xreport = false) {
130
+ const money = <T extends Values, K extends string>(values: T, fields: K[]) =>
131
+ Object.assign(
132
+ {},
133
+ values,
134
+ Object.fromEntries(fields.map((key) => [`${key}_display`, context.formatMoney(String(values[key] ?? ''))])),
135
+ ) as T & Record<`${K}_display`, string>;
136
+ const breakdowns = (row.breakdowns ?? {}) as Values;
137
+ const labels: Values = {
138
+ opened_by_name: '', closed_by_name: '', approved_by_name: '',
139
+ ...Object.fromEntries(Object.entries(breakdowns).filter(([key]) => key.endsWith('_name'))),
140
+ ...(breakdowns.labels as Values),
141
+ };
142
+ const rows = (key: string, fields: string[]) =>
143
+ Object.entries((breakdowns[key] ?? {}) as Record<string, Values>).map(([entryKey, value]) =>
144
+ money({ ...value, name: value.name ?? value.method ?? value.rate ?? entryKey }, fields),
145
+ );
146
+ const methods = (breakdowns.payment_methods ?? {}) as Record<string, Values>;
147
+ const counted = (row.counted ?? {}) as Record<string, string>;
148
+ const expected = (row.expected ?? {}) as Record<string, string>;
149
+ const variances = (row.variance ?? {}) as Record<string, string>;
150
+ const tenders = [...new Set([...Object.keys(counted), ...Object.keys(expected)])].map((name) => {
151
+ const variance = variances[name] ?? '';
152
+ const method = Object.values(methods).find((m) => m.method === name) ?? methods[name];
153
+ return money(
154
+ {
155
+ name,
156
+ label: method?.name || name.charAt(0).toUpperCase() + name.slice(1),
157
+ expected: expected[name] ?? '', counted: counted[name] ?? '', variance,
158
+ has_variance: Number(variance || 0) !== 0,
159
+ variance_label:
160
+ variance === '' ? '' : context.i18n[Number(variance) > 0 ? 'over' : Number(variance) < 0 ? 'short' : 'exact'],
161
+ variance_absolute_display: context.formatMoney(variance.replace('-', '')),
162
+ },
163
+ ['expected', 'counted', 'variance'],
164
+ );
165
+ });
166
+ return {
167
+ closure: {
168
+ has_sales:
169
+ row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
170
+ has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
171
+ ...Object.fromEntries(
172
+ ['payment_methods', 'tax_rates', 'movements'].map((key) => [`has_${key}`, Object.keys((breakdowns[key] as Values) ?? {}).length > 0]),
173
+ ),
174
+ ...money(row, ['period_sales_total', 'period_refunds_total', 'perpetual_sales_total', 'perpetual_refunds_total', 'unsynced_total']),
175
+ opened_at_gmt: row.opened_at,
176
+ opened_by: breakdowns.opened_by ?? null,
177
+ closed_by: row.closed_by ?? null,
178
+ approved_by: breakdowns.approved_by ?? null,
179
+ closed_at_gmt: row.closed_at,
180
+ opened_at: formatClosureDate(row.opened_at as string, context),
181
+ closed_at: formatClosureDate(row.closed_at as string, context),
182
+ tenders,
183
+ breakdowns: {
184
+ ...breakdowns,
185
+ labels,
186
+ payment_methods: rows('payment_methods', ['sales', 'refunds']),
187
+ tax_rates: rows('tax_rates', ['net', 'tax', 'gross']),
188
+ opening_float: money((breakdowns.opening_float ?? {}) as Values, ['expected', 'counted', 'variance']),
189
+ movements: ((breakdowns.movements ?? []) as BreakdownMovement[]).map((m) => ({
190
+ ...money(m, ['amount']),
191
+ created_at: formatClosureDate((m.created_at_gmt ?? m.created_at) as string, context),
192
+ type_label: context.i18n[String(m.type)] ?? m.type,
193
+ voided: !!m.voided_by,
194
+ })),
195
+ cashiers: ((breakdowns.cashiers ?? []) as (Values | string)[]).map((c) => (typeof c === 'string' ? { id: c, name: c } : c)),
196
+ },
197
+ },
198
+ store: context.store,
199
+ register: { id: row.register_id, name: labels.register_name ?? '' },
200
+ software: { name: 'WCPOS', plugin_version: row.software_version ?? '' },
201
+ order: { currency: context.currency, printed: formatClosureDate(context.printedAt, context) },
202
+ fiscal: {
203
+ immutable_id: '', hash: '', qr_payload: '', tax_agency_code: '', signature_excerpt: '',
204
+ document_label: '', sequence: null, signed_at: null, extra_fields: [],
205
+ document_type: xreport ? 'xreport' : 'closure',
206
+ receipt_number: xreport ? '' : String(row.number),
207
+ is_sale_document: false, is_refund_document: false, is_closure_document: true,
208
+ is_x_report: xreport,
209
+ is_reprint: !xreport && !!row.print_count,
210
+ reprint_count: xreport ? 0 : ((row.print_count as number) ?? 0),
211
+ },
212
+ i18n: context.i18n,
213
+ };
214
+ }
215
+
216
+ export function buildClosureDocument(row: Closure, context: ClosureContext) {
217
+ const { exponent } = context;
218
+ const dec = (m: number) => minorToDecimal(m, exponent);
219
+ const {
220
+ store_key, till_expected, expected, counted, variance, period_sales_total_minor, period_refunds_total_minor,
221
+ perpetual_sales_total_minor, perpetual_refunds_total_minor, unsynced_total_minor, ...rest
222
+ } = row;
223
+ return envelope(
224
+ {
225
+ ...rest,
226
+ breakdowns: decimalizeBreakdowns(rest.breakdowns, exponent),
227
+ store_id: store_key,
228
+ till_expected: decimalMap(till_expected, exponent),
229
+ expected: decimalMap(expected, exponent),
230
+ counted: decimalMap(counted, exponent),
231
+ variance: decimalMap(variance, exponent),
232
+ period_sales_total: dec(period_sales_total_minor),
233
+ period_refunds_total: dec(period_refunds_total_minor),
234
+ perpetual_sales_total: dec(perpetual_sales_total_minor),
235
+ perpetual_refunds_total: dec(perpetual_refunds_total_minor),
236
+ unsynced_total: dec(unsynced_total_minor),
237
+ },
238
+ context,
239
+ );
240
+ }
241
+
242
+ export function buildXReportDocument(session: RegisterSession, context: ClosureContext) {
243
+ const { exponent } = context;
244
+ const dec = (m: number | null | undefined) => (m == null ? undefined : minorToDecimal(m, exponent));
245
+ const expectedMinor = context.expected ?? session.server_expected ?? {};
246
+ const countedMinor = session.counted ?? {};
247
+ const varianceMinor = Object.fromEntries(
248
+ Object.entries(countedMinor).map(([key, value]) => [key, value - (expectedMinor[key] ?? 0)]),
249
+ );
250
+ return envelope(
251
+ {
252
+ id: session.id,
253
+ register_id: session.register_id,
254
+ store_id: session.store_key,
255
+ opened_at: session.opened_at_gmt,
256
+ expected: decimalMap(expectedMinor, exponent),
257
+ counted: decimalMap(countedMinor, exponent),
258
+ variance: decimalMap(varianceMinor, exponent),
259
+ breakdowns: {
260
+ ...context.breakdowns,
261
+ opening_float: {
262
+ expected: dec(session.expected_float_minor), counted: dec(session.counted_float_minor),
263
+ variance: dec(session.opening_variance_minor),
264
+ },
265
+ },
266
+ },
267
+ context,
268
+ true,
269
+ );
270
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The closures list's pure selectors (ADR-032): a requested scope clamped to the local history
3
+ * window, and its rows within that scope, newest first. Port provenance (ADR-032 amendment 1):
4
+ * WCPOS `next` `3b5331b5c` `use-closure-rows.ts` — `ClosureScope`, `clampClosureScope` and
5
+ * `selectClosureRows` only. The React hook that pages the server and merges local/remote rows is
6
+ * registers job c, not ported here.
7
+ */
8
+ import { businessDayOf } from './session-store';
9
+ import type { Closure } from './schemas';
10
+
11
+ export type ClosureScope = {
12
+ from: string;
13
+ to: string;
14
+ registerId: string;
15
+ storeKey?: string;
16
+ cashier?: string;
17
+ };
18
+
19
+ /**
20
+ * WCPOS's `HISTORY_DAYS` (`@wcpos/sync-core`) bounds how far back local report history reaches
21
+ * without a server aggregation call. Its value is 92 (wcpos/roadmap#332, "the 92-day reach").
22
+ */
23
+ const DEFAULT_HISTORY_DAYS = 92;
24
+
25
+ /** `day` minus `days` calendar days, both `yyyy-MM-dd`: calendar arithmetic, not a zone conversion. */
26
+ function daysBefore(day: string, days: number): string {
27
+ const at = new Date(`${day}T00:00:00Z`);
28
+ at.setUTCDate(at.getUTCDate() - days);
29
+ return at.toISOString().slice(0, 10);
30
+ }
31
+
32
+ /** Clamps a requested scope's `from`/`to` to `today` and to `historyDays` before it. */
33
+ export function clampClosureScope(
34
+ scope: ClosureScope,
35
+ today: string,
36
+ historyDays = DEFAULT_HISTORY_DAYS,
37
+ ): ClosureScope {
38
+ const min = daysBefore(today, historyDays);
39
+ const clamp = (day: string) => (day < min ? min : day > today ? today : day);
40
+ return { ...scope, from: clamp(scope.from), to: clamp(scope.to) };
41
+ }
42
+
43
+ /**
44
+ * Rows within `scope`, newest business day first, then newest `closed_at`. A row with no
45
+ * `business_day` (a legacy or remote row) gets one from `opened_at` in `timezone`, reusing a2's
46
+ * `businessDayOf` rather than duplicating it. Register and cashier are optional filters; the
47
+ * store is not — a row's `store_key` must equal `scope.storeKey` (both `null` when neither is
48
+ * set), as WCPOS always compared `store_id`.
49
+ */
50
+ export function selectClosureRows(rows: readonly Closure[], scope: ClosureScope, timezone: string) {
51
+ return rows
52
+ .map((row) => ({
53
+ ...row,
54
+ business_day: row.business_day || businessDayOf(row.opened_at, timezone),
55
+ }))
56
+ .filter(
57
+ (row) =>
58
+ row.business_day >= scope.from &&
59
+ row.business_day <= scope.to &&
60
+ (!scope.registerId || row.register_id === scope.registerId) &&
61
+ (row.store_key ?? null) === (scope.storeKey ?? null) &&
62
+ (scope.cashier === undefined || row.closed_by === scope.cashier),
63
+ )
64
+ .sort((a, b) => b.business_day.localeCompare(a.business_day) || b.closed_at.localeCompare(a.closed_at));
65
+ }
@@ -0,0 +1,46 @@
1
+ // Offline defaults use the same label keys as the shipped server closure template. Port
2
+ // provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c` `document-labels.ts`, unchanged —
3
+ // no money, no imports.
4
+ export const labelKeys = {
5
+ x_report: 'register.x_report',
6
+ closure: 'reports.document.closure',
7
+ opened: 'reports.document.opened',
8
+ closed: 'register.closed',
9
+ approver: 'reports.document.approver',
10
+ opening_float: 'reports.document.opening_float',
11
+ expected: 'reports.document.expected',
12
+ counted: 'register.counted',
13
+ variance: 'register.variance',
14
+ tenders: 'reports.document.tenders',
15
+ tender: 'reports.document.tender',
16
+ cash_movements: 'reports.document.cash_movements',
17
+ time: 'common.time',
18
+ type: 'common.type',
19
+ amount: 'register.amount',
20
+ reason: 'register.reason',
21
+ sales: 'reports.sales',
22
+ period_sales: 'register.period_sales',
23
+ period_refunds: 'register.period_refunds',
24
+ transactions: 'reports.document.transactions',
25
+ refunds: 'common.refunds',
26
+ payment_method: 'reports.document.payment_method',
27
+ cashiers: 'reports.document.cashiers',
28
+ tax_rates: 'common.tax_rates',
29
+ tax_rate: 'reports.document.tax_rate',
30
+ net: 'reports.document.net',
31
+ tax: 'common.tax',
32
+ gross: 'reports.document.gross',
33
+ perpetual_totals: 'reports.document.perpetual_totals',
34
+ perpetual_sales: 'register.perpetual_sales',
35
+ perpetual_refunds: 'register.perpetual_refunds',
36
+ unsynced_sales: 'reports.document.unsynced_sales',
37
+ short: 'reports.document.short',
38
+ over: 'reports.document.over',
39
+ exact: 'reports.exact',
40
+ voided: 'pos_checkout.status_voided',
41
+ paid_in: 'register.paid_in',
42
+ paid_out: 'register.paid_out',
43
+ no_sale: 'register.no_sale',
44
+ void: 'register.void',
45
+ copy: 'reports.document.copy',
46
+ };