platform-types 1.10.853 → 1.10.855

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);
@@ -7,6 +7,7 @@ exports.WEB_NOTIFICATIONS_MAX_LIMIT = exports.CUSTOMER_NOTIFICATION_TYPES = expo
7
7
  // Addressed ERROR / ML_FACTURADOR_COLLISION / ML_SELLER_SWITCHED /
8
8
  // ML_CONNECTION_BROKEN / MENTION / CUSTOMER_PENDING_APPROVAL /
9
9
  // PAYMENT_ATTEMPT_RESOLUTION / CUSTOMER_WHOLESALE_REQUEST / STOREFRONT_QUOTE_REQUEST /
10
+ // SERVICE_QUOTE_DECIDED /
10
11
  // REFUND_OBLIGATION_OVERDUE / MESSAGE_CREDITS_LOW / MESSAGE_SKIPPED_NO_CREDITS do not select a
11
12
  // producer subscriber audience;
12
13
  // the consumer still reads the live USER row for each delivery channel.
@@ -237,6 +238,24 @@ var NotificationTypeEnum;
237
238
  * socket).
238
239
  */
239
240
  NotificationTypeEnum["STOREFRONT_QUOTE_REQUEST"] = "STOREFRONT_QUOTE_REQUEST";
241
+ /**
242
+ * A customer approved or rejected a presupuesto online — by the tracking link
243
+ * (`POST /services/tracking/quote`) or signed in
244
+ * (`POST /user { mode: 'service-quote-decision' }`). The decision does not
245
+ * move the ticket's stage; the bell is the shop's cue to do it. Names the
246
+ * ticket number and the decision only — never the customer.
247
+ *
248
+ * ⚠️ ADMIN-ONLY, and its own preference key. The producer fans out to every
249
+ * non-disabled ADMIN of the store (`listActiveAdmins`), the same recipients
250
+ * as `STOREFRONT_QUOTE_REQUEST`. No legacy opt-in scan: the consumer enforces
251
+ * explicit type/channel preferences
252
+ * (`notificationPreferences.SERVICE_QUOTE_DECIDED`), default-on when absent.
253
+ * Additive — no backfill or consumer migration owed.
254
+ *
255
+ * ⚠️ Carries `serviceOrderRef` and NEVER a top-level `customerId` (an
256
+ * audience — the bell would reach the customer's own storefront socket).
257
+ */
258
+ NotificationTypeEnum["SERVICE_QUOTE_DECIDED"] = "SERVICE_QUOTE_DECIDED";
240
259
  /**
241
260
  * A refund obligation went overdue: its due date passed and the hourly
242
261
  * sweep, after reconciling the payment, still found the money owed
@@ -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
+ ];
@@ -305,6 +305,11 @@ exports.SERVICE_ORDER_ERROR_CODES = [
305
305
  'SERVICE_TRACKING_DISABLED',
306
306
  'SERVICE_TRACKING_NO_ADDRESS',
307
307
  'SERVICE_TRACKING_LINK_LAPSED',
308
+ // The customer's online quote decision (`_customerDecision.ts`, shared by the
309
+ // web routes) — see `SERVICE_TRACKING_DECISION_ERROR_CODES`
310
+ 'SERVICE_TRACKING_VERIFICATION_FAILED',
311
+ // A tracking link revoked between the handle check and the decision's commit.
312
+ 'SERVICE_TRACKING_TOKEN_INVALID',
308
313
  'ADMIN_REQUIRED',
309
314
  'CUSTODY_ILLEGAL_TRANSITION',
310
315
  'SERVICE_ORDER_BILLABLE_FROZEN',