@classytic/primitives 0.9.1 → 0.13.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 CHANGED
@@ -3,6 +3,64 @@
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.13.0] - 2026-07-22
7
+
8
+ ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
9
+
10
+ - **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
11
+ rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
12
+ `RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
13
+ dividing a total by a fractional quantity yields a sub-minor-unit rate
14
+ (100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
15
+ Exports:
16
+ - `unitCostRate(minorPerUnit, currency)` — direct rate constructor
17
+ - `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
18
+ constructor; bigint-safe, QTY_SCALE grid for fractional quantities
19
+ - `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
20
+ bigint multiplication to keep large amounts exact; default `'half-even'`
21
+ (banker's rounding), `'half-up'` available for legacy parity
22
+ - `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
23
+ - `isUnitCostRate(value)` — structural type guard
24
+ - `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
25
+ `RoundingMode`, `RATE_SCALE`
26
+ - For distributing a top-down charge across lines with no lost minor unit,
27
+ compose with `allocate` from `@classytic/primitives/split-allocation`.
28
+
29
+ ### Changed — `/outbox` is now the canonical contract owner
30
+
31
+ - **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
32
+ Previously this file was documented as a mirror of `@classytic/arc`'s copy
33
+ ("arc is source of truth, keep bit-identical") while arc's docs said the
34
+ opposite — a manual-sync drift risk. Settled: primitives owns pure
35
+ cross-package contracts (events + outbox); arc owns runtime behavior
36
+ (`EventOutbox` relay, stores, backoff, Fastify integration) and, from
37
+ arc 2.24+, imports + re-exports this contract instead of duplicating it.
38
+ - **Cross-package `instanceof` now works.** Because arc re-exports these exact
39
+ `OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
40
+ against primitives throws the same class identity arc's relay catches.
41
+ Under the old duplication, arc's `instanceof` checks silently missed
42
+ primitives-thrown ownership errors.
43
+ - No export names or signatures changed — contract surface is identical.
44
+
45
+ ## [0.11.0] - 2026-07-08
46
+
47
+ ### Added — eKYC vocabulary + secure OTP primitive
48
+
49
+ - **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
50
+ `IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
51
+ `IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
52
+ `VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
53
+ `VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
54
+ `checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
55
+ here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
56
+ (IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
57
+ - **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
58
+ never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
59
+ comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
60
+ counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
61
+ `verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
62
+ `@classytic/notifications` — this primitive owns only the verify half.
63
+
6
64
  ## [0.9.1] - 2026-07-04
7
65
 
8
66
  ### Added
@@ -0,0 +1,113 @@
1
+ import { PersonName } from "./person.mjs";
2
+
3
+ //#region src/identity/identity-verification.d.ts
4
+ /**
5
+ * Government / institutional identity documents. `trade_license` and
6
+ * `tin_certificate` support MERCHANT (business) onboarding — a payment gateway
7
+ * KYBs the business, not just a person.
8
+ */
9
+ type IdentityDocumentType = 'nid' | 'passport' | 'driving_license' | 'birth_certificate' | 'residence_permit' | 'trade_license' | 'tin_certificate' | 'other';
10
+ /** A single verification check a session can run. Providers advertise support. */
11
+ type VerificationCheck = 'document' | 'liveness' | 'face_match' | 'database' | 'address' | 'sanctions' | 'pep' | 'adverse_media';
12
+ /**
13
+ * Assurance tier — mirrors the market (Stripe Identity / Persona levels).
14
+ * Higher tiers imply a superset of checks; the exact mapping is host policy.
15
+ */
16
+ type VerificationLevel = 'basic' | 'standard' | 'enhanced';
17
+ /** The kind of subject under verification — a natural person or a business. */
18
+ type SubjectKind = 'individual' | 'business';
19
+ interface DocumentReference {
20
+ readonly type: IdentityDocumentType;
21
+ /** Document number (NID/passport no.). May be redacted/tokenized at rest. */
22
+ readonly number?: string;
23
+ /** ISO 3166-1 alpha-2 issuing country. */
24
+ readonly country?: string;
25
+ /** ISO date (YYYY-MM-DD). */
26
+ readonly expiresOn?: string;
27
+ }
28
+ interface IdentitySubject {
29
+ readonly kind: SubjectKind;
30
+ readonly name?: PersonName;
31
+ /** Legal/business name when `kind === 'business'`. */
32
+ readonly legalName?: string;
33
+ /** ISO date (YYYY-MM-DD). */
34
+ readonly dateOfBirth?: string;
35
+ /** ISO 3166-1 alpha-2. */
36
+ readonly country?: string;
37
+ }
38
+ interface IdentityProviderCapabilities {
39
+ readonly checks: readonly VerificationCheck[];
40
+ readonly documents: readonly IdentityDocumentType[];
41
+ /** ISO 3166-1 alpha-2 codes; empty ⇒ global. */
42
+ readonly countries: readonly string[];
43
+ /** Delivers async results via webhook (vs poll-only). */
44
+ readonly webhooks: boolean;
45
+ /** Supports business/KYB flows, not just individuals. */
46
+ readonly business: boolean;
47
+ }
48
+ type VerificationStatus = 'pending' | 'processing' | 'verified' | 'rejected' | 'review' | 'expired';
49
+ type CheckOutcome = 'pass' | 'fail' | 'manual' | 'skipped';
50
+ interface CheckResult {
51
+ readonly check: VerificationCheck;
52
+ readonly outcome: CheckOutcome;
53
+ /** Provider-specific reason/code for a fail/manual outcome. */
54
+ readonly reason?: string;
55
+ }
56
+ interface CreateVerificationParams {
57
+ readonly subject: IdentitySubject;
58
+ readonly level: VerificationLevel;
59
+ /** Checks the host requires to consider the subject verified. */
60
+ readonly requiredChecks?: readonly VerificationCheck[];
61
+ readonly documents?: readonly DocumentReference[];
62
+ /** Where the provider redirects the subject after a hosted flow. */
63
+ readonly redirectUrl?: string;
64
+ /** Correlates the session back to a host entity (payee id, signer id, …). */
65
+ readonly reference?: string;
66
+ readonly metadata?: Readonly<Record<string, string>>;
67
+ }
68
+ interface VerificationSession {
69
+ /** Provider-side session id — the handle a host stores and polls. */
70
+ readonly id: string;
71
+ readonly provider: string;
72
+ readonly level: VerificationLevel;
73
+ readonly status: VerificationStatus;
74
+ readonly checks: readonly CheckResult[];
75
+ /** Hosted-flow URL for the subject, when the provider uses one. */
76
+ readonly url?: string;
77
+ readonly reference?: string;
78
+ /** ISO timestamp the verification was decided. */
79
+ readonly decidedAt?: string;
80
+ /**
81
+ * ISO timestamp the verification lapses and re-KYC is required. Periodic
82
+ * re-KYC is an AML requirement; consumers gate on this (payee pauses
83
+ * payability, esign re-verifies before signing).
84
+ */
85
+ readonly expiresAt?: string;
86
+ /** Raw provider payload for audit/debugging. */
87
+ readonly raw?: unknown;
88
+ }
89
+ /** Normalized provider webhook — the async-decision callback shape. */
90
+ interface VerificationWebhookEvent {
91
+ readonly sessionId: string;
92
+ readonly provider: string;
93
+ readonly status: VerificationStatus;
94
+ readonly checks?: readonly CheckResult[];
95
+ readonly occurredAt: string;
96
+ readonly raw?: unknown;
97
+ }
98
+ /** A session is usable when it passed and has not lapsed at `now`. */
99
+ declare function isVerified(session: VerificationSession, now?: Date): boolean;
100
+ /**
101
+ * Do the check RESULTS satisfy the REQUIRED checks? Every required check must
102
+ * have a `pass`. Missing or non-pass required checks ⇒ false. Extra results
103
+ * are ignored.
104
+ */
105
+ declare function checksSatisfy(required: readonly VerificationCheck[], results: readonly CheckResult[]): boolean;
106
+ /**
107
+ * Derive a session status from check results alone (for providers that return
108
+ * raw checks without a verdict): any `fail` ⇒ rejected; any `manual` ⇒ review;
109
+ * all required present and `pass` ⇒ verified; otherwise processing.
110
+ */
111
+ declare function deriveStatus(results: readonly CheckResult[], required?: readonly VerificationCheck[]): VerificationStatus;
112
+ //#endregion
113
+ export { CheckOutcome, CheckResult, CreateVerificationParams, DocumentReference, IdentityDocumentType, IdentityProviderCapabilities, IdentitySubject, SubjectKind, VerificationCheck, VerificationLevel, VerificationSession, VerificationStatus, VerificationWebhookEvent, checksSatisfy, deriveStatus, isVerified };
@@ -0,0 +1,30 @@
1
+ //#region src/identity/identity-verification.ts
2
+ /** A session is usable when it passed and has not lapsed at `now`. */
3
+ function isVerified(session, now) {
4
+ if (session.status !== "verified") return false;
5
+ if (!session.expiresAt) return true;
6
+ const at = (now ?? /* @__PURE__ */ new Date()).toISOString();
7
+ return session.expiresAt > at;
8
+ }
9
+ /**
10
+ * Do the check RESULTS satisfy the REQUIRED checks? Every required check must
11
+ * have a `pass`. Missing or non-pass required checks ⇒ false. Extra results
12
+ * are ignored.
13
+ */
14
+ function checksSatisfy(required, results) {
15
+ const passed = new Set(results.filter((r) => r.outcome === "pass").map((r) => r.check));
16
+ return required.every((c) => passed.has(c));
17
+ }
18
+ /**
19
+ * Derive a session status from check results alone (for providers that return
20
+ * raw checks without a verdict): any `fail` ⇒ rejected; any `manual` ⇒ review;
21
+ * all required present and `pass` ⇒ verified; otherwise processing.
22
+ */
23
+ function deriveStatus(results, required = []) {
24
+ if (results.some((r) => r.outcome === "fail")) return "rejected";
25
+ if (results.some((r) => r.outcome === "manual")) return "review";
26
+ if (required.length > 0 && checksSatisfy(required, results)) return "verified";
27
+ return "processing";
28
+ }
29
+ //#endregion
30
+ export { checksSatisfy, deriveStatus, isVerified };
package/dist/otp.d.mts ADDED
@@ -0,0 +1,96 @@
1
+ //#region src/identity/otp.d.ts
2
+ /**
3
+ * OTP challenge — the pure, secure *verify* half of one-time-code verification.
4
+ *
5
+ * This primitive owns SECURITY, never delivery. Delivery (SMS / email / push /
6
+ * WhatsApp, templates, retry, fallback) is `@classytic/notifications`; the host
7
+ * passes the returned `code` to whichever channel/provider it likes. This file
8
+ * only guarantees the code cannot be forged, replayed, brute-forced, or leaked:
9
+ *
10
+ * - the plaintext code is NEVER persisted — only its HMAC-SHA256 digest is
11
+ * (a stolen challenge row cannot reveal the code);
12
+ * - comparison is constant-time (`timingSafeEqual`) — no timing oracle;
13
+ * - the code is drawn from a CSPRNG with rejection-free `randomInt` — no
14
+ * modulo bias;
15
+ * - a bounded attempt counter locks the challenge — no online brute force;
16
+ * - a TTL expires the challenge — no indefinite window;
17
+ * - a verified challenge is single-use — no replay.
18
+ *
19
+ * The shape is a plain value object: create it, persist `challenge`, and on each
20
+ * attempt feed the stored challenge back through `verifyOtpChallenge` and persist
21
+ * the returned next state. No I/O, no clock capture beyond an injectable `now`.
22
+ *
23
+ * Resend throttling (don't mint a new code too soon) is a host / arc-layer
24
+ * concern — rate-limit calls to `createOtpChallenge`; it is deliberately not
25
+ * modeled here so the verify primitive stays stateless between attempts.
26
+ */
27
+ /** Terminal + in-flight states a persisted challenge can hold. */
28
+ type OtpStatus = 'pending' | 'verified' | 'failed' | 'expired';
29
+ /** The result of a single `verifyOtpChallenge` call. */
30
+ type OtpOutcome = 'verified' | 'incorrect' | 'expired' | 'locked' | 'already_used';
31
+ /**
32
+ * The persistable challenge. Contains no secret material: `hash` is an HMAC of
33
+ * the code under a server-side `secret`, so the row is useless to a thief who
34
+ * lacks the secret. Persist this verbatim; never add the plaintext code to it.
35
+ */
36
+ interface OtpChallenge {
37
+ /** HMAC-SHA256(code, secret) as hex. The plaintext code is never stored. */
38
+ readonly hash: string;
39
+ /** ISO-8601 instant after which the challenge is dead. */
40
+ readonly expiresAt: string;
41
+ /** Failed attempts so far. */
42
+ readonly attempts: number;
43
+ /** Attempts allowed before the challenge locks (`failed`). */
44
+ readonly maxAttempts: number;
45
+ /** Lifecycle state. */
46
+ readonly status: OtpStatus;
47
+ }
48
+ interface CreateOtpOptions {
49
+ /** Server-side HMAC key. Required — a challenge with no secret is forgeable. */
50
+ readonly secret: string;
51
+ /** Number of digits in the code. Default 6. */
52
+ readonly length?: number;
53
+ /** Time-to-live in ms. Default 5 minutes. */
54
+ readonly ttlMs?: number;
55
+ /** Failed attempts before lock. Default 5. */
56
+ readonly maxAttempts?: number;
57
+ /** Injectable clock for deterministic tests. Default `new Date()`. */
58
+ readonly now?: Date;
59
+ }
60
+ interface CreatedOtp {
61
+ /** Persist this. Contains only the HMAC digest, never the code. */
62
+ readonly challenge: OtpChallenge;
63
+ /** Deliver this via `@classytic/notifications`. NEVER persist it. */
64
+ readonly code: string;
65
+ }
66
+ interface VerifyOtpOptions {
67
+ /** The same server-side HMAC key used at creation. */
68
+ readonly secret: string;
69
+ /** Injectable clock for deterministic tests. Default `new Date()`. */
70
+ readonly now?: Date;
71
+ }
72
+ interface OtpVerification {
73
+ /** The next challenge state to persist (attempt count / status advanced). */
74
+ readonly challenge: OtpChallenge;
75
+ /** What happened on this attempt. */
76
+ readonly outcome: OtpOutcome;
77
+ }
78
+ /**
79
+ * Mint a challenge + its one-time code. Persist `challenge`; hand `code` to the
80
+ * delivery layer. The code exists only in this return value and in the delivered
81
+ * message — it is never recoverable from the persisted challenge.
82
+ */
83
+ declare function createOtpChallenge(options: CreateOtpOptions): CreatedOtp;
84
+ /**
85
+ * Check `input` against a persisted challenge and return the next state. This is
86
+ * a pure transition — the caller persists `result.challenge`. Fail-closed: any
87
+ * non-`pending` challenge, an expired window, or an over-limit attempt yields a
88
+ * non-`verified` outcome and never re-opens a settled challenge.
89
+ */
90
+ declare function verifyOtpChallenge(challenge: OtpChallenge, input: string, options: VerifyOtpOptions): OtpVerification;
91
+ /** True once a challenge has been successfully verified. */
92
+ declare function isOtpVerified(challenge: OtpChallenge): boolean;
93
+ /** True while a challenge can still accept an attempt (pending + unexpired). */
94
+ declare function isOtpActive(challenge: OtpChallenge, now?: Date): boolean;
95
+ //#endregion
96
+ export { CreateOtpOptions, CreatedOtp, OtpChallenge, OtpOutcome, OtpStatus, OtpVerification, VerifyOtpOptions, createOtpChallenge, isOtpActive, isOtpVerified, verifyOtpChallenge };
package/dist/otp.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
2
+ //#region src/identity/otp.ts
3
+ /**
4
+ * OTP challenge — the pure, secure *verify* half of one-time-code verification.
5
+ *
6
+ * This primitive owns SECURITY, never delivery. Delivery (SMS / email / push /
7
+ * WhatsApp, templates, retry, fallback) is `@classytic/notifications`; the host
8
+ * passes the returned `code` to whichever channel/provider it likes. This file
9
+ * only guarantees the code cannot be forged, replayed, brute-forced, or leaked:
10
+ *
11
+ * - the plaintext code is NEVER persisted — only its HMAC-SHA256 digest is
12
+ * (a stolen challenge row cannot reveal the code);
13
+ * - comparison is constant-time (`timingSafeEqual`) — no timing oracle;
14
+ * - the code is drawn from a CSPRNG with rejection-free `randomInt` — no
15
+ * modulo bias;
16
+ * - a bounded attempt counter locks the challenge — no online brute force;
17
+ * - a TTL expires the challenge — no indefinite window;
18
+ * - a verified challenge is single-use — no replay.
19
+ *
20
+ * The shape is a plain value object: create it, persist `challenge`, and on each
21
+ * attempt feed the stored challenge back through `verifyOtpChallenge` and persist
22
+ * the returned next state. No I/O, no clock capture beyond an injectable `now`.
23
+ *
24
+ * Resend throttling (don't mint a new code too soon) is a host / arc-layer
25
+ * concern — rate-limit calls to `createOtpChallenge`; it is deliberately not
26
+ * modeled here so the verify primitive stays stateless between attempts.
27
+ */
28
+ const DEFAULT_LENGTH = 6;
29
+ const DEFAULT_TTL_MS = 300 * 1e3;
30
+ const DEFAULT_MAX_ATTEMPTS = 5;
31
+ function hmac(code, secret) {
32
+ return createHmac("sha256", secret).update(code).digest("hex");
33
+ }
34
+ /** CSPRNG numeric code with no modulo bias (`randomInt` rejection-samples). */
35
+ function generateCode(length) {
36
+ let code = "";
37
+ for (let i = 0; i < length; i += 1) code += randomInt(0, 10).toString();
38
+ return code;
39
+ }
40
+ /** Constant-time compare of two equal-length hex digests. */
41
+ function constantTimeEqualHex(a, b) {
42
+ const ab = Buffer.from(a, "hex");
43
+ const bb = Buffer.from(b, "hex");
44
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
45
+ }
46
+ /**
47
+ * Mint a challenge + its one-time code. Persist `challenge`; hand `code` to the
48
+ * delivery layer. The code exists only in this return value and in the delivered
49
+ * message — it is never recoverable from the persisted challenge.
50
+ */
51
+ function createOtpChallenge(options) {
52
+ if (!options.secret) throw new Error("[otp] a `secret` is required to HMAC the code.");
53
+ const length = options.length ?? DEFAULT_LENGTH;
54
+ if (length < 4) throw new Error("[otp] `length` must be at least 4 digits.");
55
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
56
+ if (ttlMs <= 0) throw new Error("[otp] `ttlMs` must be positive.");
57
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
58
+ if (maxAttempts < 1) throw new Error("[otp] `maxAttempts` must be at least 1.");
59
+ const now = options.now ?? /* @__PURE__ */ new Date();
60
+ const code = generateCode(length);
61
+ return {
62
+ code,
63
+ challenge: {
64
+ hash: hmac(code, options.secret),
65
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
66
+ attempts: 0,
67
+ maxAttempts,
68
+ status: "pending"
69
+ }
70
+ };
71
+ }
72
+ /**
73
+ * Check `input` against a persisted challenge and return the next state. This is
74
+ * a pure transition — the caller persists `result.challenge`. Fail-closed: any
75
+ * non-`pending` challenge, an expired window, or an over-limit attempt yields a
76
+ * non-`verified` outcome and never re-opens a settled challenge.
77
+ */
78
+ function verifyOtpChallenge(challenge, input, options) {
79
+ if (!options.secret) throw new Error("[otp] a `secret` is required to verify the code.");
80
+ if (challenge.status === "verified") return {
81
+ challenge,
82
+ outcome: "already_used"
83
+ };
84
+ if (challenge.status === "failed") return {
85
+ challenge,
86
+ outcome: "locked"
87
+ };
88
+ if (challenge.status === "expired") return {
89
+ challenge,
90
+ outcome: "expired"
91
+ };
92
+ if ((options.now ?? /* @__PURE__ */ new Date()).getTime() > Date.parse(challenge.expiresAt)) return {
93
+ challenge: {
94
+ ...challenge,
95
+ status: "expired"
96
+ },
97
+ outcome: "expired"
98
+ };
99
+ if (constantTimeEqualHex(hmac(input, options.secret), challenge.hash)) return {
100
+ challenge: {
101
+ ...challenge,
102
+ status: "verified"
103
+ },
104
+ outcome: "verified"
105
+ };
106
+ const attempts = challenge.attempts + 1;
107
+ if (attempts >= challenge.maxAttempts) return {
108
+ challenge: {
109
+ ...challenge,
110
+ attempts,
111
+ status: "failed"
112
+ },
113
+ outcome: "locked"
114
+ };
115
+ return {
116
+ challenge: {
117
+ ...challenge,
118
+ attempts
119
+ },
120
+ outcome: "incorrect"
121
+ };
122
+ }
123
+ /** True once a challenge has been successfully verified. */
124
+ function isOtpVerified(challenge) {
125
+ return challenge.status === "verified";
126
+ }
127
+ /** True while a challenge can still accept an attempt (pending + unexpired). */
128
+ function isOtpActive(challenge, now = /* @__PURE__ */ new Date()) {
129
+ return challenge.status === "pending" && now.getTime() <= Date.parse(challenge.expiresAt);
130
+ }
131
+ //#endregion
132
+ export { createOtpChallenge, isOtpActive, isOtpVerified, verifyOtpChallenge };
@@ -0,0 +1,56 @@
1
+ import { r as CurrencyCode } from "./currency-BCJnJBlb.mjs";
2
+ import { MinorUnits } from "./money.mjs";
3
+
4
+ //#region src/money/unit-cost-rate.d.ts
5
+ /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
6
+ declare const RATE_SCALE = 1000000;
7
+ /** Half-to-even (banker's) is the default; half-up available for legacy parity. */
8
+ type RoundingMode = 'half-even' | 'half-up';
9
+ /**
10
+ * Minor units per unit, stored as an integer scaled by {@link RATE_SCALE}.
11
+ * `scale` is carried so a value is self-describing on the wire even if
12
+ * `RATE_SCALE` ever changes; it is always `RATE_SCALE` today.
13
+ */
14
+ interface UnitCostRate<C extends string = string> {
15
+ /** Integer: minor units per unit × `scale`. */
16
+ readonly scaledAmount: number;
17
+ readonly currency: C | CurrencyCode | string;
18
+ /** Always {@link RATE_SCALE}. */
19
+ readonly scale: number;
20
+ }
21
+ type UnitCostRateErrorCode = 'NOT_FINITE' | 'NEGATIVE' | 'NOT_INTEGER' | 'UNSAFE_INTEGER' | 'NON_POSITIVE_QUANTITY';
22
+ declare class UnitCostRateError extends Error {
23
+ readonly name = "UnitCostRateError";
24
+ readonly code: UnitCostRateErrorCode;
25
+ constructor(code: UnitCostRateErrorCode, message: string);
26
+ }
27
+ /**
28
+ * Construct a rate from a (possibly fractional) minor-units-per-unit value.
29
+ *
30
+ * @example unitCostRate(33.3333, 'BDT') // { scaledAmount: 33_333_300, ... }
31
+ */
32
+ declare function unitCostRate<C extends string = string>(minorPerUnit: number, currency: C): UnitCostRate<C>;
33
+ /**
34
+ * Weighted-average constructor: the rate implied by an integer minor-unit
35
+ * `totalMinor` spread over a (possibly fractional) `quantity`. This is the WAC
36
+ * / layer-average rate. Overflow-safe via bigint; quantity rounded to the
37
+ * {@link QTY_SCALE} grid.
38
+ *
39
+ * @example unitCostRateFromTotal(100, 3, 'BDT') // 33.333… paisa/unit
40
+ */
41
+ declare function unitCostRateFromTotal<C extends string = string>(totalMinor: number, quantity: number, currency: C): UnitCostRate<C>;
42
+ /**
43
+ * The extended monetary amount for `quantity` units at `rate`, rounded to
44
+ * integer {@link MinorUnits} — the ONE rate→total rounding boundary.
45
+ *
46
+ * `round(quantity × scaledAmount / RATE_SCALE)`, computed in bigint so large
47
+ * amounts never lose precision. A fractional quantity is honoured to
48
+ * {@link QTY_SCALE} precision.
49
+ */
50
+ declare function extendedAmount<C extends string = string>(rate: UnitCostRate<C>, quantity: number, mode?: RoundingMode): MinorUnits<C>;
51
+ /** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
52
+ declare function rateMinorPerUnit(rate: UnitCostRate): number;
53
+ /** Structural guard. */
54
+ declare function isUnitCostRate(value: unknown): value is UnitCostRate;
55
+ //#endregion
56
+ export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
@@ -0,0 +1,116 @@
1
+ import { MinorUnits } from "./money.mjs";
2
+ //#region src/money/unit-cost-rate.ts
3
+ /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
4
+ const RATE_SCALE = 1e6;
5
+ /**
6
+ * Quantity scale used internally so a fractional quantity (e.g. `2.5`, a UoM
7
+ * split) participates in exact integer/bigint arithmetic. 6 dp of quantity
8
+ * precision — quantities finer than 1e-6 unit are rounded to this grid.
9
+ */
10
+ const QTY_SCALE = 1e6;
11
+ const DEFAULT_ROUNDING = "half-even";
12
+ var UnitCostRateError = class extends Error {
13
+ name = "UnitCostRateError";
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.code = code;
18
+ }
19
+ };
20
+ function assertSafe(scaled) {
21
+ if (!Number.isFinite(scaled)) throw new UnitCostRateError("NOT_FINITE", `unit cost rate is not finite: ${scaled}`);
22
+ if (!Number.isSafeInteger(scaled)) throw new UnitCostRateError("UNSAFE_INTEGER", `scaled unit cost ${scaled} exceeds Number.MAX_SAFE_INTEGER — value too large for this rate scale`);
23
+ }
24
+ /**
25
+ * Scale a (possibly fractional) quantity onto the {@link QTY_SCALE} grid as a
26
+ * bigint. Guards against a quantity so large that `quantity × QTY_SCALE` loses
27
+ * integer precision as a double BEFORE reaching bigint (≈ >9e9 units).
28
+ */
29
+ function scaleQuantity(quantity) {
30
+ const q = Math.round(quantity * QTY_SCALE);
31
+ if (!Number.isSafeInteger(q)) throw new UnitCostRateError("UNSAFE_INTEGER", `quantity ${quantity} is too large to scale precisely — exceeds safe-integer range`);
32
+ return BigInt(q);
33
+ }
34
+ /**
35
+ * Construct a rate from a (possibly fractional) minor-units-per-unit value.
36
+ *
37
+ * @example unitCostRate(33.3333, 'BDT') // { scaledAmount: 33_333_300, ... }
38
+ */
39
+ function unitCostRate(minorPerUnit, currency) {
40
+ if (!Number.isFinite(minorPerUnit)) throw new UnitCostRateError("NOT_FINITE", `minorPerUnit is not finite: ${minorPerUnit}`);
41
+ if (minorPerUnit < 0) throw new UnitCostRateError("NEGATIVE", `unit cost cannot be negative: ${minorPerUnit}`);
42
+ const scaledAmount = Math.round(minorPerUnit * RATE_SCALE);
43
+ assertSafe(scaledAmount);
44
+ return {
45
+ scaledAmount,
46
+ currency,
47
+ scale: RATE_SCALE
48
+ };
49
+ }
50
+ /**
51
+ * Weighted-average constructor: the rate implied by an integer minor-unit
52
+ * `totalMinor` spread over a (possibly fractional) `quantity`. This is the WAC
53
+ * / layer-average rate. Overflow-safe via bigint; quantity rounded to the
54
+ * {@link QTY_SCALE} grid.
55
+ *
56
+ * @example unitCostRateFromTotal(100, 3, 'BDT') // 33.333… paisa/unit
57
+ */
58
+ function unitCostRateFromTotal(totalMinor, quantity, currency) {
59
+ if (!Number.isInteger(totalMinor)) throw new UnitCostRateError("NOT_INTEGER", `totalMinor must be integer minor units: ${totalMinor}`);
60
+ if (!Number.isFinite(quantity) || quantity <= 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `quantity must be > 0, got ${quantity}`);
61
+ const qScaled = scaleQuantity(quantity);
62
+ const numerator = BigInt(totalMinor) * BigInt(RATE_SCALE) * BigInt(QTY_SCALE);
63
+ const scaled = Number(roundedDiv(numerator, qScaled, "half-even"));
64
+ assertSafe(scaled);
65
+ return {
66
+ scaledAmount: scaled,
67
+ currency,
68
+ scale: RATE_SCALE
69
+ };
70
+ }
71
+ /**
72
+ * The extended monetary amount for `quantity` units at `rate`, rounded to
73
+ * integer {@link MinorUnits} — the ONE rate→total rounding boundary.
74
+ *
75
+ * `round(quantity × scaledAmount / RATE_SCALE)`, computed in bigint so large
76
+ * amounts never lose precision. A fractional quantity is honoured to
77
+ * {@link QTY_SCALE} precision.
78
+ */
79
+ function extendedAmount(rate, quantity, mode = DEFAULT_ROUNDING) {
80
+ if (!Number.isFinite(quantity) || quantity < 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `quantity must be >= 0, got ${quantity}`);
81
+ const qScaled = scaleQuantity(quantity);
82
+ const numerator = BigInt(rate.scaledAmount) * qScaled;
83
+ const denominator = BigInt(rate.scale) * BigInt(QTY_SCALE);
84
+ const minor = Number(roundedDiv(numerator, denominator, mode));
85
+ if (!Number.isSafeInteger(minor)) throw new UnitCostRateError("UNSAFE_INTEGER", `extended amount ${minor} exceeds safe integer range`);
86
+ return MinorUnits(minor, rate.currency);
87
+ }
88
+ /** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
89
+ function rateMinorPerUnit(rate) {
90
+ return rate.scaledAmount / rate.scale;
91
+ }
92
+ /** Structural guard. */
93
+ function isUnitCostRate(value) {
94
+ return typeof value === "object" && value !== null && Number.isInteger(value.scaledAmount) && typeof value.currency === "string" && typeof value.scale === "number";
95
+ }
96
+ /**
97
+ * Rounded integer division of two bigints, `numerator / denominator`
98
+ * (`denominator > 0`), honouring the rounding mode at the exact half.
99
+ * Sign-safe for negative numerators.
100
+ */
101
+ function roundedDiv(numerator, denominator, mode) {
102
+ const neg = numerator < 0n;
103
+ const n = neg ? -numerator : numerator;
104
+ const q = n / denominator;
105
+ const remainder = n - q * denominator;
106
+ if (remainder === 0n) return neg ? -q : q;
107
+ const twiceRem = remainder * 2n;
108
+ let up;
109
+ if (twiceRem > denominator) up = true;
110
+ else if (twiceRem < denominator) up = false;
111
+ else up = mode === "half-up" ? true : q % 2n !== 0n;
112
+ const result = up ? q + 1n : q;
113
+ return neg ? -result : result;
114
+ }
115
+ //#endregion
116
+ export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.9.1",
3
+ "version": "0.13.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",
@@ -29,6 +29,10 @@
29
29
  "types": "./dist/currency.d.mts",
30
30
  "default": "./dist/currency.mjs"
31
31
  },
32
+ "./unit-cost-rate": {
33
+ "types": "./dist/unit-cost-rate.d.mts",
34
+ "default": "./dist/unit-cost-rate.mjs"
35
+ },
32
36
  "./address": {
33
37
  "types": "./dist/address.d.mts",
34
38
  "default": "./dist/address.mjs"
@@ -97,6 +101,14 @@
97
101
  "types": "./dist/person.d.mts",
98
102
  "default": "./dist/person.mjs"
99
103
  },
104
+ "./identity-verification": {
105
+ "types": "./dist/identity-verification.d.mts",
106
+ "default": "./dist/identity-verification.mjs"
107
+ },
108
+ "./otp": {
109
+ "types": "./dist/otp.d.mts",
110
+ "default": "./dist/otp.mjs"
111
+ },
100
112
  "./state-machine": {
101
113
  "types": "./dist/state-machine.d.mts",
102
114
  "default": "./dist/state-machine.mjs"