@classytic/primitives 0.1.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 +123 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/address.d.mts +38 -0
- package/dist/address.mjs +16 -0
- package/dist/approval.d.mts +133 -0
- package/dist/approval.mjs +146 -0
- package/dist/brand-DApaegoR.d.mts +37 -0
- package/dist/brand.d.mts +2 -0
- package/dist/brand.mjs +1 -0
- package/dist/cadence.d.mts +93 -0
- package/dist/cadence.mjs +139 -0
- package/dist/context.d.mts +48 -0
- package/dist/context.mjs +1 -0
- package/dist/currency.d.mts +110 -0
- package/dist/currency.mjs +116 -0
- package/dist/errors.d.mts +50 -0
- package/dist/errors.mjs +20 -0
- package/dist/events.d.mts +240 -0
- package/dist/events.mjs +90 -0
- package/dist/hold.d.mts +89 -0
- package/dist/hold.mjs +69 -0
- package/dist/money.d.mts +78 -0
- package/dist/money.mjs +131 -0
- package/dist/outbox.d.mts +222 -0
- package/dist/outbox.mjs +30 -0
- package/dist/pagination.d.mts +57 -0
- package/dist/pagination.mjs +22 -0
- package/dist/period.d.mts +46 -0
- package/dist/period.mjs +31 -0
- package/dist/person.d.mts +44 -0
- package/dist/person.mjs +16 -0
- package/dist/reference.d.mts +39 -0
- package/dist/reference.mjs +10 -0
- package/dist/result.d.mts +32 -0
- package/dist/result.mjs +34 -0
- package/dist/sla.d.mts +71 -0
- package/dist/sla.mjs +57 -0
- package/dist/split-allocation.d.mts +84 -0
- package/dist/split-allocation.mjs +115 -0
- package/dist/tenant.d.mts +122 -0
- package/dist/tenant.mjs +54 -0
- package/package.json +142 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region src/cadence.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Recurrence spec + pure date arithmetic.
|
|
4
|
+
*
|
|
5
|
+
* Describes "every N days", "every weekday", "on the 15th of each month", etc.
|
|
6
|
+
* Consumed by `@classytic/order` (blanket / recurring orders, subscription
|
|
7
|
+
* renewal), `@classytic/flow` (replenishment schedules), and
|
|
8
|
+
* `@classytic/commission` (vesting periods, statement close).
|
|
9
|
+
*
|
|
10
|
+
* No cron parser dep — the `cron` kind stores an opaque string that the host
|
|
11
|
+
* parses with its own library (node-cron, croner, etc.). This keeps primitives
|
|
12
|
+
* zero-dep and lets hosts pick whatever cron dialect they want.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const monthly: Cadence = {
|
|
16
|
+
* kind: 'monthly',
|
|
17
|
+
* interval: 1,
|
|
18
|
+
* dayOfMonth: 15,
|
|
19
|
+
* startAt: new Date('2026-01-15T00:00:00Z'),
|
|
20
|
+
* };
|
|
21
|
+
* nextOccurrence(monthly, new Date('2026-02-20T00:00:00Z'));
|
|
22
|
+
* // → 2026-03-15T00:00:00Z
|
|
23
|
+
*/
|
|
24
|
+
type CadenceKind = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron';
|
|
25
|
+
/** ISO weekday — 1=Mon … 7=Sun. Matches Temporal + ISO 8601. */
|
|
26
|
+
type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
27
|
+
interface CadenceBase {
|
|
28
|
+
readonly interval: number;
|
|
29
|
+
readonly startAt: Date;
|
|
30
|
+
readonly endAt?: Date;
|
|
31
|
+
/**
|
|
32
|
+
* Optional timezone IANA name (e.g. `Asia/Dhaka`). Pure functions in this
|
|
33
|
+
* module operate on UTC Date values; the field is carried for the host to
|
|
34
|
+
* localize occurrences when displaying. Occurrence math here is UTC-based.
|
|
35
|
+
*/
|
|
36
|
+
readonly timezone?: string;
|
|
37
|
+
}
|
|
38
|
+
interface DailyCadence extends CadenceBase {
|
|
39
|
+
readonly kind: 'daily';
|
|
40
|
+
}
|
|
41
|
+
interface WeeklyCadence extends CadenceBase {
|
|
42
|
+
readonly kind: 'weekly';
|
|
43
|
+
/**
|
|
44
|
+
* If provided, only these ISO weekdays count as occurrences; the earliest
|
|
45
|
+
* matching weekday on or after `startAt` is the first occurrence. If omitted,
|
|
46
|
+
* the weekday of `startAt` is used.
|
|
47
|
+
*/
|
|
48
|
+
readonly daysOfWeek?: readonly IsoWeekday[];
|
|
49
|
+
}
|
|
50
|
+
interface MonthlyCadence extends CadenceBase {
|
|
51
|
+
readonly kind: 'monthly';
|
|
52
|
+
/**
|
|
53
|
+
* Day of month, 1–31. If the target month has fewer days (e.g. 31st → Feb),
|
|
54
|
+
* occurrence snaps to the last day of the month.
|
|
55
|
+
*/
|
|
56
|
+
readonly dayOfMonth: number;
|
|
57
|
+
}
|
|
58
|
+
interface YearlyCadence extends CadenceBase {
|
|
59
|
+
readonly kind: 'yearly';
|
|
60
|
+
/** Calendar month 1–12. */
|
|
61
|
+
readonly month: number;
|
|
62
|
+
/** Day of month 1–31. Snaps to last day of month if needed (e.g. Feb 29 → Feb 28 in non-leap years). */
|
|
63
|
+
readonly dayOfMonth: number;
|
|
64
|
+
}
|
|
65
|
+
interface CronCadence extends CadenceBase {
|
|
66
|
+
readonly kind: 'cron';
|
|
67
|
+
/** Opaque cron expression — caller parses with its own cron library. */
|
|
68
|
+
readonly expression: string;
|
|
69
|
+
}
|
|
70
|
+
type Cadence = DailyCadence | WeeklyCadence | MonthlyCadence | YearlyCadence | CronCadence;
|
|
71
|
+
type CadenceErrorCode = 'INVALID_INTERVAL' | 'INVALID_DAY_OF_MONTH' | 'INVALID_MONTH' | 'INVALID_DAYS_OF_WEEK' | 'INVALID_START_AT' | 'INVALID_END_AT' | 'UNSUPPORTED_CRON';
|
|
72
|
+
declare class CadenceError extends Error {
|
|
73
|
+
readonly name = "CadenceError";
|
|
74
|
+
readonly code: CadenceErrorCode;
|
|
75
|
+
constructor(code: CadenceErrorCode, message: string);
|
|
76
|
+
}
|
|
77
|
+
/** Validate a Cadence spec. Call this before storing host-side. */
|
|
78
|
+
declare function validateCadence(c: Cadence): void;
|
|
79
|
+
/**
|
|
80
|
+
* Next occurrence strictly after `from`. Returns `null` if the cadence has
|
|
81
|
+
* ended (endAt passed) or the first occurrence is still in the future.
|
|
82
|
+
*
|
|
83
|
+
* Cron cadences return `null` — hosts must parse the expression themselves.
|
|
84
|
+
*/
|
|
85
|
+
declare function nextOccurrence(cadence: Cadence, from: Date): Date | null;
|
|
86
|
+
/**
|
|
87
|
+
* Enumerate occurrences in `[from, to)` inclusive at `from`, exclusive at `to`.
|
|
88
|
+
* Returns up to `limit` results (default 10_000) to guard against runaway
|
|
89
|
+
* misconfigured cadences. Cron returns an empty array.
|
|
90
|
+
*/
|
|
91
|
+
declare function occurrencesBetween(cadence: Cadence, from: Date, to: Date, limit?: number): Date[];
|
|
92
|
+
//#endregion
|
|
93
|
+
export { Cadence, CadenceError, CadenceErrorCode, CadenceKind, CronCadence, DailyCadence, IsoWeekday, MonthlyCadence, WeeklyCadence, YearlyCadence, nextOccurrence, occurrencesBetween, validateCadence };
|
package/dist/cadence.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//#region src/cadence.ts
|
|
2
|
+
var CadenceError = class extends Error {
|
|
3
|
+
name = "CadenceError";
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/** Validate a Cadence spec. Call this before storing host-side. */
|
|
11
|
+
function validateCadence(c) {
|
|
12
|
+
if (!Number.isInteger(c.interval) || c.interval < 1) throw new CadenceError("INVALID_INTERVAL", `interval must be a positive integer, got ${c.interval}`);
|
|
13
|
+
if (Number.isNaN(c.startAt.getTime())) throw new CadenceError("INVALID_START_AT", "startAt is not a valid Date");
|
|
14
|
+
if (c.endAt && Number.isNaN(c.endAt.getTime())) throw new CadenceError("INVALID_END_AT", "endAt is not a valid Date");
|
|
15
|
+
if (c.endAt && c.endAt.getTime() < c.startAt.getTime()) throw new CadenceError("INVALID_END_AT", "endAt must be >= startAt");
|
|
16
|
+
if (c.kind === "weekly" && c.daysOfWeek) {
|
|
17
|
+
if (c.daysOfWeek.length === 0) throw new CadenceError("INVALID_DAYS_OF_WEEK", "daysOfWeek cannot be empty");
|
|
18
|
+
for (const d of c.daysOfWeek) if (!Number.isInteger(d) || d < 1 || d > 7) throw new CadenceError("INVALID_DAYS_OF_WEEK", `daysOfWeek entry must be 1–7, got ${d}`);
|
|
19
|
+
}
|
|
20
|
+
if (c.kind === "monthly") assertValidDayOfMonth(c.dayOfMonth);
|
|
21
|
+
if (c.kind === "yearly") {
|
|
22
|
+
if (!Number.isInteger(c.month) || c.month < 1 || c.month > 12) throw new CadenceError("INVALID_MONTH", `month must be 1–12, got ${c.month}`);
|
|
23
|
+
assertValidDayOfMonth(c.dayOfMonth);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function assertValidDayOfMonth(day) {
|
|
27
|
+
if (!Number.isInteger(day) || day < 1 || day > 31) throw new CadenceError("INVALID_DAY_OF_MONTH", `dayOfMonth must be 1–31, got ${day}`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Next occurrence strictly after `from`. Returns `null` if the cadence has
|
|
31
|
+
* ended (endAt passed) or the first occurrence is still in the future.
|
|
32
|
+
*
|
|
33
|
+
* Cron cadences return `null` — hosts must parse the expression themselves.
|
|
34
|
+
*/
|
|
35
|
+
function nextOccurrence(cadence, from) {
|
|
36
|
+
validateCadence(cadence);
|
|
37
|
+
if (cadence.kind === "cron") return null;
|
|
38
|
+
const fromMs = from.getTime();
|
|
39
|
+
if (cadence.endAt && fromMs >= cadence.endAt.getTime()) return null;
|
|
40
|
+
const first = cadence.startAt;
|
|
41
|
+
if (fromMs < first.getTime()) return first;
|
|
42
|
+
const next = stepForward(cadence, from);
|
|
43
|
+
if (next === null) return null;
|
|
44
|
+
if (cadence.endAt && next.getTime() > cadence.endAt.getTime()) return null;
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Enumerate occurrences in `[from, to)` inclusive at `from`, exclusive at `to`.
|
|
49
|
+
* Returns up to `limit` results (default 10_000) to guard against runaway
|
|
50
|
+
* misconfigured cadences. Cron returns an empty array.
|
|
51
|
+
*/
|
|
52
|
+
function occurrencesBetween(cadence, from, to, limit = 1e4) {
|
|
53
|
+
validateCadence(cadence);
|
|
54
|
+
if (cadence.kind === "cron") return [];
|
|
55
|
+
const result = [];
|
|
56
|
+
let cursor = cadence.startAt.getTime() >= from.getTime() ? cadence.startAt : nextOccurrence(cadence, /* @__PURE__ */ new Date(from.getTime() - 1));
|
|
57
|
+
while (cursor !== null && cursor.getTime() < to.getTime() && result.length < limit) {
|
|
58
|
+
if (cursor.getTime() >= from.getTime()) result.push(cursor);
|
|
59
|
+
cursor = nextOccurrence(cadence, cursor);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function stepForward(cadence, from) {
|
|
64
|
+
switch (cadence.kind) {
|
|
65
|
+
case "daily": return stepDaily(cadence, from);
|
|
66
|
+
case "weekly": return stepWeekly(cadence, from);
|
|
67
|
+
case "monthly": return stepMonthly(cadence, from);
|
|
68
|
+
case "yearly": return stepYearly(cadence, from);
|
|
69
|
+
default: return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function stepDaily(c, from) {
|
|
73
|
+
const start = c.startAt;
|
|
74
|
+
const diffDays = Math.floor((from.getTime() - start.getTime()) / (1440 * 60 * 1e3));
|
|
75
|
+
const nextNthDay = Math.floor(diffDays / c.interval) * c.interval + c.interval;
|
|
76
|
+
const nextMs = start.getTime() + nextNthDay * 24 * 60 * 60 * 1e3;
|
|
77
|
+
return new Date(nextMs);
|
|
78
|
+
}
|
|
79
|
+
function stepWeekly(c, from) {
|
|
80
|
+
const anchor = c.startAt;
|
|
81
|
+
const sortedDays = [...c.daysOfWeek ?? [isoWeekday(anchor)]].sort((a, b) => a - b);
|
|
82
|
+
const oneDay = 1440 * 60 * 1e3;
|
|
83
|
+
const msPerWeek = 7 * oneDay;
|
|
84
|
+
let cursor = from.getTime() + oneDay;
|
|
85
|
+
const maxSteps = Math.max(60, c.interval * 14);
|
|
86
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
87
|
+
const d = new Date(cursor);
|
|
88
|
+
if (!sortedDays.includes(isoWeekday(d))) {
|
|
89
|
+
cursor += oneDay;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (Math.floor((d.getTime() - anchor.getTime()) / msPerWeek) % c.interval === 0 && d.getTime() >= anchor.getTime()) return withTime(d, anchor);
|
|
93
|
+
cursor += oneDay;
|
|
94
|
+
}
|
|
95
|
+
return new Date(cursor);
|
|
96
|
+
}
|
|
97
|
+
function stepMonthly(c, from) {
|
|
98
|
+
const anchor = c.startAt;
|
|
99
|
+
const year = from.getUTCFullYear();
|
|
100
|
+
const monthIdx = from.getUTCMonth();
|
|
101
|
+
let candYear = anchor.getUTCFullYear();
|
|
102
|
+
let candMonth = anchor.getUTCMonth();
|
|
103
|
+
while (true) {
|
|
104
|
+
const dayClamped = clampDayOfMonth(c.dayOfMonth, candYear, candMonth);
|
|
105
|
+
const candidate = new Date(Date.UTC(candYear, candMonth, dayClamped, anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), anchor.getUTCMilliseconds()));
|
|
106
|
+
if (candidate.getTime() > from.getTime()) return candidate;
|
|
107
|
+
candMonth += c.interval;
|
|
108
|
+
while (candMonth > 11) {
|
|
109
|
+
candMonth -= 12;
|
|
110
|
+
candYear += 1;
|
|
111
|
+
}
|
|
112
|
+
if (candYear - year > 1e4) return candidate;
|
|
113
|
+
}
|
|
114
|
+
return new Date(Date.UTC(year, monthIdx, 1));
|
|
115
|
+
}
|
|
116
|
+
function stepYearly(c, from) {
|
|
117
|
+
const anchor = c.startAt;
|
|
118
|
+
const anchorYear = anchor.getUTCFullYear();
|
|
119
|
+
let candYear = anchorYear;
|
|
120
|
+
while (true) {
|
|
121
|
+
const dayClamped = clampDayOfMonth(c.dayOfMonth, candYear, c.month - 1);
|
|
122
|
+
const candidate = new Date(Date.UTC(candYear, c.month - 1, dayClamped, anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), anchor.getUTCMilliseconds()));
|
|
123
|
+
if (candidate.getTime() > from.getTime()) return candidate;
|
|
124
|
+
candYear += c.interval;
|
|
125
|
+
if (candYear - anchorYear > 1e4) return candidate;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function isoWeekday(d) {
|
|
129
|
+
return (d.getUTCDay() + 6) % 7 + 1;
|
|
130
|
+
}
|
|
131
|
+
function withTime(d, timeSource) {
|
|
132
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), timeSource.getUTCHours(), timeSource.getUTCMinutes(), timeSource.getUTCSeconds(), timeSource.getUTCMilliseconds()));
|
|
133
|
+
}
|
|
134
|
+
function clampDayOfMonth(day, year, monthIdx) {
|
|
135
|
+
const lastDay = new Date(Date.UTC(year, monthIdx + 1, 0)).getUTCDate();
|
|
136
|
+
return Math.min(day, lastDay);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { CadenceError, nextOccurrence, occurrencesBetween, validateCadence };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { IdLike } from "./reference.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/context.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Operation context threaded through package APIs.
|
|
6
|
+
*
|
|
7
|
+
* This is NOT the HTTP request context — it's the minimal identity + tracing
|
|
8
|
+
* bag that a package needs to scope a domain operation. Hosts map their
|
|
9
|
+
* request context (Arc, Express, tRPC, worker job) to this shape when calling
|
|
10
|
+
* into a package.
|
|
11
|
+
*
|
|
12
|
+
* All fields are optional so packages can run unauthenticated (e.g. in tests,
|
|
13
|
+
* workers, migration scripts) without a ceremonial context object.
|
|
14
|
+
*/
|
|
15
|
+
interface OperationContext {
|
|
16
|
+
/** Authenticated principal (user, service account, API key id). */
|
|
17
|
+
actorId?: IdLike;
|
|
18
|
+
/** Tenant / organization / branch scope. Interpretation is host-defined. */
|
|
19
|
+
organizationId?: IdLike;
|
|
20
|
+
/** Distributed trace identifier (e.g. OpenTelemetry trace-id). */
|
|
21
|
+
traceId?: string;
|
|
22
|
+
/** Correlation identifier, typically per logical operation. */
|
|
23
|
+
correlationId?: string;
|
|
24
|
+
/** Request identifier (e.g. Fastify `req.id`). */
|
|
25
|
+
requestId?: string;
|
|
26
|
+
/** Idempotency key supplied by the caller. */
|
|
27
|
+
idempotencyKey?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Storage-layer session/transaction handle. Opaque here — the consuming
|
|
30
|
+
* package is expected to know how to use it (e.g. Mongoose `ClientSession`).
|
|
31
|
+
*/
|
|
32
|
+
session?: unknown;
|
|
33
|
+
/**
|
|
34
|
+
* Soft-delete behaviour toggle — packages that expose delete primitives may
|
|
35
|
+
* respect this. See PACKAGE_RULES.md §10.
|
|
36
|
+
*/
|
|
37
|
+
mode?: 'soft' | 'hard';
|
|
38
|
+
/** Free-form metadata the host may attach (locale, timezone, user-agent…). */
|
|
39
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
40
|
+
}
|
|
41
|
+
/** An identity shape — useful on audit fields (`createdBy`, `updatedBy`). */
|
|
42
|
+
interface ActorRef {
|
|
43
|
+
id: string;
|
|
44
|
+
type?: 'user' | 'service' | 'system' | 'api-key' | string;
|
|
45
|
+
name?: string;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { ActorRef, OperationContext };
|
package/dist/context.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { t as Brand } from "./brand-DApaegoR.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/currency.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* ISO 4217 currency code (3 uppercase letters).
|
|
6
|
+
*
|
|
7
|
+
* Branded `string` — any 3-letter string is structurally valid, but the brand
|
|
8
|
+
* prevents accidental mixing with raw strings. Use {@link toCurrencyCode} to
|
|
9
|
+
* construct one safely.
|
|
10
|
+
*/
|
|
11
|
+
type CurrencyCode = Brand<string, 'CurrencyCode'>;
|
|
12
|
+
/**
|
|
13
|
+
* Common ISO 4217 codes. Non-exhaustive — add more at the consumer level if
|
|
14
|
+
* needed. Listed here because shared-types ships with these by default.
|
|
15
|
+
*/
|
|
16
|
+
declare const CURRENCIES: {
|
|
17
|
+
readonly USD: "USD";
|
|
18
|
+
readonly EUR: "EUR";
|
|
19
|
+
readonly GBP: "GBP";
|
|
20
|
+
readonly JPY: "JPY";
|
|
21
|
+
readonly CNY: "CNY";
|
|
22
|
+
readonly INR: "INR";
|
|
23
|
+
readonly BDT: "BDT";
|
|
24
|
+
readonly AED: "AED";
|
|
25
|
+
readonly SAR: "SAR";
|
|
26
|
+
readonly PKR: "PKR";
|
|
27
|
+
readonly NPR: "NPR";
|
|
28
|
+
readonly LKR: "LKR";
|
|
29
|
+
readonly MYR: "MYR";
|
|
30
|
+
readonly SGD: "SGD";
|
|
31
|
+
readonly AUD: "AUD";
|
|
32
|
+
readonly CAD: "CAD";
|
|
33
|
+
readonly CHF: "CHF";
|
|
34
|
+
readonly HKD: "HKD";
|
|
35
|
+
readonly THB: "THB";
|
|
36
|
+
readonly IDR: "IDR";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Number of minor units per major unit for common currencies.
|
|
40
|
+
*
|
|
41
|
+
* ISO 4217 defines most currencies with 2 decimal places (cents). A few are
|
|
42
|
+
* zero-decimal (JPY, KRW) or three-decimal (BHD, KWD, JOD). When a currency
|
|
43
|
+
* isn't in this table, {@link minorUnitFactor} returns 100 as the safe default.
|
|
44
|
+
*/
|
|
45
|
+
declare const MINOR_UNIT_FACTOR: Readonly<Record<string, number>>;
|
|
46
|
+
/**
|
|
47
|
+
* Narrow a raw string to a {@link CurrencyCode}. Returns `null` if the string
|
|
48
|
+
* is not a valid ISO 4217 format (3 uppercase letters). This does not verify
|
|
49
|
+
* the code is registered with ISO — use your own whitelist for that.
|
|
50
|
+
*/
|
|
51
|
+
declare function toCurrencyCode(value: string): CurrencyCode | null;
|
|
52
|
+
/** Type predicate for `unknown` input. */
|
|
53
|
+
declare function isCurrencyCode(value: unknown): value is CurrencyCode;
|
|
54
|
+
/**
|
|
55
|
+
* Minor units per major unit (e.g. 100 for USD, 1 for JPY, 1000 for KWD).
|
|
56
|
+
* Falls back to 100 when the currency is unknown — matches the ISO 4217 default.
|
|
57
|
+
*/
|
|
58
|
+
declare function minorUnitFactor(currency: string): number;
|
|
59
|
+
/**
|
|
60
|
+
* Frozen FX conversion record — the cost of record for cross-currency
|
|
61
|
+
* transactions. Stored alongside a base-currency amount at the seam where
|
|
62
|
+
* currency crossed (procurement receipt, landed-cost entry, foreign refund).
|
|
63
|
+
*
|
|
64
|
+
* Invariant: once written, never mutated. Revaluation is a new snapshot on
|
|
65
|
+
* a new document — rewriting an existing snapshot corrupts audit history.
|
|
66
|
+
*
|
|
67
|
+
* `rate` is expressed as: 1 sourceCurrency = rate * baseCurrency
|
|
68
|
+
* (e.g. USD→BDT at 110 means rate = 110, so sourceAmount 10 USD → baseAmount 1100 BDT).
|
|
69
|
+
* This matches how banks quote — the source is the "from" currency.
|
|
70
|
+
*/
|
|
71
|
+
interface FxSnapshot {
|
|
72
|
+
/** Currency the transaction was invoiced / quoted in. ISO 4217. */
|
|
73
|
+
sourceCurrency: string;
|
|
74
|
+
/** Organisation's functional currency the base amount is expressed in. */
|
|
75
|
+
baseCurrency: string;
|
|
76
|
+
/** 1 unit of sourceCurrency equals this many units of baseCurrency. */
|
|
77
|
+
rate: number;
|
|
78
|
+
/** When the rate was captured — NOT when the document was last touched. */
|
|
79
|
+
snapshotAt: Date;
|
|
80
|
+
/**
|
|
81
|
+
* Optional provenance tag — e.g. 'manual', 'bangladesh-bank',
|
|
82
|
+
* 'openexchangerates.org', 'supplier-invoice'. Free-form; hosts use it for
|
|
83
|
+
* audit filtering and re-pricing policy.
|
|
84
|
+
*/
|
|
85
|
+
source?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Apply an {@link FxSnapshot} to a source-currency amount. Returns the
|
|
89
|
+
* equivalent base-currency amount. No rounding — callers that persist into
|
|
90
|
+
* an integer minor-unit column must round explicitly with a deterministic
|
|
91
|
+
* rule (`Math.round`, banker's rounding, floor for duty-conservative
|
|
92
|
+
* postings, etc.).
|
|
93
|
+
*/
|
|
94
|
+
declare function convertWithSnapshot(sourceAmount: number, fx: FxSnapshot): number;
|
|
95
|
+
/**
|
|
96
|
+
* Reverse an {@link FxSnapshot} — reconstruct the source-currency amount
|
|
97
|
+
* from a stored base amount. Useful for reporting "cost in original
|
|
98
|
+
* currency". Not round-trip safe under rounding; prefer storing the
|
|
99
|
+
* original source amount alongside the base amount when audit fidelity
|
|
100
|
+
* matters.
|
|
101
|
+
*/
|
|
102
|
+
declare function reverseWithSnapshot(baseAmount: number, fx: FxSnapshot): number;
|
|
103
|
+
/**
|
|
104
|
+
* Structural validation — checks shape only. Does NOT verify the rate is
|
|
105
|
+
* economically sensible (8.5 USD→BDT would pass this check). Host-level
|
|
106
|
+
* guards / anomaly detection handle that.
|
|
107
|
+
*/
|
|
108
|
+
declare function isFxSnapshot(value: unknown): value is FxSnapshot;
|
|
109
|
+
//#endregion
|
|
110
|
+
export { CURRENCIES, CurrencyCode, FxSnapshot, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//#region src/currency.ts
|
|
2
|
+
/**
|
|
3
|
+
* Common ISO 4217 codes. Non-exhaustive — add more at the consumer level if
|
|
4
|
+
* needed. Listed here because shared-types ships with these by default.
|
|
5
|
+
*/
|
|
6
|
+
const CURRENCIES = {
|
|
7
|
+
USD: "USD",
|
|
8
|
+
EUR: "EUR",
|
|
9
|
+
GBP: "GBP",
|
|
10
|
+
JPY: "JPY",
|
|
11
|
+
CNY: "CNY",
|
|
12
|
+
INR: "INR",
|
|
13
|
+
BDT: "BDT",
|
|
14
|
+
AED: "AED",
|
|
15
|
+
SAR: "SAR",
|
|
16
|
+
PKR: "PKR",
|
|
17
|
+
NPR: "NPR",
|
|
18
|
+
LKR: "LKR",
|
|
19
|
+
MYR: "MYR",
|
|
20
|
+
SGD: "SGD",
|
|
21
|
+
AUD: "AUD",
|
|
22
|
+
CAD: "CAD",
|
|
23
|
+
CHF: "CHF",
|
|
24
|
+
HKD: "HKD",
|
|
25
|
+
THB: "THB",
|
|
26
|
+
IDR: "IDR"
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Number of minor units per major unit for common currencies.
|
|
30
|
+
*
|
|
31
|
+
* ISO 4217 defines most currencies with 2 decimal places (cents). A few are
|
|
32
|
+
* zero-decimal (JPY, KRW) or three-decimal (BHD, KWD, JOD). When a currency
|
|
33
|
+
* isn't in this table, {@link minorUnitFactor} returns 100 as the safe default.
|
|
34
|
+
*/
|
|
35
|
+
const MINOR_UNIT_FACTOR = {
|
|
36
|
+
USD: 100,
|
|
37
|
+
EUR: 100,
|
|
38
|
+
GBP: 100,
|
|
39
|
+
JPY: 1,
|
|
40
|
+
CNY: 100,
|
|
41
|
+
INR: 100,
|
|
42
|
+
BDT: 100,
|
|
43
|
+
AED: 100,
|
|
44
|
+
SAR: 100,
|
|
45
|
+
PKR: 100,
|
|
46
|
+
NPR: 100,
|
|
47
|
+
LKR: 100,
|
|
48
|
+
MYR: 100,
|
|
49
|
+
SGD: 100,
|
|
50
|
+
AUD: 100,
|
|
51
|
+
CAD: 100,
|
|
52
|
+
CHF: 100,
|
|
53
|
+
HKD: 100,
|
|
54
|
+
THB: 100,
|
|
55
|
+
IDR: 100,
|
|
56
|
+
KRW: 1,
|
|
57
|
+
VND: 1,
|
|
58
|
+
BHD: 1e3,
|
|
59
|
+
KWD: 1e3,
|
|
60
|
+
JOD: 1e3,
|
|
61
|
+
OMR: 1e3,
|
|
62
|
+
TND: 1e3
|
|
63
|
+
};
|
|
64
|
+
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
65
|
+
/**
|
|
66
|
+
* Narrow a raw string to a {@link CurrencyCode}. Returns `null` if the string
|
|
67
|
+
* is not a valid ISO 4217 format (3 uppercase letters). This does not verify
|
|
68
|
+
* the code is registered with ISO — use your own whitelist for that.
|
|
69
|
+
*/
|
|
70
|
+
function toCurrencyCode(value) {
|
|
71
|
+
return CURRENCY_PATTERN.test(value) ? value : null;
|
|
72
|
+
}
|
|
73
|
+
/** Type predicate for `unknown` input. */
|
|
74
|
+
function isCurrencyCode(value) {
|
|
75
|
+
return typeof value === "string" && CURRENCY_PATTERN.test(value);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Minor units per major unit (e.g. 100 for USD, 1 for JPY, 1000 for KWD).
|
|
79
|
+
* Falls back to 100 when the currency is unknown — matches the ISO 4217 default.
|
|
80
|
+
*/
|
|
81
|
+
function minorUnitFactor(currency) {
|
|
82
|
+
return MINOR_UNIT_FACTOR[currency] ?? 100;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Apply an {@link FxSnapshot} to a source-currency amount. Returns the
|
|
86
|
+
* equivalent base-currency amount. No rounding — callers that persist into
|
|
87
|
+
* an integer minor-unit column must round explicitly with a deterministic
|
|
88
|
+
* rule (`Math.round`, banker's rounding, floor for duty-conservative
|
|
89
|
+
* postings, etc.).
|
|
90
|
+
*/
|
|
91
|
+
function convertWithSnapshot(sourceAmount, fx) {
|
|
92
|
+
return sourceAmount * fx.rate;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Reverse an {@link FxSnapshot} — reconstruct the source-currency amount
|
|
96
|
+
* from a stored base amount. Useful for reporting "cost in original
|
|
97
|
+
* currency". Not round-trip safe under rounding; prefer storing the
|
|
98
|
+
* original source amount alongside the base amount when audit fidelity
|
|
99
|
+
* matters.
|
|
100
|
+
*/
|
|
101
|
+
function reverseWithSnapshot(baseAmount, fx) {
|
|
102
|
+
if (fx.rate === 0) throw new Error("FxSnapshot.rate must be non-zero");
|
|
103
|
+
return baseAmount / fx.rate;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Structural validation — checks shape only. Does NOT verify the rate is
|
|
107
|
+
* economically sensible (8.5 USD→BDT would pass this check). Host-level
|
|
108
|
+
* guards / anomaly detection handle that.
|
|
109
|
+
*/
|
|
110
|
+
function isFxSnapshot(value) {
|
|
111
|
+
if (typeof value !== "object" || value === null) return false;
|
|
112
|
+
const v = value;
|
|
113
|
+
return typeof v.sourceCurrency === "string" && typeof v.baseCurrency === "string" && typeof v.rate === "number" && Number.isFinite(v.rate) && v.rate > 0 && v.snapshotAt instanceof Date;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { CURRENCIES, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/errors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Standard error contract — a framework-agnostic JSON shape that maps cleanly
|
|
4
|
+
* to HTTP responses, worker failure logs, and inter-service errors. Matches
|
|
5
|
+
* RFC 7807 (`application/problem+json`) loosely.
|
|
6
|
+
*
|
|
7
|
+
* Packages should throw `Error` instances. Hosts (HTTP adapters, workers)
|
|
8
|
+
* serialize those errors into this shape on the wire.
|
|
9
|
+
*/
|
|
10
|
+
interface ErrorContract {
|
|
11
|
+
/** Machine-readable, hierarchical code — e.g. 'order.validation.missing_line'. */
|
|
12
|
+
code: string;
|
|
13
|
+
/** Human-readable, safe-for-client message. */
|
|
14
|
+
message: string;
|
|
15
|
+
/** Suggested HTTP status code — hosts may override. */
|
|
16
|
+
status?: number;
|
|
17
|
+
/** Field-scoped validation errors. */
|
|
18
|
+
details?: ReadonlyArray<ErrorDetail>;
|
|
19
|
+
/** Correlation / trace identifier for support lookups. */
|
|
20
|
+
correlationId?: string;
|
|
21
|
+
/** Non-PII metadata (safe to log, safe to return to clients). */
|
|
22
|
+
meta?: Readonly<Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
interface ErrorDetail {
|
|
25
|
+
/** Dot-path pointer to the offending field, e.g. 'lines.0.quantity'. */
|
|
26
|
+
path?: string;
|
|
27
|
+
code: string;
|
|
28
|
+
message: string;
|
|
29
|
+
meta?: Readonly<Record<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Canonical error codes. Packages add their own (`order.validation.*`,
|
|
33
|
+
* `payment.gateway.*`) — these are the cross-cutting ones.
|
|
34
|
+
*/
|
|
35
|
+
declare const ERROR_CODES: {
|
|
36
|
+
readonly VALIDATION: "validation_error";
|
|
37
|
+
readonly NOT_FOUND: "not_found";
|
|
38
|
+
readonly CONFLICT: "conflict";
|
|
39
|
+
readonly UNAUTHORIZED: "unauthorized";
|
|
40
|
+
readonly FORBIDDEN: "forbidden";
|
|
41
|
+
readonly RATE_LIMITED: "rate_limited";
|
|
42
|
+
readonly IDEMPOTENCY_CONFLICT: "idempotency_conflict";
|
|
43
|
+
readonly PRECONDITION_FAILED: "precondition_failed";
|
|
44
|
+
readonly INTERNAL: "internal_error";
|
|
45
|
+
readonly UNAVAILABLE: "service_unavailable";
|
|
46
|
+
readonly TIMEOUT: "timeout";
|
|
47
|
+
};
|
|
48
|
+
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
|
49
|
+
//#endregion
|
|
50
|
+
export { ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail };
|
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical error codes. Packages add their own (`order.validation.*`,
|
|
4
|
+
* `payment.gateway.*`) — these are the cross-cutting ones.
|
|
5
|
+
*/
|
|
6
|
+
const ERROR_CODES = {
|
|
7
|
+
VALIDATION: "validation_error",
|
|
8
|
+
NOT_FOUND: "not_found",
|
|
9
|
+
CONFLICT: "conflict",
|
|
10
|
+
UNAUTHORIZED: "unauthorized",
|
|
11
|
+
FORBIDDEN: "forbidden",
|
|
12
|
+
RATE_LIMITED: "rate_limited",
|
|
13
|
+
IDEMPOTENCY_CONFLICT: "idempotency_conflict",
|
|
14
|
+
PRECONDITION_FAILED: "precondition_failed",
|
|
15
|
+
INTERNAL: "internal_error",
|
|
16
|
+
UNAVAILABLE: "service_unavailable",
|
|
17
|
+
TIMEOUT: "timeout"
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { ERROR_CODES };
|