@classytic/primitives 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/bank-transaction.d.mts +35 -0
- package/dist/shelf-life.d.mts +114 -0
- package/dist/shelf-life.mjs +88 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.7.2] - 2026-05-30
|
|
7
|
+
|
|
8
|
+
### Added — `BankTransaction` per-account routing + pending lifecycle fields
|
|
9
|
+
|
|
10
|
+
Three additive optional fields on `BankTransaction` (`/bank-transaction`):
|
|
11
|
+
|
|
12
|
+
- **`sourceAccountId?: string`** — the vendor-side account this row belongs to
|
|
13
|
+
(Plaid `account_id`, Xero `BankAccount.AccountID`). Sync feeds return
|
|
14
|
+
transactions spanning multiple accounts in one batch; this lets consumers
|
|
15
|
+
route each row to the right account per-transaction (multi-account sync)
|
|
16
|
+
instead of forcing the whole batch onto one account. Statement formats
|
|
17
|
+
(single-account files) leave it undefined — the account lives on
|
|
18
|
+
`BankStatement.account`.
|
|
19
|
+
- **`pending?: boolean`** — the entry hasn't posted/cleared yet (real-time
|
|
20
|
+
feeds like Plaid `pending: true`; statement formats leave it undefined).
|
|
21
|
+
Consumers must treat pending rows as provisional — don't reconcile/post
|
|
22
|
+
them as final.
|
|
23
|
+
- **`supersedesExternalId?: string`** — set on a POSTED row that replaces an
|
|
24
|
+
earlier PENDING one, carrying the pending row's `externalId` (Plaid
|
|
25
|
+
`pending_transaction_id`). Pending and posted rows have different
|
|
26
|
+
`externalId`s, so consumers MUST use this back-pointer to drop/replace the
|
|
27
|
+
superseded pending row on ingest — otherwise both persist as duplicates.
|
|
28
|
+
|
|
29
|
+
Backward-compatible (both optional). Closes a fidelity gap where Plaid's
|
|
30
|
+
pending state was dropped at the canonical boundary, so downstream consumers
|
|
31
|
+
(`@classytic/fin-io` Plaid mapper, `@classytic/revenue` import) couldn't
|
|
32
|
+
distinguish provisional rows or dedupe pending→posted.
|
|
33
|
+
|
|
6
34
|
## [0.7.1] - 2026-05-26
|
|
7
35
|
|
|
8
36
|
### Added — workflow + scheduling + composition primitives
|
|
@@ -96,6 +96,41 @@ interface BankTransaction {
|
|
|
96
96
|
balanceAfter?: Money;
|
|
97
97
|
/** Format-specific entry-type code (`'DEBIT'`, `'CREDIT'`, `'CHECK'`, …). */
|
|
98
98
|
type?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Vendor-side identifier of the account this row belongs to — Plaid
|
|
101
|
+
* `account_id`, Xero `BankAccount.AccountID`. Sync feeds return transactions
|
|
102
|
+
* spanning MULTIPLE accounts in one batch, so this lets a consumer route
|
|
103
|
+
* each row to the right destination account per-transaction (multi-account
|
|
104
|
+
* sync) instead of forcing the whole batch onto one account.
|
|
105
|
+
*
|
|
106
|
+
* Statement formats (OFX / CAMT.053 / MT940 / CSV / IIF) are single-account
|
|
107
|
+
* files — they carry the account on `BankStatement.account` and leave this
|
|
108
|
+
* `undefined`. Distinct from any consumer-side account id (e.g. the host's
|
|
109
|
+
* own `bankAccountId` used in the import unique index).
|
|
110
|
+
*/
|
|
111
|
+
sourceAccountId?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The entry has not yet posted / cleared — the bank may still change its
|
|
114
|
+
* amount, description, or drop it entirely. Real-time feeds populate this
|
|
115
|
+
* (Plaid `pending: true`); statement formats (OFX / CAMT.053 / MT940 / CSV)
|
|
116
|
+
* only ever carry settled rows and leave it `undefined`.
|
|
117
|
+
*
|
|
118
|
+
* Consumers MUST treat `pending: true` rows as provisional — do not
|
|
119
|
+
* reconcile or post them as final, and expect a superseding posted row to
|
|
120
|
+
* arrive later (linked via `supersedesExternalId`).
|
|
121
|
+
*/
|
|
122
|
+
pending?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Set on a POSTED row that replaces an earlier PENDING one: the
|
|
125
|
+
* `externalId` of the pending row this entry supersedes (Plaid
|
|
126
|
+
* `pending_transaction_id`).
|
|
127
|
+
*
|
|
128
|
+
* The pending and posted rows carry DIFFERENT `externalId`s, so dedup on
|
|
129
|
+
* `externalId` alone can't collapse them — consumers MUST use this
|
|
130
|
+
* back-pointer to drop / replace the superseded pending row on ingest, or
|
|
131
|
+
* both versions persist as duplicates.
|
|
132
|
+
*/
|
|
133
|
+
supersedesExternalId?: string;
|
|
99
134
|
/**
|
|
100
135
|
* Format-specific raw record. Pass-through for audit / debugging.
|
|
101
136
|
* Consumers MUST NOT rely on this — schema may change between fin-io
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//#region src/scheduling/shelf-life.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shelf-life / lot-tracking policy + pure expiry-date derivation.
|
|
4
|
+
*
|
|
5
|
+
* Describes whether a SKU is tracked by lot/serial, whether it carries an
|
|
6
|
+
* expiration date, and the day-offsets used to derive the four lifecycle
|
|
7
|
+
* dates of a freshly-received lot. The *policy* is product-level metadata
|
|
8
|
+
* (owned by a catalog); the *derived dates* are per-lot facts (owned by a
|
|
9
|
+
* WMS). This module is the shared contract both speak so the shape can't
|
|
10
|
+
* drift between packages.
|
|
11
|
+
*
|
|
12
|
+
* Modeled on Odoo's `product_expiry` addon, which puts `tracking` +
|
|
13
|
+
* `use_expiration_date` and four integer day-deltas on `product.template`,
|
|
14
|
+
* then computes four dates on each `stock.lot`:
|
|
15
|
+
*
|
|
16
|
+
* expiration = receivedAt + shelfLifeDays
|
|
17
|
+
* bestBefore = expiration − bestBeforeDays (still safe, starts deteriorating)
|
|
18
|
+
* removal = expiration − removalDays (stop selling — FEFO sorts on THIS)
|
|
19
|
+
* alert = expiration − alertDays (raise a reminder)
|
|
20
|
+
*
|
|
21
|
+
* Consumed by `@classytic/catalog` (variant tracking policy) and
|
|
22
|
+
* `@classytic/flow` (lot date derivation at receipt, FEFO, fresh-on-hand).
|
|
23
|
+
*
|
|
24
|
+
* Zero-dep, pure UTC day arithmetic — safe in unit tests with no clock.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const policy: ShelfLifePolicy = {
|
|
28
|
+
* mode: 'lot',
|
|
29
|
+
* useExpiration: true,
|
|
30
|
+
* shelfLifeDays: 540, // 18-month medicine
|
|
31
|
+
* removalDays: 30, // pull 30 days before expiry
|
|
32
|
+
* alertDays: 60, // remind 60 days before expiry
|
|
33
|
+
* };
|
|
34
|
+
* deriveShelfLifeDates(new Date('2026-01-01T00:00:00Z'), policy);
|
|
35
|
+
* // → { expiresAt: 2027-06-25…, removalDate: 2027-05-26…, alertDate: 2027-04-26… }
|
|
36
|
+
*/
|
|
37
|
+
/** How a SKU's stock identity is tracked. Mirrors Odoo `product.tracking`. */
|
|
38
|
+
type TrackingMode = 'none' | 'lot' | 'serial';
|
|
39
|
+
interface ShelfLifePolicy {
|
|
40
|
+
/** `'none'` = tracked by quantity only; `'lot'` = batch; `'serial'` = unique unit. */
|
|
41
|
+
readonly mode: TrackingMode;
|
|
42
|
+
/**
|
|
43
|
+
* When `true`, received lots carry an expiration date and the freshness
|
|
44
|
+
* lifecycle (best-before / removal / alert) applies. When `false`, the
|
|
45
|
+
* SKU may still be lot/serial tracked for traceability but has no expiry.
|
|
46
|
+
*/
|
|
47
|
+
readonly useExpiration: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Total shelf life in days: `expiration = receivedAt + shelfLifeDays`.
|
|
50
|
+
* Omit to require a manual expiration date at receipt (no auto-derivation).
|
|
51
|
+
*/
|
|
52
|
+
readonly shelfLifeDays?: number;
|
|
53
|
+
/** Days before expiration when goods start deteriorating (best-before / use date). */
|
|
54
|
+
readonly bestBeforeDays?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Days before expiration when goods must be pulled from sellable stock.
|
|
57
|
+
* This is the FEFO sort key and the fresh-on-hand cutoff — NOT the
|
|
58
|
+
* expiration date itself.
|
|
59
|
+
*/
|
|
60
|
+
readonly removalDays?: number;
|
|
61
|
+
/** Days before expiration when an expiry reminder should fire. */
|
|
62
|
+
readonly alertDays?: number;
|
|
63
|
+
}
|
|
64
|
+
/** The four lifecycle dates of a received lot. All optional — only what the policy can derive. */
|
|
65
|
+
interface ShelfLifeDates {
|
|
66
|
+
readonly expiresAt?: Date;
|
|
67
|
+
readonly bestBeforeDate?: Date;
|
|
68
|
+
readonly removalDate?: Date;
|
|
69
|
+
readonly alertDate?: Date;
|
|
70
|
+
}
|
|
71
|
+
interface DeriveOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Explicit expiration date — overrides `shelfLifeDays` derivation (manual
|
|
74
|
+
* receiving, GS1-scanned expiry). The other three dates are then offset
|
|
75
|
+
* back from THIS date, matching Odoo's edit-expiration behavior.
|
|
76
|
+
*/
|
|
77
|
+
readonly expiresAt?: Date;
|
|
78
|
+
}
|
|
79
|
+
type ShelfLifeErrorCode = 'INVALID_MODE' | 'INVALID_DAYS' | 'INVALID_RECEIVED_AT' | 'INVALID_EXPIRES_AT';
|
|
80
|
+
declare class ShelfLifeError extends Error {
|
|
81
|
+
readonly name = "ShelfLifeError";
|
|
82
|
+
readonly code: ShelfLifeErrorCode;
|
|
83
|
+
constructor(code: ShelfLifeErrorCode, message: string);
|
|
84
|
+
}
|
|
85
|
+
/** True when the policy requires lot OR serial identity (i.e. lots get created). */
|
|
86
|
+
declare function isTracked(policy: ShelfLifePolicy): boolean;
|
|
87
|
+
/** True when received lots carry an expiration date. */
|
|
88
|
+
declare function isExpiryTracked(policy: ShelfLifePolicy): boolean;
|
|
89
|
+
/** Validate a policy. Call before storing host-side. */
|
|
90
|
+
declare function validateShelfLifePolicy(policy: ShelfLifePolicy): void;
|
|
91
|
+
/**
|
|
92
|
+
* Derive the four lifecycle dates for a lot received at `receivedAt`.
|
|
93
|
+
*
|
|
94
|
+
* Returns an empty object when the policy does not track expiry (`mode:
|
|
95
|
+
* 'none'` or `useExpiration: false`) and no explicit `expiresAt` override is
|
|
96
|
+
* given. Each of the three sub-dates is emitted only when both an expiration
|
|
97
|
+
* date resolves AND the corresponding offset is present in the policy.
|
|
98
|
+
*
|
|
99
|
+
* Offsets are clamped to never produce a date before `receivedAt` (an offset
|
|
100
|
+
* larger than the shelf life would otherwise yield a removal date in the
|
|
101
|
+
* past on day one).
|
|
102
|
+
*/
|
|
103
|
+
declare function deriveShelfLifeDates(receivedAt: Date, policy: ShelfLifePolicy, opts?: DeriveOptions): ShelfLifeDates;
|
|
104
|
+
type ShelfLifeStatus = 'none' | 'fresh' | 'alert' | 'removed' | 'expired';
|
|
105
|
+
/**
|
|
106
|
+
* Classify a lot's freshness as of `asOf`. `'removed'` means past its removal
|
|
107
|
+
* date (excluded from fresh-on-hand even though not yet expired); `'expired'`
|
|
108
|
+
* means past the hard expiration date. Returns `'none'` when no dates apply.
|
|
109
|
+
*
|
|
110
|
+
* Precedence (most severe first): expired > removed > alert > fresh.
|
|
111
|
+
*/
|
|
112
|
+
declare function shelfLifeStatus(dates: ShelfLifeDates, asOf: Date): ShelfLifeStatus;
|
|
113
|
+
//#endregion
|
|
114
|
+
export { DeriveOptions, ShelfLifeDates, ShelfLifeError, ShelfLifeErrorCode, ShelfLifePolicy, ShelfLifeStatus, TrackingMode, deriveShelfLifeDates, isExpiryTracked, isTracked, shelfLifeStatus, validateShelfLifePolicy };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/scheduling/shelf-life.ts
|
|
2
|
+
var ShelfLifeError = class extends Error {
|
|
3
|
+
name = "ShelfLifeError";
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
const DAY_MS = 1440 * 60 * 1e3;
|
|
11
|
+
const TRACKING_MODES = [
|
|
12
|
+
"none",
|
|
13
|
+
"lot",
|
|
14
|
+
"serial"
|
|
15
|
+
];
|
|
16
|
+
/** True when the policy requires lot OR serial identity (i.e. lots get created). */
|
|
17
|
+
function isTracked(policy) {
|
|
18
|
+
return policy.mode !== "none";
|
|
19
|
+
}
|
|
20
|
+
/** True when received lots carry an expiration date. */
|
|
21
|
+
function isExpiryTracked(policy) {
|
|
22
|
+
return policy.mode !== "none" && policy.useExpiration;
|
|
23
|
+
}
|
|
24
|
+
/** Validate a policy. Call before storing host-side. */
|
|
25
|
+
function validateShelfLifePolicy(policy) {
|
|
26
|
+
if (!TRACKING_MODES.includes(policy.mode)) throw new ShelfLifeError("INVALID_MODE", `mode must be one of ${TRACKING_MODES.join(", ")}, got ${String(policy.mode)}`);
|
|
27
|
+
for (const [key, val] of [
|
|
28
|
+
["shelfLifeDays", policy.shelfLifeDays],
|
|
29
|
+
["bestBeforeDays", policy.bestBeforeDays],
|
|
30
|
+
["removalDays", policy.removalDays],
|
|
31
|
+
["alertDays", policy.alertDays]
|
|
32
|
+
]) {
|
|
33
|
+
if (val === void 0) continue;
|
|
34
|
+
if (!Number.isFinite(val) || val < 0) throw new ShelfLifeError("INVALID_DAYS", `${key} must be a non-negative number, got ${String(val)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derive the four lifecycle dates for a lot received at `receivedAt`.
|
|
39
|
+
*
|
|
40
|
+
* Returns an empty object when the policy does not track expiry (`mode:
|
|
41
|
+
* 'none'` or `useExpiration: false`) and no explicit `expiresAt` override is
|
|
42
|
+
* given. Each of the three sub-dates is emitted only when both an expiration
|
|
43
|
+
* date resolves AND the corresponding offset is present in the policy.
|
|
44
|
+
*
|
|
45
|
+
* Offsets are clamped to never produce a date before `receivedAt` (an offset
|
|
46
|
+
* larger than the shelf life would otherwise yield a removal date in the
|
|
47
|
+
* past on day one).
|
|
48
|
+
*/
|
|
49
|
+
function deriveShelfLifeDates(receivedAt, policy, opts = {}) {
|
|
50
|
+
validateShelfLifePolicy(policy);
|
|
51
|
+
if (Number.isNaN(receivedAt.getTime())) throw new ShelfLifeError("INVALID_RECEIVED_AT", "receivedAt is not a valid Date");
|
|
52
|
+
if (opts.expiresAt && Number.isNaN(opts.expiresAt.getTime())) throw new ShelfLifeError("INVALID_EXPIRES_AT", "opts.expiresAt is not a valid Date");
|
|
53
|
+
let expiresAt;
|
|
54
|
+
if (opts.expiresAt) expiresAt = opts.expiresAt;
|
|
55
|
+
else if (isExpiryTracked(policy) && policy.shelfLifeDays !== void 0) expiresAt = addDays(receivedAt, policy.shelfLifeDays);
|
|
56
|
+
if (!expiresAt) return {};
|
|
57
|
+
const floor = receivedAt.getTime();
|
|
58
|
+
const offsetBack = (days) => days === void 0 ? void 0 : new Date(Math.max(floor, expiresAt.getTime() - days * DAY_MS));
|
|
59
|
+
return {
|
|
60
|
+
expiresAt,
|
|
61
|
+
...defined("bestBeforeDate", offsetBack(policy.bestBeforeDays)),
|
|
62
|
+
...defined("removalDate", offsetBack(policy.removalDays)),
|
|
63
|
+
...defined("alertDate", offsetBack(policy.alertDays))
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Classify a lot's freshness as of `asOf`. `'removed'` means past its removal
|
|
68
|
+
* date (excluded from fresh-on-hand even though not yet expired); `'expired'`
|
|
69
|
+
* means past the hard expiration date. Returns `'none'` when no dates apply.
|
|
70
|
+
*
|
|
71
|
+
* Precedence (most severe first): expired > removed > alert > fresh.
|
|
72
|
+
*/
|
|
73
|
+
function shelfLifeStatus(dates, asOf) {
|
|
74
|
+
const now = asOf.getTime();
|
|
75
|
+
if (!dates.expiresAt && !dates.removalDate && !dates.alertDate) return "none";
|
|
76
|
+
if (dates.expiresAt && now >= dates.expiresAt.getTime()) return "expired";
|
|
77
|
+
if (dates.removalDate && now >= dates.removalDate.getTime()) return "removed";
|
|
78
|
+
if (dates.alertDate && now >= dates.alertDate.getTime()) return "alert";
|
|
79
|
+
return "fresh";
|
|
80
|
+
}
|
|
81
|
+
function addDays(d, days) {
|
|
82
|
+
return new Date(d.getTime() + days * DAY_MS);
|
|
83
|
+
}
|
|
84
|
+
function defined(key, val) {
|
|
85
|
+
return val === void 0 ? {} : { [key]: val };
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { ShelfLifeError, deriveShelfLifeDates, isExpiryTracked, isTracked, shelfLifeStatus, validateShelfLifePolicy };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -81,6 +81,10 @@
|
|
|
81
81
|
"types": "./dist/sla.d.mts",
|
|
82
82
|
"default": "./dist/sla.mjs"
|
|
83
83
|
},
|
|
84
|
+
"./shelf-life": {
|
|
85
|
+
"types": "./dist/shelf-life.d.mts",
|
|
86
|
+
"default": "./dist/shelf-life.mjs"
|
|
87
|
+
},
|
|
84
88
|
"./person": {
|
|
85
89
|
"types": "./dist/person.d.mts",
|
|
86
90
|
"default": "./dist/person.mjs"
|