platform-types 1.10.852 → 1.10.854

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/dist/account.d.ts CHANGED
@@ -188,6 +188,12 @@ declare global {
188
188
  * payment): the obligation whose refund the debit books. Absent on every other row.
189
189
  */
190
190
  refundObligationId?: string;
191
+ /**
192
+ * On a link-credit reversal row (SK `LINKREV#{reversedAccountId}`, `paymentRef*` naming the linked
193
+ * payment): the ledger row it reverses — the payment's credit, or that credit's FX adjustment.
194
+ * Absent on every other row.
195
+ */
196
+ reversesAccountId?: string;
191
197
  }
192
198
  /**
193
199
  * `POST /account` — a new customer-ledger movement. Mirrors the request side of
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The cuenta-corriente allocator — which customer debits are still open, derived
3
+ * on read from `ACCOUNT#{storeId}` rows, in INTEGER MINOR UNITS. One
4
+ * implementation shared by the api, the operator app and the storefront, so the
5
+ * three agree on which debit a payment can still settle.
6
+ *
7
+ * No allocation entity is stored: a credit carries no pointer to the debit it
8
+ * paid, so every reader re-derives it, and two readers that derive it
9
+ * differently tell the customer two different things. The rules:
10
+ *
11
+ * 1. **Row order is `createdAt` ascending, NEVER `dated`.** A backdated row is
12
+ * allocated where it was written. Ties: a debit before a credit (an order's
13
+ * debit and its auto-linked payment can share a millisecond), then
14
+ * `accountId`, so the result never depends on the order DynamoDB returned.
15
+ * 2. **A credit stamped with an `orderId` serves that order's open debits
16
+ * first**, oldest first, and only debits already on the ledger — it cannot
17
+ * reach a debit of that order written after it.
18
+ * 3. **Everything else applies oldest-first**: an unstamped credit, and the rest
19
+ * of a stamped one once its order is paid (or has no debit yet).
20
+ * 4. **Credit with no open debit to land on waits unapplied** and is drained by
21
+ * the next debit, so a deposit taken before the order is booked still pays
22
+ * it. What is never drained is reported as `unappliedMinor`.
23
+ *
24
+ * Rows it reads, and how:
25
+ *
26
+ * - A `deleted` row, or one whose `createdAt` is past `asOf`, is skipped.
27
+ * - A synthesised opening row (`ACC000001`, `ACC000001-<suffix>` — the "Saldo
28
+ * anterior" a statement leads with) is a movement of its `balance`: positive
29
+ * is an orderless debit, negative an unstamped credit, and any `orderId` on
30
+ * it is ignored. A `balance` on any OTHER row is a projected running total
31
+ * and is ignored.
32
+ * - A row whose `debit` is above zero is a debit (its `credit` is then not
33
+ * read); otherwise a row whose `credit` is above zero at the ledger scale is
34
+ * a credit; anything else moves nothing. A stored `BALANCE-` checkpoint
35
+ * carries neither, so it moves nothing — the ledger must be read from its
36
+ * first row instead, because FIFO over a ledger with a missing debit is
37
+ * wrong.
38
+ * - A refund-in-transit debit (`RIT#…`) is an ordinary debit stamped with its
39
+ * order. It is reported like any other item; a consumer offering debits to
40
+ * pay excludes it by its `accountId` if it must.
41
+ *
42
+ * ⚠️ **ONE ledger bucket — `ledgerCurrency` — never one per row label.** Every
43
+ * writer stores `debit`/`credit` already converted into the store's ledger
44
+ * denomination; a legacy manual row's `currency` labels what the operator
45
+ * TYPED, so bucketing by it files pesos as dollars. Opening rows follow the
46
+ * same rule, a legacy `ACC000001-usd` seed included. Every item therefore
47
+ * carries `ledgerCurrency`.
48
+ *
49
+ * ⚠️ **Exact, with no display tolerance.** An item is `paid` only at zero minor
50
+ * units. A reader that rounds to fewer decimals than the ledger scale (a store
51
+ * pricing in whole units on a two-decimal ledger) can call "paid" what this
52
+ * calls `partial`; that tolerance is the reader's to apply, on top of these
53
+ * figures. Amounts are quantised once per row — half away from zero after
54
+ * normalising the scaled value to 15 significant digits, the api ledger's own
55
+ * rule — and never summed as floats. A missing or non-finite amount is zero.
56
+ *
57
+ * Pure: no clock, no I/O, input rows never mutated.
58
+ */
59
+ /** An item's allocation state, as a code — the reader owns the label. */
60
+ export declare const ACCOUNT_ITEM_STATUSES: readonly ["open", "partial", "paid"];
61
+ export type AccountItemStatus = (typeof ACCOUNT_ITEM_STATUSES)[number];
62
+ /** The fields the allocator reads — a stored row and either wire projection all satisfy it. */
63
+ export type AllocatableAccountRow = Pick<Account, 'accountId' | 'dated'> & Partial<Pick<Account, 'createdAt' | 'orderId' | 'dueDate' | 'details' | 'debit' | 'credit' | 'balance' | 'deleted'>>;
64
+ export interface AccountAllocationOptions {
65
+ /** The store's ledger denomination (`store.config.displayCurrency` as a catalogId). Lowercased on output. */
66
+ ledgerCurrency: string;
67
+ /** Decimal places of `ledgerCurrency`'s minor unit, an integer 0–4. Defaults to 2. */
68
+ ledgerScale?: number;
69
+ /** Unix ms; rows written after it are left out. Omitted, every row counts. */
70
+ asOf?: number;
71
+ }
72
+ /** One debit row after allocation. */
73
+ export interface AllocatedAccountItem {
74
+ /** The debit row's `Account.accountId`. */
75
+ accountId: string;
76
+ /** Absent for orderless debt — an empty `orderId` included, which allocation treats as orderless too. */
77
+ orderId?: string;
78
+ /** `AccountLedgerAllocation.currency`. */
79
+ currency: string;
80
+ /** `YYYYMMDD` ledger day the debit is dated — what an aging read buckets. */
81
+ dated: number;
82
+ /** `YYYYMMDD` due day stamped on the row. Absent means unspecified — never inferred from `dated`. */
83
+ dueDate?: number;
84
+ /** The row's `Account.details`, verbatim when the row carries the key. */
85
+ details?: string;
86
+ /** The row's `createdAt` — the axis allocation ran on. */
87
+ createdAt: number;
88
+ /** The debit, in minor units of `currency`. */
89
+ originalMinor: number;
90
+ /** How much of it credits have covered. */
91
+ paidMinor: number;
92
+ /** `originalMinor - paidMinor`, never negative. */
93
+ remainingMinor: number;
94
+ status: AccountItemStatus;
95
+ }
96
+ /** Every debit of one order, summed. */
97
+ export interface AllocatedAccountOrder {
98
+ originalMinor: number;
99
+ paidMinor: number;
100
+ remainingMinor: number;
101
+ status: AccountItemStatus;
102
+ }
103
+ export interface AccountLedgerAllocation {
104
+ /** The ledger bucket every amount here is in: `ledgerCurrency`, lowercased. */
105
+ currency: string;
106
+ /** Decimal places of one minor unit of `currency`. */
107
+ scale: number;
108
+ /** Every debit row, in allocation order, paid ones included. */
109
+ items: AllocatedAccountItem[];
110
+ /** The items carrying an `orderId`, summed per order. */
111
+ orders: Record<string, AllocatedAccountOrder>;
112
+ /** Credit no debit absorbed — owed to the customer. Never negative. */
113
+ unappliedMinor: number;
114
+ /** Debits minus credits: positive is owed by the customer. Equals the open items' total minus `unappliedMinor`. */
115
+ saldoMinor: number;
116
+ }
117
+ /**
118
+ * Allocate a customer's ledger. Pass the WHOLE ledger — every movement from the
119
+ * first, or a synthesised opening row standing in for what precedes the rows —
120
+ * since a debit missing from the input leaves a later credit landing on the
121
+ * wrong one. Throws a `RangeError` on a scale outside 0–4.
122
+ */
123
+ export declare const allocateAccountLedger: (rows: readonly AllocatableAccountRow[], options: AccountAllocationOptions) => AccountLedgerAllocation;
124
+ /** The items of an allocation that still owe something, in allocation order. */
125
+ export declare const openAccountItems: (allocation: AccountLedgerAllocation) => AllocatedAccountItem[];
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The cuenta-corriente allocator — which customer debits are still open, derived
3
+ * on read from `ACCOUNT#{storeId}` rows, in INTEGER MINOR UNITS. One
4
+ * implementation shared by the api, the operator app and the storefront, so the
5
+ * three agree on which debit a payment can still settle.
6
+ *
7
+ * No allocation entity is stored: a credit carries no pointer to the debit it
8
+ * paid, so every reader re-derives it, and two readers that derive it
9
+ * differently tell the customer two different things. The rules:
10
+ *
11
+ * 1. **Row order is `createdAt` ascending, NEVER `dated`.** A backdated row is
12
+ * allocated where it was written. Ties: a debit before a credit (an order's
13
+ * debit and its auto-linked payment can share a millisecond), then
14
+ * `accountId`, so the result never depends on the order DynamoDB returned.
15
+ * 2. **A credit stamped with an `orderId` serves that order's open debits
16
+ * first**, oldest first, and only debits already on the ledger — it cannot
17
+ * reach a debit of that order written after it.
18
+ * 3. **Everything else applies oldest-first**: an unstamped credit, and the rest
19
+ * of a stamped one once its order is paid (or has no debit yet).
20
+ * 4. **Credit with no open debit to land on waits unapplied** and is drained by
21
+ * the next debit, so a deposit taken before the order is booked still pays
22
+ * it. What is never drained is reported as `unappliedMinor`.
23
+ *
24
+ * Rows it reads, and how:
25
+ *
26
+ * - A `deleted` row, or one whose `createdAt` is past `asOf`, is skipped.
27
+ * - A synthesised opening row (`ACC000001`, `ACC000001-<suffix>` — the "Saldo
28
+ * anterior" a statement leads with) is a movement of its `balance`: positive
29
+ * is an orderless debit, negative an unstamped credit, and any `orderId` on
30
+ * it is ignored. A `balance` on any OTHER row is a projected running total
31
+ * and is ignored.
32
+ * - A row whose `debit` is above zero is a debit (its `credit` is then not
33
+ * read); otherwise a row whose `credit` is above zero at the ledger scale is
34
+ * a credit; anything else moves nothing. A stored `BALANCE-` checkpoint
35
+ * carries neither, so it moves nothing — the ledger must be read from its
36
+ * first row instead, because FIFO over a ledger with a missing debit is
37
+ * wrong.
38
+ * - A refund-in-transit debit (`RIT#…`) is an ordinary debit stamped with its
39
+ * order. It is reported like any other item; a consumer offering debits to
40
+ * pay excludes it by its `accountId` if it must.
41
+ *
42
+ * ⚠️ **ONE ledger bucket — `ledgerCurrency` — never one per row label.** Every
43
+ * writer stores `debit`/`credit` already converted into the store's ledger
44
+ * denomination; a legacy manual row's `currency` labels what the operator
45
+ * TYPED, so bucketing by it files pesos as dollars. Opening rows follow the
46
+ * same rule, a legacy `ACC000001-usd` seed included. Every item therefore
47
+ * carries `ledgerCurrency`.
48
+ *
49
+ * ⚠️ **Exact, with no display tolerance.** An item is `paid` only at zero minor
50
+ * units. A reader that rounds to fewer decimals than the ledger scale (a store
51
+ * pricing in whole units on a two-decimal ledger) can call "paid" what this
52
+ * calls `partial`; that tolerance is the reader's to apply, on top of these
53
+ * figures. Amounts are quantised once per row — half away from zero after
54
+ * normalising the scaled value to 15 significant digits, the api ledger's own
55
+ * rule — and never summed as floats. A missing or non-finite amount is zero.
56
+ *
57
+ * Pure: no clock, no I/O, input rows never mutated.
58
+ */
59
+ /** An item's allocation state, as a code — the reader owns the label. */
60
+ export const ACCOUNT_ITEM_STATUSES = ['open', 'partial', 'paid'];
61
+ const OPENING_ROW_ID = 'ACC000001';
62
+ const DEFAULT_SCALE = 2;
63
+ const MAX_SCALE = 4;
64
+ const isOpeningRow = (accountId) => accountId === OPENING_ROW_ID || accountId.startsWith(`${OPENING_ROW_ID}-`);
65
+ const finiteOrZero = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
66
+ const toMinorUnits = (amount, scale) => {
67
+ const rounded = Math.round(Number(Math.abs(amount * 10 ** scale).toPrecision(15)));
68
+ // Never `-0`.
69
+ return amount < 0 && rounded !== 0 ? -rounded : rounded;
70
+ };
71
+ const statusOf = (originalMinor, paidMinor) => {
72
+ if (originalMinor - paidMinor <= 0)
73
+ return 'paid';
74
+ return paidMinor > 0 ? 'partial' : 'open';
75
+ };
76
+ const toMove = (row, scale) => {
77
+ const createdAt = finiteOrZero(row.createdAt);
78
+ if (isOpeningRow(row.accountId)) {
79
+ const opening = toMinorUnits(finiteOrZero(row.balance), scale);
80
+ return {
81
+ row,
82
+ createdAt,
83
+ isDebit: opening > 0,
84
+ debitMinor: Math.max(opening, 0),
85
+ creditMinor: Math.max(-opening, 0),
86
+ };
87
+ }
88
+ const debit = finiteOrZero(row.debit);
89
+ return {
90
+ row,
91
+ createdAt,
92
+ // The RAW amount decides the kind, as the operator app does: a debit too small to reach one
93
+ // minor unit is still a (zero) debit, and its `credit` is still not read.
94
+ isDebit: debit > 0,
95
+ debitMinor: toMinorUnits(debit, scale),
96
+ creditMinor: toMinorUnits(finiteOrZero(row.credit), scale),
97
+ ...(row.orderId ? { orderId: row.orderId } : {}),
98
+ };
99
+ };
100
+ const compareMoves = (a, b) => a.createdAt - b.createdAt || Number(b.isDebit) - Number(a.isDebit) || a.row.accountId.localeCompare(b.row.accountId);
101
+ /** Serves `debits` in order; returns the credit left over. */
102
+ const applyFifo = (debits, creditMinor) => {
103
+ let remaining = creditMinor;
104
+ for (const debit of debits) {
105
+ if (remaining <= 0)
106
+ break;
107
+ const open = debit.totalMinor - debit.paidMinor;
108
+ if (open <= 0)
109
+ continue;
110
+ const applied = Math.min(open, remaining);
111
+ debit.paidMinor += applied;
112
+ remaining -= applied;
113
+ }
114
+ return remaining;
115
+ };
116
+ const toItem = (tracker, currency) => {
117
+ const { row } = tracker;
118
+ return {
119
+ accountId: row.accountId,
120
+ ...(tracker.orderId ? { orderId: tracker.orderId } : {}),
121
+ currency,
122
+ dated: row.dated,
123
+ ...(row.dueDate === undefined ? {} : { dueDate: row.dueDate }),
124
+ ...(row.details === undefined ? {} : { details: row.details }),
125
+ createdAt: tracker.createdAt,
126
+ originalMinor: tracker.totalMinor,
127
+ paidMinor: tracker.paidMinor,
128
+ remainingMinor: Math.max(tracker.totalMinor - tracker.paidMinor, 0),
129
+ status: statusOf(tracker.totalMinor, tracker.paidMinor),
130
+ };
131
+ };
132
+ const summariseOrders = (items) => {
133
+ const orders = {};
134
+ for (const item of items) {
135
+ if (!item.orderId)
136
+ continue;
137
+ const originalMinor = (orders[item.orderId]?.originalMinor ?? 0) + item.originalMinor;
138
+ const paidMinor = (orders[item.orderId]?.paidMinor ?? 0) + item.paidMinor;
139
+ orders[item.orderId] = {
140
+ originalMinor,
141
+ paidMinor,
142
+ remainingMinor: Math.max(originalMinor - paidMinor, 0),
143
+ status: statusOf(originalMinor, paidMinor),
144
+ };
145
+ }
146
+ return orders;
147
+ };
148
+ /**
149
+ * Allocate a customer's ledger. Pass the WHOLE ledger — every movement from the
150
+ * first, or a synthesised opening row standing in for what precedes the rows —
151
+ * since a debit missing from the input leaves a later credit landing on the
152
+ * wrong one. Throws a `RangeError` on a scale outside 0–4.
153
+ */
154
+ export const allocateAccountLedger = (rows, options) => {
155
+ const scale = options.ledgerScale ?? DEFAULT_SCALE;
156
+ if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
157
+ throw new RangeError(`Ledger scale must be an integer in 0-${MAX_SCALE}, got ${scale}`);
158
+ }
159
+ const currency = options.ledgerCurrency.toLowerCase();
160
+ const { asOf } = options;
161
+ const moves = rows
162
+ .filter((row) => !row.deleted && (asOf === undefined || finiteOrZero(row.createdAt) <= asOf))
163
+ .map((row) => toMove(row, scale))
164
+ .sort(compareMoves);
165
+ const debits = [];
166
+ let unappliedMinor = 0;
167
+ let saldoMinor = 0;
168
+ for (const move of moves) {
169
+ if (move.isDebit) {
170
+ saldoMinor += move.debitMinor;
171
+ debits.push({
172
+ row: move.row,
173
+ createdAt: move.createdAt,
174
+ ...(move.orderId ? { orderId: move.orderId } : {}),
175
+ totalMinor: move.debitMinor,
176
+ paidMinor: 0,
177
+ });
178
+ unappliedMinor = applyFifo(debits, unappliedMinor);
179
+ continue;
180
+ }
181
+ if (move.creditMinor <= 0)
182
+ continue;
183
+ saldoMinor -= move.creditMinor;
184
+ const spare = move.orderId
185
+ ? applyFifo(debits.filter((debit) => debit.orderId === move.orderId), move.creditMinor)
186
+ : move.creditMinor;
187
+ unappliedMinor = applyFifo(debits, unappliedMinor + spare);
188
+ }
189
+ const items = debits.map((tracker) => toItem(tracker, currency));
190
+ return { currency, scale, items, orders: summariseOrders(items), unappliedMinor, saldoMinor };
191
+ };
192
+ /** The items of an allocation that still owe something, in allocation order. */
193
+ export const openAccountItems = (allocation) => allocation.items.filter((item) => item.remainingMinor > 0);
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ /**
3
+ * The cuenta-corriente allocator — which customer debits are still open, derived
4
+ * on read from `ACCOUNT#{storeId}` rows, in INTEGER MINOR UNITS. One
5
+ * implementation shared by the api, the operator app and the storefront, so the
6
+ * three agree on which debit a payment can still settle.
7
+ *
8
+ * No allocation entity is stored: a credit carries no pointer to the debit it
9
+ * paid, so every reader re-derives it, and two readers that derive it
10
+ * differently tell the customer two different things. The rules:
11
+ *
12
+ * 1. **Row order is `createdAt` ascending, NEVER `dated`.** A backdated row is
13
+ * allocated where it was written. Ties: a debit before a credit (an order's
14
+ * debit and its auto-linked payment can share a millisecond), then
15
+ * `accountId`, so the result never depends on the order DynamoDB returned.
16
+ * 2. **A credit stamped with an `orderId` serves that order's open debits
17
+ * first**, oldest first, and only debits already on the ledger — it cannot
18
+ * reach a debit of that order written after it.
19
+ * 3. **Everything else applies oldest-first**: an unstamped credit, and the rest
20
+ * of a stamped one once its order is paid (or has no debit yet).
21
+ * 4. **Credit with no open debit to land on waits unapplied** and is drained by
22
+ * the next debit, so a deposit taken before the order is booked still pays
23
+ * it. What is never drained is reported as `unappliedMinor`.
24
+ *
25
+ * Rows it reads, and how:
26
+ *
27
+ * - A `deleted` row, or one whose `createdAt` is past `asOf`, is skipped.
28
+ * - A synthesised opening row (`ACC000001`, `ACC000001-<suffix>` — the "Saldo
29
+ * anterior" a statement leads with) is a movement of its `balance`: positive
30
+ * is an orderless debit, negative an unstamped credit, and any `orderId` on
31
+ * it is ignored. A `balance` on any OTHER row is a projected running total
32
+ * and is ignored.
33
+ * - A row whose `debit` is above zero is a debit (its `credit` is then not
34
+ * read); otherwise a row whose `credit` is above zero at the ledger scale is
35
+ * a credit; anything else moves nothing. A stored `BALANCE-` checkpoint
36
+ * carries neither, so it moves nothing — the ledger must be read from its
37
+ * first row instead, because FIFO over a ledger with a missing debit is
38
+ * wrong.
39
+ * - A refund-in-transit debit (`RIT#…`) is an ordinary debit stamped with its
40
+ * order. It is reported like any other item; a consumer offering debits to
41
+ * pay excludes it by its `accountId` if it must.
42
+ *
43
+ * ⚠️ **ONE ledger bucket — `ledgerCurrency` — never one per row label.** Every
44
+ * writer stores `debit`/`credit` already converted into the store's ledger
45
+ * denomination; a legacy manual row's `currency` labels what the operator
46
+ * TYPED, so bucketing by it files pesos as dollars. Opening rows follow the
47
+ * same rule, a legacy `ACC000001-usd` seed included. Every item therefore
48
+ * carries `ledgerCurrency`.
49
+ *
50
+ * ⚠️ **Exact, with no display tolerance.** An item is `paid` only at zero minor
51
+ * units. A reader that rounds to fewer decimals than the ledger scale (a store
52
+ * pricing in whole units on a two-decimal ledger) can call "paid" what this
53
+ * calls `partial`; that tolerance is the reader's to apply, on top of these
54
+ * figures. Amounts are quantised once per row — half away from zero after
55
+ * normalising the scaled value to 15 significant digits, the api ledger's own
56
+ * rule — and never summed as floats. A missing or non-finite amount is zero.
57
+ *
58
+ * Pure: no clock, no I/O, input rows never mutated.
59
+ */
60
+ Object.defineProperty(exports, "__esModule", { value: true });
61
+ exports.openAccountItems = exports.allocateAccountLedger = exports.ACCOUNT_ITEM_STATUSES = void 0;
62
+ /** An item's allocation state, as a code — the reader owns the label. */
63
+ exports.ACCOUNT_ITEM_STATUSES = ['open', 'partial', 'paid'];
64
+ const OPENING_ROW_ID = 'ACC000001';
65
+ const DEFAULT_SCALE = 2;
66
+ const MAX_SCALE = 4;
67
+ const isOpeningRow = (accountId) => accountId === OPENING_ROW_ID || accountId.startsWith(`${OPENING_ROW_ID}-`);
68
+ const finiteOrZero = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
69
+ const toMinorUnits = (amount, scale) => {
70
+ const rounded = Math.round(Number(Math.abs(amount * 10 ** scale).toPrecision(15)));
71
+ // Never `-0`.
72
+ return amount < 0 && rounded !== 0 ? -rounded : rounded;
73
+ };
74
+ const statusOf = (originalMinor, paidMinor) => {
75
+ if (originalMinor - paidMinor <= 0)
76
+ return 'paid';
77
+ return paidMinor > 0 ? 'partial' : 'open';
78
+ };
79
+ const toMove = (row, scale) => {
80
+ const createdAt = finiteOrZero(row.createdAt);
81
+ if (isOpeningRow(row.accountId)) {
82
+ const opening = toMinorUnits(finiteOrZero(row.balance), scale);
83
+ return {
84
+ row,
85
+ createdAt,
86
+ isDebit: opening > 0,
87
+ debitMinor: Math.max(opening, 0),
88
+ creditMinor: Math.max(-opening, 0),
89
+ };
90
+ }
91
+ const debit = finiteOrZero(row.debit);
92
+ return {
93
+ row,
94
+ createdAt,
95
+ // The RAW amount decides the kind, as the operator app does: a debit too small to reach one
96
+ // minor unit is still a (zero) debit, and its `credit` is still not read.
97
+ isDebit: debit > 0,
98
+ debitMinor: toMinorUnits(debit, scale),
99
+ creditMinor: toMinorUnits(finiteOrZero(row.credit), scale),
100
+ ...(row.orderId ? { orderId: row.orderId } : {}),
101
+ };
102
+ };
103
+ const compareMoves = (a, b) => a.createdAt - b.createdAt || Number(b.isDebit) - Number(a.isDebit) || a.row.accountId.localeCompare(b.row.accountId);
104
+ /** Serves `debits` in order; returns the credit left over. */
105
+ const applyFifo = (debits, creditMinor) => {
106
+ let remaining = creditMinor;
107
+ for (const debit of debits) {
108
+ if (remaining <= 0)
109
+ break;
110
+ const open = debit.totalMinor - debit.paidMinor;
111
+ if (open <= 0)
112
+ continue;
113
+ const applied = Math.min(open, remaining);
114
+ debit.paidMinor += applied;
115
+ remaining -= applied;
116
+ }
117
+ return remaining;
118
+ };
119
+ const toItem = (tracker, currency) => {
120
+ const { row } = tracker;
121
+ return {
122
+ accountId: row.accountId,
123
+ ...(tracker.orderId ? { orderId: tracker.orderId } : {}),
124
+ currency,
125
+ dated: row.dated,
126
+ ...(row.dueDate === undefined ? {} : { dueDate: row.dueDate }),
127
+ ...(row.details === undefined ? {} : { details: row.details }),
128
+ createdAt: tracker.createdAt,
129
+ originalMinor: tracker.totalMinor,
130
+ paidMinor: tracker.paidMinor,
131
+ remainingMinor: Math.max(tracker.totalMinor - tracker.paidMinor, 0),
132
+ status: statusOf(tracker.totalMinor, tracker.paidMinor),
133
+ };
134
+ };
135
+ const summariseOrders = (items) => {
136
+ const orders = {};
137
+ for (const item of items) {
138
+ if (!item.orderId)
139
+ continue;
140
+ const originalMinor = (orders[item.orderId]?.originalMinor ?? 0) + item.originalMinor;
141
+ const paidMinor = (orders[item.orderId]?.paidMinor ?? 0) + item.paidMinor;
142
+ orders[item.orderId] = {
143
+ originalMinor,
144
+ paidMinor,
145
+ remainingMinor: Math.max(originalMinor - paidMinor, 0),
146
+ status: statusOf(originalMinor, paidMinor),
147
+ };
148
+ }
149
+ return orders;
150
+ };
151
+ /**
152
+ * Allocate a customer's ledger. Pass the WHOLE ledger — every movement from the
153
+ * first, or a synthesised opening row standing in for what precedes the rows —
154
+ * since a debit missing from the input leaves a later credit landing on the
155
+ * wrong one. Throws a `RangeError` on a scale outside 0–4.
156
+ */
157
+ const allocateAccountLedger = (rows, options) => {
158
+ const scale = options.ledgerScale ?? DEFAULT_SCALE;
159
+ if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
160
+ throw new RangeError(`Ledger scale must be an integer in 0-${MAX_SCALE}, got ${scale}`);
161
+ }
162
+ const currency = options.ledgerCurrency.toLowerCase();
163
+ const { asOf } = options;
164
+ const moves = rows
165
+ .filter((row) => !row.deleted && (asOf === undefined || finiteOrZero(row.createdAt) <= asOf))
166
+ .map((row) => toMove(row, scale))
167
+ .sort(compareMoves);
168
+ const debits = [];
169
+ let unappliedMinor = 0;
170
+ let saldoMinor = 0;
171
+ for (const move of moves) {
172
+ if (move.isDebit) {
173
+ saldoMinor += move.debitMinor;
174
+ debits.push({
175
+ row: move.row,
176
+ createdAt: move.createdAt,
177
+ ...(move.orderId ? { orderId: move.orderId } : {}),
178
+ totalMinor: move.debitMinor,
179
+ paidMinor: 0,
180
+ });
181
+ unappliedMinor = applyFifo(debits, unappliedMinor);
182
+ continue;
183
+ }
184
+ if (move.creditMinor <= 0)
185
+ continue;
186
+ saldoMinor -= move.creditMinor;
187
+ const spare = move.orderId
188
+ ? applyFifo(debits.filter((debit) => debit.orderId === move.orderId), move.creditMinor)
189
+ : move.creditMinor;
190
+ unappliedMinor = applyFifo(debits, unappliedMinor + spare);
191
+ }
192
+ const items = debits.map((tracker) => toItem(tracker, currency));
193
+ return { currency, scale, items, orders: summariseOrders(items), unappliedMinor, saldoMinor };
194
+ };
195
+ exports.allocateAccountLedger = allocateAccountLedger;
196
+ /** The items of an allocation that still owe something, in allocation order. */
197
+ const openAccountItems = (allocation) => allocation.items.filter((item) => item.remainingMinor > 0);
198
+ exports.openAccountItems = openAccountItems;
package/dist/cjs/index.js CHANGED
@@ -23,6 +23,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
23
23
  // the published ESM build keeps the extension Node's resolver demands — bare
24
24
  // `export * from "./account"` would throw ERR_MODULE_NOT_FOUND at import time.
25
25
  __exportStar(require("./account.js"), exports);
26
+ __exportStar(require("./accountAllocation.js"), exports);
26
27
  __exportStar(require("./afip.js"), exports);
27
28
  __exportStar(require("./ai.js"), exports);
28
29
  __exportStar(require("./api.js"), exports);
@@ -103,6 +104,7 @@ __exportStar(require("./rma.js"), exports);
103
104
  __exportStar(require("./roles.js"), exports);
104
105
  __exportStar(require("./sentry.js"), exports);
105
106
  __exportStar(require("./serviceOrder.js"), exports);
107
+ __exportStar(require("./serviceTracking.js"), exports);
106
108
  __exportStar(require("./serviceTemplate.js"), exports);
107
109
  __exportStar(require("./socket.js"), exports);
108
110
  __exportStar(require("./stock.js"), exports);
@@ -1,2 +1,46 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PAYMENT_CREDIT_REVERSAL_LOCK_REASONS = exports.PAYMENT_CREDIT_REVERSAL_ERROR_CODES = void 0;
4
+ /**
5
+ * The credit reversal's refusals (`PaymentCreditReversalRequest`). Each writes nothing.
6
+ *
7
+ * - `ADMIN_REQUIRED` · 403 — the token is below the ADMIN tier (an `ACCOUNTANT` is `403 ROLE_FORBIDDEN`, earlier).
8
+ * - `INVALID_INPUT` · 400 — the path's `source` is not `mp`, `stripe` or `mp_movement`.
9
+ * - `VALIDATION_FAILED` · 400 — the body is not `{ mode, reason }` or the reason is refused.
10
+ * - `PAYMENT_NOT_FOUND` · 404 — the store has no such payment.
11
+ * - `LINK_LOCKED` · 409 — `reason` is one of {@link PAYMENT_CREDIT_REVERSAL_LOCK_REASONS}.
12
+ * - `CUSTOMER_MERGED` · 409 — the credit's owner is mid-merge (or the payment carries a historical
13
+ * merged-refund flag); `data.mergedInto` names the survivor.
14
+ * - `CUSTOMER_ANONYMIZED` · 409 — a legacy credit naming no customer, on an erased customer.
15
+ * - `CUSTOMER_NOT_FOUND` · 404 — the customer whose balance the credit moved is gone.
16
+ */
17
+ exports.PAYMENT_CREDIT_REVERSAL_ERROR_CODES = [
18
+ 'ADMIN_REQUIRED',
19
+ 'INVALID_INPUT',
20
+ 'VALIDATION_FAILED',
21
+ 'PAYMENT_NOT_FOUND',
22
+ 'LINK_LOCKED',
23
+ 'CUSTOMER_MERGED',
24
+ 'CUSTOMER_ANONYMIZED',
25
+ 'CUSTOMER_NOT_FOUND',
26
+ ];
27
+ /**
28
+ * Every `reason` of the credit reversal's `409 LINK_LOCKED`:
29
+ *
30
+ * - `credit-reversed` — this credit was already reversed (`reversalAccountId` names the row): a second
31
+ * reversal of the same credit, a replay included.
32
+ * - `not-credited` — the payment's `accountId` names no credit of its own (never credited, or a pointer to
33
+ * a row this payment did not write), or the payment names no customer (un-linked by hand).
34
+ * - `refund-obligation` — a refund obligation already debited part of this credit as in transit
35
+ * (`refundObligationId`); reversing it all would debit that share twice.
36
+ * - `edited-ledger-pointer` — the payment's `fxAdjustmentAccountId` names a row that is not its own FX
37
+ * adjustment for the credit's customer, so the delta to reverse cannot be named.
38
+ * - `concurrent-link` — the payment or its ledger rows kept changing while the reversal ran; retry.
39
+ */
40
+ exports.PAYMENT_CREDIT_REVERSAL_LOCK_REASONS = [
41
+ 'credit-reversed',
42
+ 'not-credited',
43
+ 'refund-obligation',
44
+ 'edited-ledger-pointer',
45
+ 'concurrent-link',
46
+ ];
@@ -301,6 +301,11 @@ exports.SERVICE_ORDER_ERROR_CODES = [
301
301
  'WARRANTY_BELOW_LEGAL_MINIMUM',
302
302
  'TECHNICIAN_NOT_FOUND',
303
303
  'TECHNICIAN_NOT_ELIGIBLE',
304
+ // The customer tracking link (`mode: 'trackingLink'`) — see `SERVICE_TRACKING_OPERATOR_ERROR_CODES`
305
+ 'SERVICE_TRACKING_DISABLED',
306
+ 'SERVICE_TRACKING_NO_ADDRESS',
307
+ 'SERVICE_TRACKING_LINK_LAPSED',
308
+ 'ADMIN_REQUIRED',
304
309
  'CUSTODY_ILLEGAL_TRANSITION',
305
310
  'SERVICE_ORDER_BILLABLE_FROZEN',
306
311
  'SERVICE_ORDER_EDIT_CHANGED',
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Customer service-order tracking — the page a customer opens from a link a
4
+ * shop sends (`/servicio/{handle}`), and the same view for a signed-in customer
5
+ * on their account.
6
+ *
7
+ * The HANDLE is 16 base62 characters (~95 bits), stored server-side and a
8
+ * bearer credential over exactly ONE service order of one store. It authorises
9
+ * the read (`GET /services/tracking?handle=`) and the current presupuesto PDF
10
+ * (`GET /services/tracking?handle=&mode=quote-pdf`), and nothing else. Every
11
+ * earlier link of an order dies when staff revoke it. A link stops reading
12
+ * `serviceTrackingLinkDaysAfterClose` days (platform global, default 90) after
13
+ * the order reached a terminal status.
14
+ *
15
+ * The gate is `ecommerce.config.serviceTracking` AND the `workshop` module on.
16
+ * With the gate off, staff cannot mint and the signed-in reads refuse, but a
17
+ * link minted earlier keeps reading (STORE_MODULES rule 4).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.SERVICE_TRACKING_HANDLE_RE = exports.SERVICE_TRACKING_PATH_PREFIX = exports.SERVICE_TRACKING_LINK_ACTIONS = exports.SERVICE_TRACKING_OPERATOR_ERROR_CODES = exports.SERVICE_TRACKING_ACCOUNT_ERROR_CODES = exports.SERVICE_TRACKING_ERROR_CODES = void 0;
21
+ /**
22
+ * The refusals the PUBLIC tracking routes answer themselves.
23
+ *
24
+ * - SERVICE_TRACKING_TOKEN_INVALID (401): every reason a handle cannot be
25
+ * honoured — missing, malformed, unknown, revoked, past the lifetime,
26
+ * presented on another store's host or with no store host at all, or naming
27
+ * an order that is gone or disabled — with one identical body, so a handle is
28
+ * never an oracle.
29
+ * - SERVICE_QUOTE_NOT_FOUND (404, `mode=quote-pdf` only): the order has no
30
+ * current presupuesto a customer was sent.
31
+ */
32
+ exports.SERVICE_TRACKING_ERROR_CODES = ['SERVICE_TRACKING_TOKEN_INVALID', 'SERVICE_QUOTE_NOT_FOUND'];
33
+ /**
34
+ * The refusals of the signed-in reads (`GET /user?mode=service-orders` and
35
+ * `?mode=service-order&serviceOrderId=`).
36
+ *
37
+ * - SERVICE_TRACKING_DISABLED (403): the store's gate is off.
38
+ * - SERVICE_ORDER_NOT_FOUND (404): no such order, a disabled one, or one that
39
+ * belongs to another customer — one answer for all three.
40
+ */
41
+ exports.SERVICE_TRACKING_ACCOUNT_ERROR_CODES = ['SERVICE_TRACKING_DISABLED', 'SERVICE_ORDER_NOT_FOUND'];
42
+ /**
43
+ * The refusals of the staff `POST /services { mode: 'trackingLink' }`, beside
44
+ * the router's own (`ROLE_FORBIDDEN`, `VALIDATION_FAILED`, `SERVICE_ORDER_NOT_FOUND`).
45
+ *
46
+ * - SERVICE_TRACKING_DISABLED (403, mint only): the store's gate is off.
47
+ * - SERVICE_TRACKING_NO_ADDRESS (409, mint only): the store has no public
48
+ * storefront address to build the link on (no slug, or public links off).
49
+ * - SERVICE_TRACKING_LINK_LAPSED (409, mint only): the order closed more than
50
+ * `serviceTrackingLinkDaysAfterClose` days ago, so a link would not read.
51
+ * - ADMIN_REQUIRED (403, revoke only): revoking takes ADMIN, SUPERVISOR or MANAGER.
52
+ */
53
+ exports.SERVICE_TRACKING_OPERATOR_ERROR_CODES = [
54
+ 'SERVICE_TRACKING_DISABLED',
55
+ 'SERVICE_TRACKING_NO_ADDRESS',
56
+ 'SERVICE_TRACKING_LINK_LAPSED',
57
+ 'ADMIN_REQUIRED',
58
+ ];
59
+ /** `POST /services { mode: 'trackingLink' }` actions. */
60
+ exports.SERVICE_TRACKING_LINK_ACTIONS = ['mint', 'revoke'];
61
+ /** The storefront path a tracking link opens: `/servicio/{handle}`. */
62
+ exports.SERVICE_TRACKING_PATH_PREFIX = '/servicio/';
63
+ /** The handle's exact shape: 16 base62 characters. */
64
+ exports.SERVICE_TRACKING_HANDLE_RE = /^[0-9A-Za-z]{16}$/;