@tallyui/pos 0.1.0 → 3.0.0-next.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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +2031 -43
  3. package/dist/index.js +4629 -205
  4. package/package.json +23 -12
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +53 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +8 -0
  10. package/src/order/order-builder.ts +256 -95
  11. package/src/order/order-manager.ts +26 -43
  12. package/src/order/tax-figures.ts +23 -0
  13. package/src/order/types.ts +73 -15
  14. package/src/outbox/backend-not-found.ts +27 -0
  15. package/src/outbox/http-transport.ts +67 -0
  16. package/src/outbox/index.ts +9 -0
  17. package/src/outbox/logger.ts +3 -0
  18. package/src/outbox/order-outbox.ts +502 -0
  19. package/src/outbox/register-outbox.ts +250 -0
  20. package/src/outbox/types.test-d.ts +22 -0
  21. package/src/outbox/types.ts +36 -0
  22. package/src/outbox/use-order-outbox.ts +175 -0
  23. package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
  24. package/src/pos-order/command.ts +123 -0
  25. package/src/pos-order/device-id.ts +23 -0
  26. package/src/pos-order/finalize.ts +219 -0
  27. package/src/pos-order/index.ts +10 -0
  28. package/src/pos-order/needs-attention.ts +16 -0
  29. package/src/pos-order/open.ts +269 -0
  30. package/src/pos-order/same-sale.ts +26 -0
  31. package/src/pos-order/schema.ts +130 -0
  32. package/src/pos-order/types.ts +93 -0
  33. package/src/pos-order/uuidv7.ts +54 -0
  34. package/src/product/index.ts +2 -0
  35. package/src/product/search-products.ts +32 -0
  36. package/src/product/stock.ts +25 -0
  37. package/src/receipt/build-receipt-data.ts +39 -23
  38. package/src/receipt/types.ts +17 -10
  39. package/src/register/__fixtures__/closure-local-row.json +61 -0
  40. package/src/register/__fixtures__/closure.json +197 -0
  41. package/src/register/__fixtures__/corrections-dst.json +63 -0
  42. package/src/register/closure-document.ts +277 -0
  43. package/src/register/closure-rows.ts +65 -0
  44. package/src/register/document-labels.ts +46 -0
  45. package/src/register/expected.ts +66 -0
  46. package/src/register/export-csv.ts +74 -0
  47. package/src/register/facts.ts +109 -0
  48. package/src/register/index.ts +32 -0
  49. package/src/register/money.ts +18 -0
  50. package/src/register/movement-input.ts +59 -0
  51. package/src/register/register-commands.ts +147 -0
  52. package/src/register/register-count.denominations.ts +11 -0
  53. package/src/register/register-count.helpers.ts +64 -0
  54. package/src/register/register-document.ts +268 -0
  55. package/src/register/schemas.ts +175 -0
  56. package/src/register/session-store.ts +570 -0
  57. package/src/register/settled-figures.ts +98 -0
  58. package/src/register/use-register-session.ts +469 -0
  59. package/src/rxdb/index.ts +2 -0
  60. package/src/sale/cart.ts +23 -0
  61. package/src/sale/catalogue.ts +21 -0
  62. package/src/sale/index.ts +5 -0
  63. package/src/sale/use-sale.ts +394 -0
  64. package/src/store-settings/index.ts +5 -0
  65. package/src/store-settings/map-store-settings.ts +18 -0
  66. package/src/store-settings/resolve-store-settings.ts +65 -0
  67. package/src/store-settings/use-store-settings.ts +84 -0
  68. package/src/tax/exact.ts +218 -0
  69. package/src/tax/index.ts +4 -3
  70. package/src/tax/tax-provider.tsx +35 -12
  71. package/src/tax/types.ts +8 -6
  72. package/src/tender/index.ts +2 -0
  73. package/src/tender/tender-state.ts +326 -0
  74. package/src/currency/currency-provider.test.tsx +0 -36
  75. package/src/currency/format-currency.test.ts +0 -36
  76. package/src/currency/format-currency.ts +0 -18
  77. package/src/logging/logger.test.ts +0 -154
  78. package/src/logging/sinks.test.ts +0 -60
  79. package/src/order/discount-engine.test.ts +0 -152
  80. package/src/order/order-builder.test.ts +0 -246
  81. package/src/order/order-manager.test.ts +0 -209
  82. package/src/order/payment.test.ts +0 -91
  83. package/src/receipt/build-receipt-data.test.ts +0 -166
  84. package/src/repository/create-repository.test.ts +0 -166
  85. package/src/tax/calculate.test.ts +0 -59
  86. package/src/tax/calculate.ts +0 -32
  87. package/src/tax/tax-provider.test.tsx +0 -51
@@ -0,0 +1,277 @@
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
+
21
+ /**
22
+ * The Z report's line when a session's sales used more than one tax rounding (#287; wording approved by the Front
23
+ * desk, 2026-09-30). The closure template lives in the apps, so the document carries it ready to print.
24
+ */
25
+ export const TAX_ROUNDING_MIXED_NOTE = "This register's sales used more than one tax rounding method. Each sale's tax is as its receipt showed.";
26
+ /** `writeClosure`'s own `breakdowns.movements` shape (session-store.ts): every entry carries at
27
+ * least a string `id` and `reason`, so callers can read them without a cast. */
28
+ type BreakdownMovement = Values & { id: string; reason: string };
29
+
30
+ export type ClosureContext = {
31
+ store: Values;
32
+ currency: string;
33
+ timezone: string;
34
+ locale: string;
35
+ printedAt: string;
36
+ /** Minor-unit decimal places for every money field the envelope carries. */
37
+ exponent: number;
38
+ formatMoney: (value: string) => string;
39
+ i18n: Record<string, string>;
40
+ /** Overrides an X-report's `server_expected` tender map, in minor units. */
41
+ expected?: Record<string, number>;
42
+ /**
43
+ * `buildXReportDocument`'s own breakdowns merge only (unlike `buildClosureDocument`'s frozen
44
+ * `Closure.breakdowns`): its money fields are still expected as decimal strings, not minor
45
+ * units. Job c's X-report caller, once it exists, converts them before passing this in.
46
+ */
47
+ breakdowns?: Values;
48
+ };
49
+
50
+ const decimalMap = (minor: Record<string, number>, exponent: number): Record<string, string> =>
51
+ Object.fromEntries(Object.entries(minor).map(([key, value]) => [key, minorToDecimal(value, exponent)]));
52
+
53
+ /** `writeClosure`'s frozen minor-unit breakdown money (`payment_methods`, `tax_rates`,
54
+ * `opening_float`, `movements[].amountMinor`) → the decimal strings the envelope carries. */
55
+ function decimalizeBreakdowns(breakdowns: Values, exponent: number): Values {
56
+ const dec = (m: unknown) => (typeof m === 'number' ? minorToDecimal(m, exponent) : m);
57
+ const group = (key: string, pairs: [string, string][]) => {
58
+ const source = breakdowns[key] as Record<string, Values> | undefined;
59
+ return (
60
+ source &&
61
+ Object.fromEntries(
62
+ Object.entries(source).map(([id, entry]) => [
63
+ id,
64
+ { ...entry, ...Object.fromEntries(pairs.map(([from, to]) => [to, dec(entry[from])])) },
65
+ ]),
66
+ )
67
+ );
68
+ };
69
+ const openingFloat = breakdowns.opening_float as Values | undefined;
70
+ return {
71
+ ...breakdowns,
72
+ ...(breakdowns.payment_methods
73
+ ? { payment_methods: group('payment_methods', [['sales_minor', 'sales'], ['refunds_minor', 'refunds']]) }
74
+ : {}),
75
+ ...(breakdowns.tax_rates
76
+ ? { tax_rates: group('tax_rates', [['net_minor', 'net'], ['tax_minor', 'tax'], ['gross_minor', 'gross']]) }
77
+ : {}),
78
+ ...(openingFloat
79
+ ? {
80
+ opening_float: {
81
+ ...openingFloat, expected: dec(openingFloat.expected_minor), counted: dec(openingFloat.counted_minor),
82
+ variance: dec(openingFloat.variance_minor),
83
+ },
84
+ }
85
+ : {}),
86
+ ...(breakdowns.movements
87
+ ? { movements: (breakdowns.movements as BreakdownMovement[]).map((m) => ({ ...m, amount: dec(m.amountMinor) })) }
88
+ : {}),
89
+ };
90
+ }
91
+
92
+ // Server Receipt_Date_Formatter keys; absent dates remain empty, not today's date.
93
+ export function formatClosureDate(
94
+ value: string | null | undefined,
95
+ context: Pick<ClosureContext, 'timezone' | 'locale'>,
96
+ ) {
97
+ const date = value
98
+ ? new Date(/[Zz]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(' ', 'T')}Z`)
99
+ : null;
100
+ const timeZone = context.timezone === 'device' ? undefined : context.timezone;
101
+ const display = (settings: Intl.DateTimeFormatOptions) =>
102
+ date ? new Intl.DateTimeFormat(context.locale, { timeZone, ...settings, hour12: false }).format(date) : '';
103
+ const result: Record<string, string> = {
104
+ datetime: display({ dateStyle: 'medium', timeStyle: 'short' }),
105
+ date: display({ dateStyle: 'medium' }),
106
+ time: display({ timeStyle: 'short' }),
107
+ };
108
+ for (const style of ['short', 'long', 'full'] as const) {
109
+ result[`datetime_${style}`] = display({ dateStyle: style, timeStyle: style === 'short' ? 'short' : style });
110
+ result[`date_${style}`] = display({ dateStyle: style });
111
+ }
112
+ const parts = date
113
+ ? Object.fromEntries(
114
+ new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' })
115
+ .formatToParts(date)
116
+ .map(({ type, value: v }) => [type, v]),
117
+ )
118
+ : ({} as Record<string, string>);
119
+ result.date_ymd = date ? `${parts.year}-${parts.month}-${parts.day}` : '';
120
+ result.date_dmy = date ? `${parts.day}/${parts.month}/${parts.year}` : '';
121
+ result.date_mdy = date ? `${parts.month}/${parts.day}/${parts.year}` : '';
122
+ result.day = date ? parts.day : '';
123
+ result.month = date ? parts.month : '';
124
+ result.year = date ? parts.year : '';
125
+ for (const style of ['short', 'long'] as const) {
126
+ result[`weekday_${style}`] = display({ weekday: style });
127
+ result[`month_${style}`] = display({ month: style });
128
+ }
129
+ return result;
130
+ }
131
+
132
+ // Generic over the row so its own literal fields (e.g. `Closure`'s `unsynced_count: number`)
133
+ // survive the `money(row, [...])` spread below into the return type, instead of widening to
134
+ // `Values`'s `unknown` — the same reason `BreakdownMovement` types the movements mapping.
135
+ function envelope<R extends Values>(row: R, context: ClosureContext, xreport = false) {
136
+ const money = <T extends Values, K extends string>(values: T, fields: K[]) =>
137
+ Object.assign(
138
+ {},
139
+ values,
140
+ Object.fromEntries(fields.map((key) => [`${key}_display`, context.formatMoney(String(values[key] ?? ''))])),
141
+ ) as T & Record<`${K}_display`, string>;
142
+ const breakdowns = (row.breakdowns ?? {}) as Values;
143
+ const labels: Values = {
144
+ opened_by_name: '', closed_by_name: '', approved_by_name: '',
145
+ ...Object.fromEntries(Object.entries(breakdowns).filter(([key]) => key.endsWith('_name'))),
146
+ ...(breakdowns.labels as Values),
147
+ };
148
+ const rows = (key: string, fields: string[]) =>
149
+ Object.entries((breakdowns[key] ?? {}) as Record<string, Values>).map(([entryKey, value]) =>
150
+ money({ ...value, name: value.name ?? value.method ?? value.rate ?? entryKey }, fields),
151
+ );
152
+ const methods = (breakdowns.payment_methods ?? {}) as Record<string, Values>;
153
+ const counted = (row.counted ?? {}) as Record<string, string>;
154
+ const expected = (row.expected ?? {}) as Record<string, string>;
155
+ const variances = (row.variance ?? {}) as Record<string, string>;
156
+ const tenders = [...new Set([...Object.keys(counted), ...Object.keys(expected)])].map((name) => {
157
+ const variance = variances[name] ?? '';
158
+ const method = Object.values(methods).find((m) => m.method === name) ?? methods[name];
159
+ return money(
160
+ {
161
+ name,
162
+ label: method?.name || name.charAt(0).toUpperCase() + name.slice(1),
163
+ expected: expected[name] ?? '', counted: counted[name] ?? '', variance,
164
+ has_variance: Number(variance || 0) !== 0,
165
+ variance_label:
166
+ variance === '' ? '' : context.i18n[Number(variance) > 0 ? 'over' : Number(variance) < 0 ? 'short' : 'exact'],
167
+ variance_absolute_display: context.formatMoney(variance.replace('-', '')),
168
+ },
169
+ ['expected', 'counted', 'variance'],
170
+ );
171
+ });
172
+ return {
173
+ closure: {
174
+ has_sales:
175
+ row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
176
+ has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
177
+ ...Object.fromEntries(
178
+ ['payment_methods', 'tax_rates', 'movements'].map((key) => [`has_${key}`, Object.keys((breakdowns[key] as Values) ?? {}).length > 0]),
179
+ ),
180
+ ...(breakdowns.tax_rounding_mixed === true ? { tax_rounding_note: TAX_ROUNDING_MIXED_NOTE } : {}),
181
+ ...money(row, ['period_sales_total', 'period_refunds_total', 'perpetual_sales_total', 'perpetual_refunds_total', 'unsynced_total']),
182
+ opened_at_gmt: row.opened_at,
183
+ opened_by: breakdowns.opened_by ?? null,
184
+ closed_by: row.closed_by ?? null,
185
+ approved_by: breakdowns.approved_by ?? null,
186
+ closed_at_gmt: row.closed_at,
187
+ opened_at: formatClosureDate(row.opened_at as string, context),
188
+ closed_at: formatClosureDate(row.closed_at as string, context),
189
+ tenders,
190
+ breakdowns: {
191
+ ...breakdowns,
192
+ labels,
193
+ payment_methods: rows('payment_methods', ['sales', 'refunds']),
194
+ tax_rates: rows('tax_rates', ['net', 'tax', 'gross']),
195
+ opening_float: money((breakdowns.opening_float ?? {}) as Values, ['expected', 'counted', 'variance']),
196
+ movements: ((breakdowns.movements ?? []) as BreakdownMovement[]).map((m) => ({
197
+ ...money(m, ['amount']),
198
+ created_at: formatClosureDate((m.created_at_gmt ?? m.created_at) as string, context),
199
+ type_label: context.i18n[String(m.type)] ?? m.type,
200
+ voided: !!m.voided_by,
201
+ })),
202
+ cashiers: ((breakdowns.cashiers ?? []) as (Values | string)[]).map((c) => (typeof c === 'string' ? { id: c, name: c } : c)),
203
+ },
204
+ },
205
+ store: context.store,
206
+ register: { id: row.register_id, name: labels.register_name ?? '' },
207
+ software: { name: 'WCPOS', plugin_version: row.software_version ?? '' },
208
+ order: { currency: context.currency, printed: formatClosureDate(context.printedAt, context) },
209
+ fiscal: {
210
+ immutable_id: '', hash: '', qr_payload: '', tax_agency_code: '', signature_excerpt: '',
211
+ document_label: '', sequence: null, signed_at: null, extra_fields: [],
212
+ document_type: xreport ? 'xreport' : 'closure',
213
+ receipt_number: xreport ? '' : String(row.number),
214
+ is_sale_document: false, is_refund_document: false, is_closure_document: true,
215
+ is_x_report: xreport,
216
+ is_reprint: !xreport && !!row.print_count,
217
+ reprint_count: xreport ? 0 : ((row.print_count as number) ?? 0),
218
+ },
219
+ i18n: context.i18n,
220
+ };
221
+ }
222
+
223
+ export function buildClosureDocument(row: Closure, context: ClosureContext) {
224
+ const { exponent } = context;
225
+ const dec = (m: number) => minorToDecimal(m, exponent);
226
+ const {
227
+ store_key, till_expected, expected, counted, variance, period_sales_total_minor, period_refunds_total_minor,
228
+ perpetual_sales_total_minor, perpetual_refunds_total_minor, unsynced_total_minor, ...rest
229
+ } = row;
230
+ return envelope(
231
+ {
232
+ ...rest,
233
+ breakdowns: decimalizeBreakdowns(rest.breakdowns, exponent),
234
+ store_id: store_key,
235
+ till_expected: decimalMap(till_expected, exponent),
236
+ expected: decimalMap(expected, exponent),
237
+ counted: decimalMap(counted, exponent),
238
+ variance: decimalMap(variance, exponent),
239
+ period_sales_total: dec(period_sales_total_minor),
240
+ period_refunds_total: dec(period_refunds_total_minor),
241
+ perpetual_sales_total: dec(perpetual_sales_total_minor),
242
+ perpetual_refunds_total: dec(perpetual_refunds_total_minor),
243
+ unsynced_total: dec(unsynced_total_minor),
244
+ },
245
+ context,
246
+ );
247
+ }
248
+
249
+ export function buildXReportDocument(session: RegisterSession, context: ClosureContext) {
250
+ const { exponent } = context;
251
+ const dec = (m: number | null | undefined) => (m == null ? undefined : minorToDecimal(m, exponent));
252
+ const expectedMinor = context.expected ?? session.server_expected ?? {};
253
+ const countedMinor = session.counted ?? {};
254
+ const varianceMinor = Object.fromEntries(
255
+ Object.entries(countedMinor).map(([key, value]) => [key, value - (expectedMinor[key] ?? 0)]),
256
+ );
257
+ return envelope(
258
+ {
259
+ id: session.id,
260
+ register_id: session.register_id,
261
+ store_id: session.store_key,
262
+ opened_at: session.opened_at_gmt,
263
+ expected: decimalMap(expectedMinor, exponent),
264
+ counted: decimalMap(countedMinor, exponent),
265
+ variance: decimalMap(varianceMinor, exponent),
266
+ breakdowns: {
267
+ ...context.breakdowns,
268
+ opening_float: {
269
+ expected: dec(session.expected_float_minor), counted: dec(session.counted_float_minor),
270
+ variance: dec(session.opening_variance_minor),
271
+ },
272
+ },
273
+ },
274
+ context,
275
+ true,
276
+ );
277
+ }
@@ -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
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What the register should hold at close, per tender method: the session's cash float plus
3
+ * captured payments and cash movements — never what the cashier counts. Port provenance
4
+ * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
5
+ *
6
+ * **Refund attribution is deferred.** WCPOS's `attributeRefunds` debits a session's drawer for
7
+ * refunds processed against its captured payments (by stamped session, then by a legacy
8
+ * `refunded_amount` fallback). TallyUI has no refund model yet (ADR-032 amendment 1), so
9
+ * `deriveExpected` covers only the float, the session's captured payment rows, and its
10
+ * paid-in/paid-out cash movements under WCPOS's void rules.
11
+ */
12
+
13
+ /** `session_id`, `kind`, `method_id`, `status` kept as WCPOS names them; money is TallyUI's integer-minor-units convention. */
14
+ export type LedgerRow = {
15
+ session_id?: string | null;
16
+ kind: string;
17
+ method_id: string;
18
+ status: string;
19
+ amountMinor: number;
20
+ };
21
+
22
+ /** `type`, `voids` and `voided_by` are kept as WCPOS names them: neutral across backends. */
23
+ export type Movement = {
24
+ id: string;
25
+ session_id: string;
26
+ type: string;
27
+ amountMinor: number;
28
+ voided_by?: string | null;
29
+ voids?: string | null;
30
+ };
31
+
32
+ /**
33
+ * Expected totals per tender method: the counted float, plus every captured ledger row for the
34
+ * session (grouped under `cash` for cash rows, else `method_id`), plus its non-voided
35
+ * paid-in/paid-out movements.
36
+ *
37
+ * A movement is excluded when its own `voided_by` is set, or when a `type: 'void'` row's
38
+ * `voids` names its id; the `void` row itself carries no amount of its own.
39
+ */
40
+ export function deriveExpected({
41
+ session,
42
+ movements,
43
+ ledgerRowsBySession,
44
+ }: {
45
+ session: { id: string; countedFloatMinor: number };
46
+ movements: readonly Movement[];
47
+ ledgerRowsBySession: readonly LedgerRow[];
48
+ }): Record<string, number> {
49
+ const totals: Record<string, number> = { cash: session.countedFloatMinor };
50
+ for (const row of ledgerRowsBySession) {
51
+ if (row.session_id !== session.id || row.status !== 'captured') continue;
52
+ const method = row.kind === 'cash' ? 'cash' : row.method_id;
53
+ totals[method] = (totals[method] ?? 0) + row.amountMinor;
54
+ }
55
+ const voids = new Set(
56
+ movements
57
+ .filter((row) => row.session_id === session.id && row.type === 'void')
58
+ .map((row) => row.voids),
59
+ );
60
+ for (const row of movements) {
61
+ if (row.session_id !== session.id || row.voided_by || voids.has(row.id)) continue;
62
+ if (row.type === 'paid_in') totals.cash += row.amountMinor;
63
+ if (row.type === 'paid_out') totals.cash -= row.amountMinor;
64
+ }
65
+ return totals;
66
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A CSV export of the closures currently shown: one column per tender the visible rows use, an
3
+ * expected-only tender kept as a zero-counted shortage, quoted/escaped cells with a CRLF line
4
+ * ending, and a leading `'` on formula- or control-prefixed text so a spreadsheet reads it as
5
+ * text (LEDGER.md #26, #27). Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`
6
+ * `export-csv.ts`. Money is a2's integer minor units: an `exponent` parameter (as in a1's
7
+ * `parseMinor`/`varianceText`) formats each cell back to the decimal string WCPOS's server sent,
8
+ * e.g. `1250` at exponent 2 becomes `'12.50'`.
9
+ */
10
+ import { minorToDecimal } from './money';
11
+ import type { Closure } from './schemas';
12
+
13
+ /** Widens a tender-map lookup back to `number | undefined`: a method absent from the map. */
14
+ const at = (map: Record<string, number>, key: string): number | undefined => map[key];
15
+
16
+ export function exportCsv(
17
+ rows: readonly Closure[],
18
+ exponent: number,
19
+ t: (key: string) => string,
20
+ names: Record<string, string> = {},
21
+ storeName = '',
22
+ ) {
23
+ const tenders = [
24
+ ...new Set(
25
+ rows.flatMap((row) => [
26
+ ...Object.keys(row.expected),
27
+ ...Object.keys(row.counted),
28
+ ...Object.keys(row.variance),
29
+ ]),
30
+ ),
31
+ ];
32
+ const cell = (value: unknown) => {
33
+ const text = String(value ?? '');
34
+ return `"${(/^(?:[\t\r\n]|[\s\x00-\x1f\x7f-\x9f]*[=+\-@])/.test(text) ? "'" + text : text).replace(/"/g, '""')}"`;
35
+ };
36
+ const header = ['business_day', 'closure', 'register', 'store', 'opened', 'closed', 'closer'].map((key) =>
37
+ t(`reports.csv_${key}`),
38
+ );
39
+ header.push(
40
+ ...tenders.flatMap((method) => [
41
+ `${t('reports.document.expected')} (${method})`,
42
+ `${t('register.counted')} (${method})`,
43
+ `${t('register.variance')} (${method})`,
44
+ ]),
45
+ t('reports.csv_status'),
46
+ );
47
+ return [
48
+ header,
49
+ ...rows.map((row) => [
50
+ row.business_day,
51
+ row.number,
52
+ row.breakdowns.register_name || names[row.register_id] || row.register_id.slice(0, 8),
53
+ row.breakdowns.store_name || storeName || row.store_key,
54
+ row.opened_at,
55
+ row.closed_at,
56
+ row.breakdowns.closed_by_name || t('register.unknown_cashier'),
57
+ ...tenders.flatMap((method) => {
58
+ const expected = at(row.expected, method);
59
+ const counted = at(row.counted, method) ?? (expected === undefined ? undefined : 0);
60
+ const value = at(row.variance, method) ?? (expected === undefined ? undefined : (counted ?? 0) - expected);
61
+ return [
62
+ expected === undefined ? '' : minorToDecimal(expected, exponent),
63
+ counted === undefined ? '' : minorToDecimal(counted, exponent),
64
+ value === undefined
65
+ ? ''
66
+ : `${minorToDecimal(Math.abs(value), exponent)} ${t(value < 0 ? 'register.short' : value > 0 ? 'register.over' : 'reports.exact')}`,
67
+ ];
68
+ }),
69
+ row.unsynced_count > 0 ? t('register.unsynced') : row.corrections_count ? t('reports.corrected') : '',
70
+ ]),
71
+ ]
72
+ .map((row) => row.map(cell).join(','))
73
+ .join('\r\n');
74
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Register facts (ADR-032): a closed vocabulary of human register actions, logged through
3
+ * TallyUI's own logger. Port provenance (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`
4
+ * `audit.ts`, `RegisterFact` and `recordRegisterFact` only — `useRegisterActor` is React and
5
+ * belongs to job c, and so do the outbox/bridge-diagnostic facts (`outbox-approval-refused`,
6
+ * `outbox-request-failed`, `movement-retry-requested`, `movement-accepted`, `session-adopted`,
7
+ * `session-pruned`, `binding-changed`, `binding-removed`, `directory-unavailable`,
8
+ * `bridge-cycle-failed`): a2 has no outbox or hardware-bridge concept yet to attach them to.
9
+ *
10
+ * Neutral changes from the WCPOS source: WCPOS's `mintUuid` (`register-document.ts`) becomes
11
+ * a2's own; a person id (`approvedBy`) is a2's string id, not WCPOS's numeric one; money fields
12
+ * are a2's integer minor units, matching `CashMovement.amountMinor` and `Closure.counted`/
13
+ * `variance`.
14
+ *
15
+ * TallyUI's own addition (registers c1a): `late-sale`, a sale whose money was taken but whose
16
+ * session refused the stamp (ADR-032 amendment "Late sale"). Its `sessionId` is the session it
17
+ * tried, and its durable subject is the order.
18
+ */
19
+ import { createLogger } from '../logging';
20
+ import { mintUuid } from './register-document';
21
+ import type { CashMovement, Closure, RegisterSession } from './schemas';
22
+
23
+ /** The register-session logging scope; the app attaches its own sinks (console, remote, toast). */
24
+ export const registerFactsLogger = createLogger('register-session');
25
+
26
+ const attempt = () => ({ operationId: mintUuid().replace(/-/g, '') });
27
+
28
+ export type Actor = { id: string; name: string };
29
+ type Pair = { sessionId: string | undefined; registerId: string | null | undefined };
30
+ type Human = Pair & { actor: Actor };
31
+ type Movement = Pair & { movementId: string; movementType: CashMovement['type']; amount: number };
32
+
33
+ type SimpleKind = 'counting-started' | 'counting-abandoned' | 'approval-refused' | 'x-report-dispatched' | 'drawer-dispatched';
34
+ export type RegisterFact =
35
+ | (Human & { kind: 'session-opened'; amount: number; variance: RegisterSession['opening_variance_minor'] })
36
+ | (Human & { kind: SimpleKind })
37
+ | (Human & { kind: 'session-closed'; closureId: string } & Pick<Closure, 'number' | 'counted' | 'variance'>)
38
+ | (Human & Movement & { kind: 'movement-recorded' })
39
+ | (Human & Movement & { kind: 'movement-voided'; voids: string })
40
+ | (Human & { kind: 'approval-granted'; approvedBy: string | null })
41
+ | (Human & { kind: 'variance-over-threshold'; variance: number; threshold: number | undefined })
42
+ | (Pair & { kind: 'late-sale'; orderId: string; actor?: Actor });
43
+
44
+ /** A human action without its own durable record gets a fresh attempt id; a durable action (a
45
+ * session, a movement or a late sale's order) uses the stable id of its subject. */
46
+ function identity(fact: RegisterFact): { operationId: string } {
47
+ switch (fact.kind) {
48
+ case 'session-opened':
49
+ return { operationId: fact.sessionId?.replace(/-/g, '') ?? '' };
50
+ case 'session-closed':
51
+ return { operationId: fact.closureId.replace(/-/g, '') };
52
+ case 'movement-recorded':
53
+ case 'movement-voided':
54
+ return { operationId: fact.movementId.replace(/-/g, '') };
55
+ case 'late-sale':
56
+ return { operationId: fact.orderId.replace(/-/g, '') };
57
+ default:
58
+ return attempt();
59
+ }
60
+ }
61
+
62
+ export function recordRegisterFact(fact: RegisterFact): void {
63
+ const terminal = identity(fact);
64
+ const pair = { sessionId: fact.sessionId, registerId: fact.registerId };
65
+ const emit = (
66
+ message: string,
67
+ type: string,
68
+ context: Record<string, unknown> = {},
69
+ level: 'info' | 'warn' = 'info',
70
+ ) => registerFactsLogger[level](message, { actor: fact.actor, terminal, context: { type, ...pair, ...context } });
71
+ switch (fact.kind) {
72
+ case 'session-opened':
73
+ return emit('Register session opened', 'register.session-opened', { amount: fact.amount, variance: fact.variance });
74
+ case 'counting-started':
75
+ return emit('Register session counting started', 'register.counting-started');
76
+ case 'counting-abandoned':
77
+ return emit('Register session counting abandoned', 'register.counting-abandoned');
78
+ case 'session-closed':
79
+ return emit('Register session closed', 'register.session-closed', {
80
+ closureId: fact.closureId, number: fact.number, counted: fact.counted, variance: fact.variance,
81
+ });
82
+ case 'movement-recorded':
83
+ // No-sale is the cashier action, independent of drawer dispatch, and makes no cash claim.
84
+ if (fact.movementType === 'no_sale')
85
+ return emit('Register no-sale recorded', 'register.no-sale-recorded', { movementId: fact.movementId });
86
+ return emit('Register cash movement recorded', 'register.movement-recorded', {
87
+ movementId: fact.movementId, movementType: fact.movementType, amount: fact.amount,
88
+ });
89
+ case 'movement-voided':
90
+ return emit('Register cash movement voided', 'register.movement-voided', {
91
+ movementId: fact.movementId, movementType: fact.movementType, amount: fact.amount, voids: fact.voids,
92
+ });
93
+ case 'approval-granted':
94
+ return emit('Register session approval granted', 'register.approval-granted', { approvedBy: fact.approvedBy });
95
+ case 'approval-refused':
96
+ return emit('Register session approval refused', 'register.approval-refused', {}, 'warn');
97
+ case 'variance-over-threshold':
98
+ return emit('Register count exceeds variance threshold', 'register.variance-over-threshold',
99
+ { variance: fact.variance, threshold: fact.threshold }, 'warn');
100
+ // These prove dispatch success, not that paper printed or a physical drawer opened.
101
+ case 'x-report-dispatched':
102
+ return emit('Register X-report print dispatched', 'register.x-report-printed');
103
+ case 'drawer-dispatched':
104
+ return emit('Register drawer kick dispatched', 'register.drawer-opened');
105
+ // The money was taken, so the sale is kept and queued, outside every closure.
106
+ case 'late-sale':
107
+ return emit('Register late sale kept outside its session', 'register.late-sale', { orderId: fact.orderId }, 'warn');
108
+ }
109
+ }