@classytic/primitives 0.5.0 → 0.7.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/address.d.mts +1 -1
  3. package/dist/address.mjs +1 -1
  4. package/dist/approval.d.mts +1 -1
  5. package/dist/approval.mjs +1 -1
  6. package/dist/bank-transaction.d.mts +1 -1
  7. package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
  8. package/dist/brand.d.mts +1 -1
  9. package/dist/cadence.d.mts +1 -1
  10. package/dist/cadence.mjs +1 -1
  11. package/dist/condition.d.mts +76 -0
  12. package/dist/condition.mjs +131 -0
  13. package/dist/context.d.mts +1 -1
  14. package/dist/{currency-rgUAn5KM.d.mts → currency-B4rSdN7D.d.mts} +2 -2
  15. package/dist/currency.d.mts +1 -1
  16. package/dist/currency.mjs +1 -1
  17. package/dist/events.d.mts +1 -1
  18. package/dist/events.mjs +1 -1
  19. package/dist/hold.d.mts +1 -1
  20. package/dist/hold.mjs +1 -1
  21. package/dist/mixin.d.mts +73 -0
  22. package/dist/mixin.mjs +47 -0
  23. package/dist/money.d.mts +2 -2
  24. package/dist/money.mjs +1 -1
  25. package/dist/outbox.d.mts +1 -1
  26. package/dist/outbox.mjs +1 -1
  27. package/dist/payment-allocation-status.d.mts +47 -0
  28. package/dist/payment-allocation-status.mjs +39 -0
  29. package/dist/payment-events.d.mts +141 -0
  30. package/dist/payment-events.mjs +16 -0
  31. package/dist/payment-gateway.d.mts +1 -1
  32. package/dist/payment-method-kind.d.mts +36 -0
  33. package/dist/payment-method-kind.mjs +31 -0
  34. package/dist/period.d.mts +1 -1
  35. package/dist/period.mjs +1 -1
  36. package/dist/person.d.mts +1 -1
  37. package/dist/person.mjs +1 -1
  38. package/dist/phone.d.mts +47 -0
  39. package/dist/phone.mjs +310 -0
  40. package/dist/reference.d.mts +1 -1
  41. package/dist/reference.mjs +1 -1
  42. package/dist/result.d.mts +1 -1
  43. package/dist/result.mjs +1 -1
  44. package/dist/sla-policy.d.mts +116 -0
  45. package/dist/sla-policy.mjs +120 -0
  46. package/dist/sla.d.mts +1 -1
  47. package/dist/sla.mjs +1 -1
  48. package/dist/split-allocation.d.mts +1 -1
  49. package/dist/split-allocation.mjs +1 -1
  50. package/dist/state-machine.d.mts +1 -1
  51. package/dist/state-machine.mjs +1 -1
  52. package/dist/status-history.d.mts +85 -0
  53. package/dist/status-history.mjs +48 -0
  54. package/package.json +33 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,97 @@
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.0] - 2026-05-26
7
+
8
+ ### Added — payment domain primitives
9
+
10
+ Three new subpaths shared across every payment-aware package
11
+ (`@classytic/invoice`, `@classytic/revenue`, `@classytic/order`, future
12
+ POS / AP / subscription packages). Each ships under a flat subpath so
13
+ consumers tree-shake what they don't import.
14
+
15
+ - **`/payment-method-kind`** — `PaymentMethodKind` universal vocabulary
16
+ (kind vs host-registered `methodCode`) so packages can speak `kind`
17
+ while hosts speak `code`.
18
+ - **`/payment-allocation-status`** — orthogonal allocation +
19
+ reconciliation status enums for payment legs (`unallocated`,
20
+ `partial`, `allocated`, `over_allocated`, plus reconciliation states).
21
+ - **`/payment-events`** — pure TS payload contracts for payment domain
22
+ events. Wire-only (no Zod, no runtime). Anchored on `Money` +
23
+ `PaymentMethodKind`; causation/correlation stay on `EventMeta`.
24
+
25
+ No breaking changes.
26
+
27
+ ## [0.6.0] - 2026-05-17
28
+
29
+ ### Added — five new primitives for CRM / workflow domains
30
+
31
+ Five new primitives covering gaps the existing surface didn't address.
32
+ Each ships under its own flat subpath import (no breaking changes).
33
+
34
+ - **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
35
+ normalizes free-form input to E.164, captures the country calling code
36
+ as a discrete field, and reports failures via `Result<PhoneNumber,
37
+ PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
38
+ that want region-of-issue / line-type detection layer on top with
39
+ libphonenumber-js.
40
+
41
+ - **`/status-history`** — Generic, immutable status-change log:
42
+ `StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
43
+ `lastTransitionTo()`. Captures `durationInPriorMs` at write time so
44
+ funnel-velocity dashboards ("avg time in Qualification") work without
45
+ re-walking timestamps. Pairs naturally with `/state-machine`.
46
+
47
+ - **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
48
+ (`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
49
+ / `NotCondition`, plus a total `evaluate(condition, target)` and a
50
+ `validateCondition()` for boot-time schema checks. Powers SLA scope
51
+ filters, drip "if opened/clicked" branches, automation triggers, and
52
+ permission row-scopes — anywhere a predicate needs to round-trip
53
+ through a database.
54
+
55
+ - **`/mixin`** — Additive type composition without subclassing:
56
+ `withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
57
+ Lets a `Contact` carry per-domain payloads (`mixins.customer`,
58
+ `mixins.lead-fit`) on a typed `mixins` namespace without bloating the
59
+ base type. Plain object composition — no decorators, no global
60
+ hierarchy, no proxies.
61
+
62
+ - **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
63
+ priority matrix (`urgent` / `high` / `normal` → durations), first-
64
+ response vs rolling-response semantics, and working-hours window
65
+ (weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
66
+ at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
67
+ remainingMs, breached }` from a policy + per-entity inputs. The
68
+ existing simple `/sla` interface is unchanged — `sla-policy` derives
69
+ concrete `SLA` instances from a policy + priority and delegates to it.
70
+
71
+ ### Changed — source-tree reorganization (no public-surface impact)
72
+
73
+ The 24 primitive source files moved from a flat `src/*.ts` layout into
74
+ six category folders so the package is self-documenting at the file
75
+ system level:
76
+
77
+ ```
78
+ src/
79
+ money/ money, currency, split-allocation, bank-transaction, payment-gateway
80
+ identity/ person, address, phone
81
+ scheduling/ period, cadence, sla, sla-policy
82
+ workflow/ state-machine, status-history, approval, hold, condition
83
+ events/ events, outbox
84
+ composition/ mixin, reference, context, brand, result
85
+ ```
86
+
87
+ **Public subpath imports are unchanged** — `@classytic/primitives/money`,
88
+ `@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
89
+ to `dist/<name>.mjs` regardless of source nesting. Existing consumers
90
+ upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
91
+ diff.
92
+
93
+ ### Dependencies
94
+
95
+ Still zero runtime deps. Node 22+.
96
+
6
97
  ## [0.5.0] - 2026-05-05
7
98
 
8
99
  ### Added — `/bank-transaction` subpath
@@ -1,4 +1,4 @@
1
- //#region src/address.d.ts
1
+ //#region src/identity/address.d.ts
2
2
  /**
3
3
  * Postal address.
4
4
  *
package/dist/address.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/address.ts
1
+ //#region src/identity/address.ts
2
2
  function toGeoJsonPoint(p) {
3
3
  return {
4
4
  type: "Point",
@@ -1,4 +1,4 @@
1
- //#region src/approval.d.ts
1
+ //#region src/workflow/approval.d.ts
2
2
  /**
3
3
  * Approval chain value object and pure state-transition helpers.
4
4
  *
package/dist/approval.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/approval.ts
1
+ //#region src/workflow/approval.ts
2
2
  var ApprovalError = class extends Error {
3
3
  name = "ApprovalError";
4
4
  code;
@@ -1,6 +1,6 @@
1
1
  import { Money } from "./money.mjs";
2
2
 
3
- //#region src/bank-transaction.d.ts
3
+ //#region src/money/bank-transaction.d.ts
4
4
  /**
5
5
  * The other side of a bank transaction. All fields optional — different
6
6
  * formats populate different subsets:
@@ -1,4 +1,4 @@
1
- //#region src/brand.d.ts
1
+ //#region src/composition/brand.d.ts
2
2
  /**
3
3
  * Type utilities for building nominal types and transforming shapes.
4
4
  *
package/dist/brand.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-DApaegoR.mjs";
1
+ import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-LLXqoh5J.mjs";
2
2
  export { Brand, DeepPartial, DeepReadonly, KeysMatching, NonEmptyArray, Nullable, OptionalKeys, Prettify, RequireKeys };
@@ -1,4 +1,4 @@
1
- //#region src/cadence.d.ts
1
+ //#region src/scheduling/cadence.d.ts
2
2
  /**
3
3
  * Recurrence spec + pure date arithmetic.
4
4
  *
package/dist/cadence.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/cadence.ts
1
+ //#region src/scheduling/cadence.ts
2
2
  var CadenceError = class extends Error {
3
3
  name = "CadenceError";
4
4
  code;
@@ -0,0 +1,76 @@
1
+ //#region src/workflow/condition.d.ts
2
+ /**
3
+ * Tiny JSON-serializable predicate DSL + evaluator.
4
+ *
5
+ * Hosts need to express "when this is true, do that" rules in places that
6
+ * can't carry code — SLA scope filters, drip-sequence branches ("if opened
7
+ * within 24h"), automation triggers, permission row-scopes. A 60-line
8
+ * predicate spec covers every realistic case better than a hand-rolled
9
+ * SQL-like grammar.
10
+ *
11
+ * Two layers:
12
+ * 1. `Condition` — pure data. JSON-safe. Round-trips through DB.
13
+ * 2. `evaluate(condition, value)` — pure function. Walks a dotted-path
14
+ * expression on `value`, applies the comparator, returns boolean.
15
+ *
16
+ * No regex parsing, no AST tooling, no string interpolation — the grammar
17
+ * is structurally typed and the evaluator is a 30-line switch.
18
+ *
19
+ * @example Single comparison
20
+ * evaluate({ field: 'status', op: 'eq', value: 'won' }, opportunity);
21
+ *
22
+ * @example Composite — drip "if opened in last 7d AND not clicked"
23
+ * evaluate(
24
+ * {
25
+ * all: [
26
+ * { field: 'lastOpenedAt', op: 'gte', value: sevenDaysAgo },
27
+ * { field: 'lastClickedAt', op: 'isNull' },
28
+ * ],
29
+ * },
30
+ * contactEngagement,
31
+ * );
32
+ */
33
+ /**
34
+ * Comparators. Kept small — every operator here has a single, unambiguous
35
+ * semantic across types. `in` accepts an array; `contains` accepts a single
36
+ * scalar against an array field; `startsWith`/`endsWith` are string-only;
37
+ * `isNull` / `isNotNull` have no `value`.
38
+ */
39
+ type Comparator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'startsWith' | 'endsWith' | 'isNull' | 'isNotNull';
40
+ /** Atomic predicate: extract `field` from the target, compare to `value`. */
41
+ interface FieldCondition {
42
+ /** Dotted path: `'contact.email'`, `'metadata.utm.source'`, etc. */
43
+ readonly field: string;
44
+ readonly op: Comparator;
45
+ /** Comparand. Omitted for `isNull` / `isNotNull`. */
46
+ readonly value?: unknown;
47
+ }
48
+ /** All branches must be true. */
49
+ interface AllCondition {
50
+ readonly all: readonly Condition[];
51
+ }
52
+ /** Any branch true short-circuits true. */
53
+ interface AnyCondition {
54
+ readonly any: readonly Condition[];
55
+ }
56
+ /** Inverts the wrapped condition. */
57
+ interface NotCondition {
58
+ readonly not: Condition;
59
+ }
60
+ type Condition = FieldCondition | AllCondition | AnyCondition | NotCondition;
61
+ /** Evaluate a `Condition` against `target`. Total — never throws on shape. */
62
+ declare function evaluate(condition: Condition, target: unknown): boolean;
63
+ type ConditionErrorCode = 'EMPTY' | 'UNKNOWN_SHAPE' | 'INVALID_OP' | 'MISSING_VALUE' | 'EMPTY_GROUP';
64
+ declare class ConditionError extends Error {
65
+ readonly name = "ConditionError";
66
+ readonly code: ConditionErrorCode;
67
+ constructor(code: ConditionErrorCode, message: string);
68
+ }
69
+ /**
70
+ * Validate a condition shape before persisting. Recurses through composites.
71
+ * Throws `ConditionError` so hosts get a stable code + message in API
72
+ * responses (`{ code: 'INVALID_OP', message: '...' }`).
73
+ */
74
+ declare function validateCondition(condition: Condition): void;
75
+ //#endregion
76
+ export { AllCondition, AnyCondition, Comparator, Condition, ConditionError, ConditionErrorCode, FieldCondition, NotCondition, evaluate, validateCondition };
@@ -0,0 +1,131 @@
1
+ //#region src/workflow/condition.ts
2
+ function isFieldCondition(c) {
3
+ return typeof c.field === "string" && typeof c.op === "string";
4
+ }
5
+ function isAllCondition(c) {
6
+ return Array.isArray(c.all);
7
+ }
8
+ function isAnyCondition(c) {
9
+ return Array.isArray(c.any);
10
+ }
11
+ function isNotCondition(c) {
12
+ return typeof c.not === "object" && c.not !== null;
13
+ }
14
+ /**
15
+ * Walk a dotted path on `target`. Returns `undefined` when any segment
16
+ * doesn't exist. Treats `null` as a terminal (doesn't try to recurse on it).
17
+ */
18
+ function getPath(target, path) {
19
+ if (path === "" || target === null || target === void 0) return void 0;
20
+ let cursor = target;
21
+ for (const segment of path.split(".")) {
22
+ if (cursor === null || cursor === void 0) return void 0;
23
+ if (typeof cursor !== "object") return void 0;
24
+ cursor = cursor[segment];
25
+ }
26
+ return cursor;
27
+ }
28
+ /** Evaluate a `Condition` against `target`. Total — never throws on shape. */
29
+ function evaluate(condition, target) {
30
+ if (isAllCondition(condition)) return condition.all.every((c) => evaluate(c, target));
31
+ if (isAnyCondition(condition)) return condition.any.some((c) => evaluate(c, target));
32
+ if (isNotCondition(condition)) return !evaluate(condition.not, target);
33
+ if (isFieldCondition(condition)) return evaluateField(condition, target);
34
+ return false;
35
+ }
36
+ function evaluateField(c, target) {
37
+ const lhs = getPath(target, c.field);
38
+ const rhs = c.value;
39
+ switch (c.op) {
40
+ case "eq": return strictEqual(lhs, rhs);
41
+ case "neq": return !strictEqual(lhs, rhs);
42
+ case "gt": return compare(lhs, rhs) > 0;
43
+ case "gte": return compare(lhs, rhs) >= 0;
44
+ case "lt": return compare(lhs, rhs) < 0;
45
+ case "lte": return compare(lhs, rhs) <= 0;
46
+ case "in": return Array.isArray(rhs) && rhs.some((v) => strictEqual(lhs, v));
47
+ case "nin": return Array.isArray(rhs) && !rhs.some((v) => strictEqual(lhs, v));
48
+ case "contains": return Array.isArray(lhs) && lhs.some((v) => strictEqual(v, rhs));
49
+ case "startsWith": return typeof lhs === "string" && typeof rhs === "string" && lhs.startsWith(rhs);
50
+ case "endsWith": return typeof lhs === "string" && typeof rhs === "string" && lhs.endsWith(rhs);
51
+ case "isNull": return lhs === null || lhs === void 0;
52
+ case "isNotNull": return lhs !== null && lhs !== void 0;
53
+ default: return ((_) => false)(c.op);
54
+ }
55
+ }
56
+ /**
57
+ * Equality with `Date` awareness. `===` on two `Date` objects with the same
58
+ * millis returns `false`; this normalizes them. Plain primitives use `===`.
59
+ */
60
+ function strictEqual(a, b) {
61
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
62
+ if (a instanceof Date && typeof b === "string") return a.toISOString() === b;
63
+ if (typeof a === "string" && b instanceof Date) return a === b.toISOString();
64
+ return a === b;
65
+ }
66
+ /**
67
+ * Numeric / chronological comparison. Returns `NaN` (which `compare()`
68
+ * callers handle by returning false for gt/lt/gte/lte) when operands aren't
69
+ * comparable. Dates are compared by their unix timestamp.
70
+ */
71
+ function compare(a, b) {
72
+ const av = a instanceof Date ? a.getTime() : a;
73
+ const bv = b instanceof Date ? b.getTime() : b;
74
+ if (typeof av === "number" && typeof bv === "number") return av - bv;
75
+ if (typeof av === "string" && typeof bv === "string") return av < bv ? -1 : av > bv ? 1 : 0;
76
+ return NaN;
77
+ }
78
+ var ConditionError = class extends Error {
79
+ name = "ConditionError";
80
+ code;
81
+ constructor(code, message) {
82
+ super(message);
83
+ this.code = code;
84
+ }
85
+ };
86
+ const COMPARATORS = new Set([
87
+ "eq",
88
+ "neq",
89
+ "gt",
90
+ "gte",
91
+ "lt",
92
+ "lte",
93
+ "in",
94
+ "nin",
95
+ "contains",
96
+ "startsWith",
97
+ "endsWith",
98
+ "isNull",
99
+ "isNotNull"
100
+ ]);
101
+ const UNARY_OPS = new Set(["isNull", "isNotNull"]);
102
+ /**
103
+ * Validate a condition shape before persisting. Recurses through composites.
104
+ * Throws `ConditionError` so hosts get a stable code + message in API
105
+ * responses (`{ code: 'INVALID_OP', message: '...' }`).
106
+ */
107
+ function validateCondition(condition) {
108
+ if (condition === null || typeof condition !== "object") throw new ConditionError("EMPTY", "condition is null or not an object");
109
+ if (isAllCondition(condition)) {
110
+ if (condition.all.length === 0) throw new ConditionError("EMPTY_GROUP", "all-group must have at least one branch");
111
+ condition.all.forEach(validateCondition);
112
+ return;
113
+ }
114
+ if (isAnyCondition(condition)) {
115
+ if (condition.any.length === 0) throw new ConditionError("EMPTY_GROUP", "any-group must have at least one branch");
116
+ condition.any.forEach(validateCondition);
117
+ return;
118
+ }
119
+ if (isNotCondition(condition)) {
120
+ validateCondition(condition.not);
121
+ return;
122
+ }
123
+ if (isFieldCondition(condition)) {
124
+ if (!COMPARATORS.has(condition.op)) throw new ConditionError("INVALID_OP", `unknown comparator: ${JSON.stringify(condition.op)}`);
125
+ if (!UNARY_OPS.has(condition.op) && condition.value === void 0) throw new ConditionError("MISSING_VALUE", `comparator '${condition.op}' requires a value`);
126
+ return;
127
+ }
128
+ throw new ConditionError("UNKNOWN_SHAPE", `condition must be a field/all/any/not — got: ${JSON.stringify(condition)}`);
129
+ }
130
+ //#endregion
131
+ export { ConditionError, evaluate, validateCondition };
@@ -1,6 +1,6 @@
1
1
  import { IdLike } from "./reference.mjs";
2
2
 
3
- //#region src/context.d.ts
3
+ //#region src/composition/context.d.ts
4
4
  /**
5
5
  * Operation context threaded through package APIs.
6
6
  *
@@ -1,6 +1,6 @@
1
- import { t as Brand } from "./brand-DApaegoR.mjs";
1
+ import { t as Brand } from "./brand-LLXqoh5J.mjs";
2
2
 
3
- //#region src/currency.d.ts
3
+ //#region src/money/currency.d.ts
4
4
  /**
5
5
  * ISO 4217 currency code (3 uppercase letters).
6
6
  *
@@ -1,2 +1,2 @@
1
- import { a as convertWithSnapshot, c as minorUnitFactor, i as MINOR_UNIT_FACTOR, l as reverseWithSnapshot, n as CurrencyCode, o as isCurrencyCode, r as FxSnapshot, s as isFxSnapshot, t as CURRENCIES, u as toCurrencyCode } from "./currency-rgUAn5KM.mjs";
1
+ import { a as convertWithSnapshot, c as minorUnitFactor, i as MINOR_UNIT_FACTOR, l as reverseWithSnapshot, n as CurrencyCode, o as isCurrencyCode, r as FxSnapshot, s as isFxSnapshot, t as CURRENCIES, u as toCurrencyCode } from "./currency-B4rSdN7D.mjs";
2
2
  export { CURRENCIES, CurrencyCode, FxSnapshot, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
package/dist/currency.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/currency.ts
1
+ //#region src/money/currency.ts
2
2
  /**
3
3
  * Common ISO 4217 codes. Non-exhaustive — add more at the consumer level if
4
4
  * needed. Listed here because shared-types ships with these by default.
package/dist/events.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/events.d.ts
1
+ //#region src/events/events.d.ts
2
2
  /**
3
3
  * Domain event transport primitives.
4
4
  *
package/dist/events.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/events.ts
1
+ //#region src/events/events.ts
2
2
  /**
3
3
  * Build a {@link DomainEvent} with auto-filled `id` + `timestamp`. All other
4
4
  * `EventMeta` fields are caller-controlled and override the defaults if
package/dist/hold.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/hold.d.ts
1
+ //#region src/workflow/hold.d.ts
2
2
  /**
3
3
  * Hold reason value object + collection helpers.
4
4
  *
package/dist/hold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/hold.ts
1
+ //#region src/workflow/hold.ts
2
2
  var HoldError = class extends Error {
3
3
  name = "HoldError";
4
4
  code;
@@ -0,0 +1,73 @@
1
+ //#region src/composition/mixin.d.ts
2
+ /**
3
+ * Additive composition for domain entities — without subclassing.
4
+ *
5
+ * Most mixin system (good idea, heavy implementation) lets a `Contact`
6
+ * become a `Contact & Customer` without rewriting the type or copying
7
+ * fields. We replicate the GOOD bit (additive type composition stored as
8
+ * nested data) with none of the bad bits (no decorators, no global class
9
+ * registry, no proxies).
10
+ *
11
+ * Pattern: store mixin payloads on a known well-typed key (`mixins.customer`,
12
+ * `mixins.lead-fit`, …) instead of spreading them onto the entity root. This
13
+ * keeps the base type stable and lets each mixin own its own keyspace —
14
+ * additive schema evolution without breaking exhaustiveness.
15
+ *
16
+ * @example
17
+ * import { withMixin, getMixin, hasMixin } from '@classytic/primitives/mixin';
18
+ *
19
+ * interface Contact { id: string; name: string; mixins?: Record<string, unknown>; }
20
+ * interface CustomerFacts { lifetimeValue: number; firstOrderAt: Date; }
21
+ *
22
+ * const c: Contact = { id: 'c1', name: 'Acme' };
23
+ * const enriched = withMixin<Contact, 'customer', CustomerFacts>(c, 'customer', {
24
+ * lifetimeValue: 9200,
25
+ * firstOrderAt: new Date('2024-09-12'),
26
+ * });
27
+ *
28
+ * getMixin<CustomerFacts>(enriched, 'customer')?.lifetimeValue; // 9200
29
+ * hasMixin(enriched, 'customer'); // true
30
+ *
31
+ * // Domain stays exhaustive — the base type didn't change, only the
32
+ * // optional `mixins` namespace did.
33
+ */
34
+ /**
35
+ * Marker interface — entities that participate in the mixin protocol declare
36
+ * `mixins?: Mixinable['mixins']`. Hosts can opt in retroactively by widening
37
+ * their existing types via intersection.
38
+ */
39
+ interface Mixinable {
40
+ readonly mixins?: Readonly<Record<string, unknown>>;
41
+ }
42
+ /**
43
+ * Result type: the base `T` with `mixins[K]` typed as `M`. The intersection
44
+ * keeps callers exhaustive over the original entity while exposing the
45
+ * mixin payload through a well-typed accessor.
46
+ */
47
+ type WithMixin<T, K extends string, M> = T & {
48
+ readonly mixins: Readonly<Record<string, unknown>> & { readonly [P in K]: M };
49
+ };
50
+ /**
51
+ * Attach (or overwrite) a mixin on a base entity. Returns a NEW object —
52
+ * never mutates. The other mixins on the entity are preserved by spread.
53
+ *
54
+ * Type-level: caller picks the mixin key and the mixin payload shape; we
55
+ * intersect them into the return type so chained reads stay precise.
56
+ */
57
+ declare function withMixin<T extends Mixinable, K extends string, M>(base: T, key: K, data: M): WithMixin<T, K, M>;
58
+ /**
59
+ * Pull a mixin payload off an entity by key. Returns `null` (not `undefined`)
60
+ * so call sites can use `??` to fall through to a default without ambiguity
61
+ * with a real `undefined` field on the payload itself.
62
+ */
63
+ declare function getMixin<M>(entity: Mixinable, key: string): M | null;
64
+ /** Cheap presence check — useful for `if (hasMixin(c, 'customer')) …` branches. */
65
+ declare function hasMixin(entity: Mixinable, key: string): boolean;
66
+ /**
67
+ * Remove a mixin. Returns a new entity. No-op if the mixin isn't present.
68
+ * Useful for migrations / GDPR scrubs ("remove `customer` payload but keep
69
+ * the base `Contact` row").
70
+ */
71
+ declare function withoutMixin<T extends Mixinable>(entity: T, key: string): T;
72
+ //#endregion
73
+ export { Mixinable, WithMixin, getMixin, hasMixin, withMixin, withoutMixin };
package/dist/mixin.mjs ADDED
@@ -0,0 +1,47 @@
1
+ //#region src/composition/mixin.ts
2
+ /**
3
+ * Attach (or overwrite) a mixin on a base entity. Returns a NEW object —
4
+ * never mutates. The other mixins on the entity are preserved by spread.
5
+ *
6
+ * Type-level: caller picks the mixin key and the mixin payload shape; we
7
+ * intersect them into the return type so chained reads stay precise.
8
+ */
9
+ function withMixin(base, key, data) {
10
+ return {
11
+ ...base,
12
+ mixins: {
13
+ ...base.mixins ?? {},
14
+ [key]: data
15
+ }
16
+ };
17
+ }
18
+ /**
19
+ * Pull a mixin payload off an entity by key. Returns `null` (not `undefined`)
20
+ * so call sites can use `??` to fall through to a default without ambiguity
21
+ * with a real `undefined` field on the payload itself.
22
+ */
23
+ function getMixin(entity, key) {
24
+ if (entity.mixins === void 0 || entity.mixins === null) return null;
25
+ const found = entity.mixins[key];
26
+ return found === void 0 ? null : found;
27
+ }
28
+ /** Cheap presence check — useful for `if (hasMixin(c, 'customer')) …` branches. */
29
+ function hasMixin(entity, key) {
30
+ return entity.mixins !== void 0 && entity.mixins !== null && entity.mixins[key] !== void 0;
31
+ }
32
+ /**
33
+ * Remove a mixin. Returns a new entity. No-op if the mixin isn't present.
34
+ * Useful for migrations / GDPR scrubs ("remove `customer` payload but keep
35
+ * the base `Contact` row").
36
+ */
37
+ function withoutMixin(entity, key) {
38
+ if (!hasMixin(entity, key)) return entity;
39
+ const next = { ...entity.mixins };
40
+ delete next[key];
41
+ return {
42
+ ...entity,
43
+ mixins: next
44
+ };
45
+ }
46
+ //#endregion
47
+ export { getMixin, hasMixin, withMixin, withoutMixin };
package/dist/money.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { n as CurrencyCode } from "./currency-rgUAn5KM.mjs";
1
+ import { n as CurrencyCode } from "./currency-B4rSdN7D.mjs";
2
2
 
3
- //#region src/money.d.ts
3
+ //#region src/money/money.d.ts
4
4
  /**
5
5
  * Wire-format monetary amount: integer minor units, branded with a phantom
6
6
  * currency type so calls like `formatBdt(usdValue)` fail at compile time.
package/dist/money.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { minorUnitFactor } from "./currency.mjs";
2
- //#region src/money.ts
2
+ //#region src/money/money.ts
3
3
  /**
4
4
  * Cast a raw number to {@link MinorUnits}. Pass the currency as the second
5
5
  * argument to fix the phantom type. With no second arg, `MinorUnits<string>`
package/dist/outbox.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
2
2
 
3
- //#region src/outbox.d.ts
3
+ //#region src/events/outbox.d.ts
4
4
  /**
5
5
  * Options passed to {@link OutboxStore.save} for richer write semantics.
6
6
  * Stores may ignore fields they don't support — contract is best-effort.
package/dist/outbox.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/outbox.ts
1
+ //#region src/events/outbox.ts
2
2
  /**
3
3
  * Thrown by a store when `acknowledge` / `fail` is called by a consumer that
4
4
  * does not own the event's current lease.
@@ -0,0 +1,47 @@
1
+ //#region src/money/payment-allocation-status.d.ts
2
+ /**
3
+ * Allocation + reconciliation status enums for payment legs.
4
+ *
5
+ * Two orthogonal dimensions sit on every PaymentAllocation row (or
6
+ * equivalent in any package that settles money against an invoice /
7
+ * order / claim):
8
+ *
9
+ * 1. **Allocation status** — has this payment been *applied* against
10
+ * the obligation(s) it's meant to settle? (`unallocated`,
11
+ * `partial`, `allocated`, `over_allocated`)
12
+ * 2. **Reconciliation status** — has this allocation been *matched*
13
+ * to a bank statement line / processor settlement record?
14
+ * (`unreconciled`, `matched`, `manual`, `disputed`)
15
+ *
16
+ * Both are needed because a payment can be allocated (the AR ledger
17
+ * thinks it's done) without being reconciled (the bank statement
18
+ * hasn't been imported / matched yet), and vice versa.
19
+ *
20
+ * Zero runtime, zero deps.
21
+ */
22
+ declare const PAYMENT_ALLOCATION_STATUS: {
23
+ /** No portion of the payment has been applied to any obligation yet. */readonly UNALLOCATED: "unallocated"; /** Some of the payment is applied; remainder is available credit. */
24
+ readonly PARTIAL: "partial"; /** Fully applied — `sum(allocations) === payment.totalAmount`. */
25
+ readonly ALLOCATED: "allocated";
26
+ /**
27
+ * Sum of allocations exceeds the payment total — invariant violation
28
+ * that should be flagged for operator review, never silently accepted.
29
+ */
30
+ readonly OVER_ALLOCATED: "over_allocated";
31
+ };
32
+ type PaymentAllocationStatus = (typeof PAYMENT_ALLOCATION_STATUS)[keyof typeof PAYMENT_ALLOCATION_STATUS];
33
+ declare const RECONCILIATION_STATUS: {
34
+ /** No bank statement line / settlement record has been linked yet. */readonly UNRECONCILED: "unreconciled"; /** Auto-matched to a `BankTransaction` by amount + date + reference. */
35
+ readonly MATCHED: "matched"; /** Operator-confirmed match (auto-match failed or was overridden). */
36
+ readonly MANUAL: "manual";
37
+ /**
38
+ * Match was contested — amount mismatch, duplicate claim, chargeback
39
+ * in flight. Halts further automated processing until resolved.
40
+ */
41
+ readonly DISPUTED: "disputed";
42
+ };
43
+ type ReconciliationStatus = (typeof RECONCILIATION_STATUS)[keyof typeof RECONCILIATION_STATUS];
44
+ declare const isPaymentAllocationStatus: (value: unknown) => value is PaymentAllocationStatus;
45
+ declare const isReconciliationStatus: (value: unknown) => value is ReconciliationStatus;
46
+ //#endregion
47
+ export { PAYMENT_ALLOCATION_STATUS, PaymentAllocationStatus, RECONCILIATION_STATUS, ReconciliationStatus, isPaymentAllocationStatus, isReconciliationStatus };