@classytic/primitives 0.16.0 → 0.19.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 +66 -0
- package/dist/event-infra.d.mts +10 -0
- package/dist/event-infra.mjs +10 -0
- package/dist/monetization.d.mts +195 -0
- package/dist/monetization.mjs +79 -0
- package/dist/outbox.d.mts +25 -0
- package/dist/proration.d.mts +76 -0
- package/dist/proration.mjs +120 -0
- package/dist/shelf-life.mjs +36 -3
- package/dist/state-diagram.d.mts +68 -0
- package/dist/state-diagram.mjs +66 -0
- package/dist/subject.d.mts +65 -0
- package/dist/subject.mjs +40 -0
- package/dist/suspension.d.mts +151 -0
- package/dist/suspension.mjs +194 -0
- package/package.json +232 -212
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,72 @@
|
|
|
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.19.0] - 2026-08-01
|
|
7
|
+
|
|
8
|
+
### Added — `./monetization`: canonical pricing classification contract
|
|
9
|
+
|
|
10
|
+
- **`@classytic/primitives/monetization`** — the single home for how a product
|
|
11
|
+
is sold and priced, so the classification survives end-to-end across catalog,
|
|
12
|
+
revenue, order, and entitlement kernels without each maintaining its own enum.
|
|
13
|
+
- **`Monetization`** discriminated union — `FreeMonetization | OneTimeMonetization |
|
|
14
|
+
SubscriptionMonetization | BundleMonetization | UsageMonetization`, each
|
|
15
|
+
carrying its own nested pricing shape.
|
|
16
|
+
- **`MONETIZATION_KINDS`** + **`MonetizationKind`** + **`isMonetizationKind`** —
|
|
17
|
+
exhaustive kind list with runtime guard.
|
|
18
|
+
- Type-guard predicates: **`isFreeMonetization`**, **`isOneTimeMonetization`**,
|
|
19
|
+
**`isSubscriptionMonetization`**, **`isBundleMonetization`**,
|
|
20
|
+
**`isUsageMonetization`**.
|
|
21
|
+
- Supporting types: `PriceTier`, `OneTimePricing`, `SubscriptionPlan`,
|
|
22
|
+
`UsageTier`, `UsageRating`, `DurationUnit`, `MoneyByCurrency`.
|
|
23
|
+
|
|
24
|
+
### Added — `./proration`: pure proration arithmetic
|
|
25
|
+
|
|
26
|
+
- **`@classytic/primitives/proration`** — timezone-agnostic, policy-free
|
|
27
|
+
proration math. Holds no commercial policy (no upgrade/downgrade semantics,
|
|
28
|
+
no discount or tax logic) — those live in the contract kernel.
|
|
29
|
+
- **`periodProgress(input)`** — fraction of a billing period consumed given
|
|
30
|
+
start, end, and current instants as `Date`. Returns `PeriodFraction`.
|
|
31
|
+
- **`splitByPeriodFraction(amount, fraction, granularity?)`** — split a `Money`
|
|
32
|
+
amount into consumed/remaining parts; `granularity: 'whole_day'` (default) or
|
|
33
|
+
`'exact'`.
|
|
34
|
+
- **`allocateMoneyByFraction(amount, fraction)`** — proportional allocation
|
|
35
|
+
using the largest-remainder allocator so `consumed + remaining === amount`
|
|
36
|
+
exactly (no penny drift).
|
|
37
|
+
- **`ProrationError`** + **`ProrationErrorCode`** (`INVALID_PERIOD` |
|
|
38
|
+
`INVALID_FRACTION`) — typed errors for out-of-range inputs.
|
|
39
|
+
- Supporting types: `PeriodFraction`, `PeriodProgressInput`,
|
|
40
|
+
`ProrationGranularity`.
|
|
41
|
+
|
|
42
|
+
## [0.18.0] - 2026-07-29
|
|
43
|
+
|
|
44
|
+
### Added — `./subject`: `SubjectRef` polymorphic identity pointer
|
|
45
|
+
|
|
46
|
+
- **`@classytic/primitives/subject`** — `SubjectRef` (`{ subjectModel, subjectRef }`) — WHO a record is about, polymorphically. The pairing spans every kernel that holds data about a person without owning them (attendance, access, loyalty, credentials). Deliberately kept distinct from `ExternalRef` (provenance — "came from") even though the shapes are identical: a grant carries both at once and they mean different things.
|
|
47
|
+
- **`buildSubjectRef(model, id)`** — returns `SubjectRef | null`; `null` when `id` is absent/blank, because a malformed binding silently matches nothing (an entitlement nobody holds, a credential no revoke can find) rather than carrying `"undefined"` as a string.
|
|
48
|
+
- **`SubjectRef`** type exported from `@classytic/primitives/subject`.
|
|
49
|
+
|
|
50
|
+
### Added — `./state-diagram`: Mermaid diagram from a `StateMachine`
|
|
51
|
+
|
|
52
|
+
- **`@classytic/primitives/state-diagram`** — `renderStateDiagram(machine, opts?)` — generates a `stateDiagram-v2` Mermaid string from the same `StateMachine` object the runtime CAS enforces. Removes the class of drift where a hand-drawn prose comment diverges from the actual transition table. Pure: string in, string out, no I/O.
|
|
53
|
+
- **`StateDiagramOptions`** — `title?`, `highlight?` (mark specific states), `noteOn?` (per-state annotations).
|
|
54
|
+
|
|
55
|
+
### Added — `./suspension`: time-bounded suspension/pause policy
|
|
56
|
+
|
|
57
|
+
- **`@classytic/primitives/suspension`** — pure suspension policy computation (no persistence). Models a pause the subject is ENTITLED to take, bounded by an annual allowance, that ends by itself — distinct from `/hold` (an indefinite blocker needing manual resolution).
|
|
58
|
+
- **`evaluateSuspension(policy, history, opts)`** — always returns `autoResumeAt` (the instant the pause MUST end) so a sweep can resume it whether or not the subject ever comes back. Covers: gym membership freeze, subscription pause, unpaid leave.
|
|
59
|
+
- **`SuspensionPolicy`** — `maxDaysPerPeriod`, `minDurationDays`, `period` ('annual' | 'rolling').
|
|
60
|
+
- **`SuspensionEntry`** — a historical pause record (`startedAt`, `endedAt?`, `reason?`).
|
|
61
|
+
- **`SuspensionResult`** — `autoResumeAt`, `daysUsed`, `daysRemaining`, `status`.
|
|
62
|
+
|
|
63
|
+
### Added — `OutboxStore.transactionalSave` (`/outbox`)
|
|
64
|
+
|
|
65
|
+
- **`OutboxStore.transactionalSave?: boolean`** — declares whether `save` enlists `options.session` in the caller's transaction. Absent/`false` = "assume NOT atomic". Set `true` ONLY when `save` passes the session to the same transactional resource the caller writes (e.g. a mongoose model on the same connection). Callers that need atomicity (`@classytic/access`'s `atomically()`) check this flag and fail at boot rather than claiming a guarantee they cannot keep.
|
|
66
|
+
- **`MemoryOutboxStore.transactionalSave = false`** — explicitly declared; an in-memory store cannot participate in a Mongo transaction, so its write survives a rollback.
|
|
67
|
+
|
|
68
|
+
### Changed — `shelf-life.ts` deduplicates `addDays`
|
|
69
|
+
|
|
70
|
+
- `addDays` is now imported from `./calendar` instead of being re-defined inline. No behavior change.
|
|
71
|
+
|
|
6
72
|
## [0.16.0] - 2026-07-27
|
|
7
73
|
|
|
8
74
|
### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
|
package/dist/event-infra.d.mts
CHANGED
|
@@ -98,6 +98,16 @@ declare function createInProcessBus(options?: InProcessEventBusOptions): InProce
|
|
|
98
98
|
* are test-inspection helpers (not part of the contract).
|
|
99
99
|
*/
|
|
100
100
|
declare class MemoryOutboxStore implements OutboxStore {
|
|
101
|
+
/**
|
|
102
|
+
* Deliberately NOT transactional — and saying so is the point.
|
|
103
|
+
*
|
|
104
|
+
* This store pushes onto an in-process array, which no database transaction
|
|
105
|
+
* can roll back. If a caller's Mongo write aborts after `save`, the event
|
|
106
|
+
* SURVIVES here and describes something that never happened. Declaring
|
|
107
|
+
* `false` lets a caller that needs real atomicity (`@classytic/access`) refuse
|
|
108
|
+
* at boot instead of silently inheriting a ghost-event path.
|
|
109
|
+
*/
|
|
110
|
+
readonly transactionalSave = false;
|
|
101
111
|
private events;
|
|
102
112
|
save(event: DomainEvent, _options?: OutboxWriteOptions): Promise<void>;
|
|
103
113
|
getPending(limit: number): Promise<DomainEvent[]>;
|
package/dist/event-infra.mjs
CHANGED
|
@@ -89,6 +89,16 @@ function createInProcessBus(options) {
|
|
|
89
89
|
* are test-inspection helpers (not part of the contract).
|
|
90
90
|
*/
|
|
91
91
|
var MemoryOutboxStore = class {
|
|
92
|
+
/**
|
|
93
|
+
* Deliberately NOT transactional — and saying so is the point.
|
|
94
|
+
*
|
|
95
|
+
* This store pushes onto an in-process array, which no database transaction
|
|
96
|
+
* can roll back. If a caller's Mongo write aborts after `save`, the event
|
|
97
|
+
* SURVIVES here and describes something that never happened. Declaring
|
|
98
|
+
* `false` lets a caller that needs real atomicity (`@classytic/access`) refuse
|
|
99
|
+
* at boot instead of silently inheriting a ghost-event path.
|
|
100
|
+
*/
|
|
101
|
+
transactionalSave = false;
|
|
92
102
|
events = [];
|
|
93
103
|
async save(event, _options) {
|
|
94
104
|
this.events.push({ event });
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Money } from "./money.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/money/monetization.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A map of currency code → {@link Money} for explicit per-currency pricing.
|
|
6
|
+
* Explicit entries always win over a currency-conversion fallback.
|
|
7
|
+
*
|
|
8
|
+
* Canonical home (was hand-declared in `@classytic/catalog`; re-exported there
|
|
9
|
+
* for back-compat). Belongs with money because it is a money-shaped value.
|
|
10
|
+
*/
|
|
11
|
+
type MoneyByCurrency = Record<string, Money>;
|
|
12
|
+
/**
|
|
13
|
+
* The canonical monetization kinds. Order is stable; append only.
|
|
14
|
+
*
|
|
15
|
+
* - `free` — no charge.
|
|
16
|
+
* - `one_time` — charged once (catalog `one_time`; revenue `purchase`).
|
|
17
|
+
* - `subscription` — recurring plan / membership.
|
|
18
|
+
* - `bundle` — a composite priced from its members.
|
|
19
|
+
* - `usage` — metered / per-seat / consumption (billed per unit or in
|
|
20
|
+
* arrears). The extension point for rating models.
|
|
21
|
+
*/
|
|
22
|
+
declare const MONETIZATION_KINDS: readonly ["free", "one_time", "subscription", "bundle", "usage"];
|
|
23
|
+
type MonetizationKind = (typeof MONETIZATION_KINDS)[number];
|
|
24
|
+
/** True when `value` is one of the canonical kinds. */
|
|
25
|
+
declare function isMonetizationKind(value: unknown): value is MonetizationKind;
|
|
26
|
+
/** A quantity-based price break. `price` (fixed override) XOR `discountPercent`. */
|
|
27
|
+
interface PriceTier {
|
|
28
|
+
/** Minimum quantity to qualify. */
|
|
29
|
+
readonly minQuantity: number;
|
|
30
|
+
/** Fixed override price for this tier. */
|
|
31
|
+
readonly price?: Money;
|
|
32
|
+
/** OR a percentage discount (0–100). Mutually exclusive with `price`. */
|
|
33
|
+
readonly discountPercent?: number;
|
|
34
|
+
}
|
|
35
|
+
interface OneTimePricing {
|
|
36
|
+
/** Required base (list) price. */
|
|
37
|
+
readonly basePrice: Money;
|
|
38
|
+
/** Default currency (redundant with `basePrice.currency`, indexed for queries). */
|
|
39
|
+
readonly currency: string;
|
|
40
|
+
/** Optional strikethrough / MSRP price. */
|
|
41
|
+
readonly compareAtPrice?: Money;
|
|
42
|
+
/** Explicit per-currency price overrides. */
|
|
43
|
+
readonly priceByCurrency?: MoneyByCurrency;
|
|
44
|
+
/** Optional cost (role-gated — filtered for non-finance readers). */
|
|
45
|
+
readonly costPrice?: Money;
|
|
46
|
+
/** Optional volume tiers (quantity-based discounts). */
|
|
47
|
+
readonly tiers?: readonly PriceTier[];
|
|
48
|
+
}
|
|
49
|
+
type DurationUnit = 'day' | 'week' | 'month' | 'year';
|
|
50
|
+
interface SubscriptionPlan {
|
|
51
|
+
/** Internal key ('monthly', 'quarterly', 'annual'). */
|
|
52
|
+
readonly key: string;
|
|
53
|
+
/** Display label ("Monthly", "Annual (save 20%)"). */
|
|
54
|
+
readonly label: string;
|
|
55
|
+
/** Base price for this plan in the default currency. */
|
|
56
|
+
readonly price: Money;
|
|
57
|
+
/** Explicit per-currency overrides. */
|
|
58
|
+
readonly priceByCurrency?: MoneyByCurrency;
|
|
59
|
+
/** Billing cycle length. */
|
|
60
|
+
readonly duration: number;
|
|
61
|
+
/** Unit of the billing cycle. */
|
|
62
|
+
readonly durationUnit: DurationUnit;
|
|
63
|
+
/** Optional free trial length in days. */
|
|
64
|
+
readonly trialDays?: number;
|
|
65
|
+
/** Optional auto-calculated discount vs the highest-frequency plan. */
|
|
66
|
+
readonly discount?: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One band of a metered rate table.
|
|
70
|
+
*
|
|
71
|
+
* `upTo` is the inclusive upper bound of the band; omit it for the final,
|
|
72
|
+
* open-ended band. Within a band either a `perUnit` rate applies (per metered
|
|
73
|
+
* unit) or a `flatPrice` (a stairstep charge for landing in the band).
|
|
74
|
+
*/
|
|
75
|
+
interface UsageTier {
|
|
76
|
+
readonly upTo?: number;
|
|
77
|
+
readonly perUnit?: Money;
|
|
78
|
+
readonly flatPrice?: Money;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* How a metered unit is priced. One `scheme`, composed with the fields it needs
|
|
82
|
+
* — this single shape covers per-seat, metered, graduated, volume, package and
|
|
83
|
+
* base-plus-overage without a separate kind for each.
|
|
84
|
+
*
|
|
85
|
+
* - `per_unit` — price × quantity (per-seat licensing, flat metering).
|
|
86
|
+
* - `tiered` — graduated: each unit priced at the rate of the band it falls in.
|
|
87
|
+
* - `volume` — the WHOLE quantity priced at the band the total lands in.
|
|
88
|
+
* - `package` — priced per package of `packageSize` units.
|
|
89
|
+
*
|
|
90
|
+
* `includedUnits` are free before metering starts (base-plus-overage when paired
|
|
91
|
+
* with {@link UsageMonetization.baseFee}).
|
|
92
|
+
*/
|
|
93
|
+
interface UsageRating {
|
|
94
|
+
readonly scheme: 'per_unit' | 'tiered' | 'volume' | 'package';
|
|
95
|
+
/** Flat per-unit rate (`per_unit` scheme). */
|
|
96
|
+
readonly perUnit?: Money;
|
|
97
|
+
/** Rate table (`tiered` / `volume` schemes). */
|
|
98
|
+
readonly tiers?: readonly UsageTier[];
|
|
99
|
+
/** Units per package and the package price (`package` scheme). */
|
|
100
|
+
readonly packageSize?: number;
|
|
101
|
+
readonly packagePrice?: Money;
|
|
102
|
+
/** Free units before metering starts (base-plus-overage). */
|
|
103
|
+
readonly includedUnits?: number;
|
|
104
|
+
/** Display label for one unit ("seat", "GB", "call", "visit"). */
|
|
105
|
+
readonly unitLabel?: string;
|
|
106
|
+
}
|
|
107
|
+
interface FreeMonetization {
|
|
108
|
+
readonly type: 'free';
|
|
109
|
+
}
|
|
110
|
+
interface OneTimeMonetization {
|
|
111
|
+
readonly type: 'one_time';
|
|
112
|
+
readonly pricing: OneTimePricing;
|
|
113
|
+
}
|
|
114
|
+
interface SubscriptionMonetization {
|
|
115
|
+
readonly type: 'subscription';
|
|
116
|
+
/** At least one plan is required. */
|
|
117
|
+
readonly plans: readonly SubscriptionPlan[];
|
|
118
|
+
}
|
|
119
|
+
interface BundleMonetization {
|
|
120
|
+
readonly type: 'bundle';
|
|
121
|
+
/** Optional fixed bundle price (overrides the dynamic sum). */
|
|
122
|
+
readonly basePrice?: Money;
|
|
123
|
+
/** How bundle pricing is computed. */
|
|
124
|
+
readonly pricingMode: 'fixed' | 'dynamic';
|
|
125
|
+
/** For `dynamic` mode, an optional 0–100 discount on the members' sum. */
|
|
126
|
+
readonly dynamicDiscountPercent?: number;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Metered / consumption pricing. The extension point for per-seat, metered,
|
|
130
|
+
* graduated, volume, package and base-plus-overage — all via {@link UsageRating}
|
|
131
|
+
* so the classification does not grow one kind per rating model.
|
|
132
|
+
*/
|
|
133
|
+
interface UsageMonetization {
|
|
134
|
+
readonly type: 'usage';
|
|
135
|
+
/** Optional recurring base fee charged regardless of usage (base + overage). */
|
|
136
|
+
readonly baseFee?: Money;
|
|
137
|
+
/** How each metered unit is priced. */
|
|
138
|
+
readonly rating: UsageRating;
|
|
139
|
+
/** Settlement cadence when usage is billed in arrears. */
|
|
140
|
+
readonly billingPeriod?: {
|
|
141
|
+
readonly duration: number;
|
|
142
|
+
readonly durationUnit: DurationUnit;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The canonical monetization value object. The `type` discriminant selects the
|
|
147
|
+
* pricing detail. Matches `@classytic/catalog`'s existing discriminants (so no
|
|
148
|
+
* data migration) plus `usage`.
|
|
149
|
+
*/
|
|
150
|
+
type Monetization = FreeMonetization | OneTimeMonetization | SubscriptionMonetization | BundleMonetization | UsageMonetization;
|
|
151
|
+
declare const isFreeMonetization: (m: Monetization) => m is FreeMonetization;
|
|
152
|
+
declare const isOneTimeMonetization: (m: Monetization) => m is OneTimeMonetization;
|
|
153
|
+
declare const isSubscriptionMonetization: (m: Monetization) => m is SubscriptionMonetization;
|
|
154
|
+
declare const isBundleMonetization: (m: Monetization) => m is BundleMonetization;
|
|
155
|
+
declare const isUsageMonetization: (m: Monetization) => m is UsageMonetization;
|
|
156
|
+
/** The kind of a monetization value (its discriminant, narrowed to the union). */
|
|
157
|
+
declare const monetizationKindOf: (m: Monetization) => MonetizationKind;
|
|
158
|
+
/**
|
|
159
|
+
* The single headline unit price for a monetization, or `null` when there isn't
|
|
160
|
+
* one. This is the ONE place that decides "which number is the price" per kind,
|
|
161
|
+
* so a cart line, an order snapshot and a storefront card never re-`switch` on
|
|
162
|
+
* `type` and drift (a host that read `plans[0].price` while another read
|
|
163
|
+
* `plans[1]` is exactly the divergence this prevents).
|
|
164
|
+
*
|
|
165
|
+
* - `one_time` → the base (list) price
|
|
166
|
+
* - `subscription` → the first plan's price (the default / most-frequent plan)
|
|
167
|
+
* - `bundle` → the FIXED bundle price; `null` in `dynamic` mode (that
|
|
168
|
+
* price is computed from the members, not stored here)
|
|
169
|
+
* - `free` → `null`
|
|
170
|
+
* - `usage` → `null` — a metered item has no single unit price; it is
|
|
171
|
+
* rated per consumption via {@link UsageRating}
|
|
172
|
+
*
|
|
173
|
+
* Exhaustive on purpose: a new kind fails to compile here until it declares how
|
|
174
|
+
* it prices, instead of silently returning `null`.
|
|
175
|
+
*/
|
|
176
|
+
declare function unitPriceOf(m: Monetization): Money | null;
|
|
177
|
+
/** The strikethrough / MSRP price, if the kind carries one (only `one_time`). */
|
|
178
|
+
declare function compareAtPriceOf(m: Monetization): Money | null;
|
|
179
|
+
/**
|
|
180
|
+
* `@classytic/revenue`'s coarse wire enum. `purchase` is the paid-once case that
|
|
181
|
+
* the rest of the ecosystem calls `one_time`. Kept as its own type so revenue's
|
|
182
|
+
* PERSISTED values never have to change — only the mapping to the canonical kind
|
|
183
|
+
* lives here, in one place, instead of an implicit hand-conversion per call site.
|
|
184
|
+
*/
|
|
185
|
+
type RevenueMonetizationType = 'free' | 'purchase' | 'subscription';
|
|
186
|
+
/**
|
|
187
|
+
* Canonical kind → revenue wire type. Everything that is not `free` or
|
|
188
|
+
* `subscription` settles as a `purchase` in revenue's ledger vocabulary
|
|
189
|
+
* (one_time, bundle, and a usage charge are all "a purchase happened").
|
|
190
|
+
*/
|
|
191
|
+
declare function toRevenueMonetizationType(kind: MonetizationKind): RevenueMonetizationType;
|
|
192
|
+
/** Revenue wire type → canonical kind (`purchase` → `one_time`). */
|
|
193
|
+
declare function fromRevenueMonetizationType(type: RevenueMonetizationType): MonetizationKind;
|
|
194
|
+
//#endregion
|
|
195
|
+
export { BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UsageMonetization, UsageRating, UsageTier, compareAtPriceOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isSubscriptionMonetization, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/money/monetization.ts
|
|
2
|
+
/**
|
|
3
|
+
* The canonical monetization kinds. Order is stable; append only.
|
|
4
|
+
*
|
|
5
|
+
* - `free` — no charge.
|
|
6
|
+
* - `one_time` — charged once (catalog `one_time`; revenue `purchase`).
|
|
7
|
+
* - `subscription` — recurring plan / membership.
|
|
8
|
+
* - `bundle` — a composite priced from its members.
|
|
9
|
+
* - `usage` — metered / per-seat / consumption (billed per unit or in
|
|
10
|
+
* arrears). The extension point for rating models.
|
|
11
|
+
*/
|
|
12
|
+
const MONETIZATION_KINDS = [
|
|
13
|
+
"free",
|
|
14
|
+
"one_time",
|
|
15
|
+
"subscription",
|
|
16
|
+
"bundle",
|
|
17
|
+
"usage"
|
|
18
|
+
];
|
|
19
|
+
const MONETIZATION_KIND_SET = new Set(MONETIZATION_KINDS);
|
|
20
|
+
/** True when `value` is one of the canonical kinds. */
|
|
21
|
+
function isMonetizationKind(value) {
|
|
22
|
+
return typeof value === "string" && MONETIZATION_KIND_SET.has(value);
|
|
23
|
+
}
|
|
24
|
+
const isFreeMonetization = (m) => m.type === "free";
|
|
25
|
+
const isOneTimeMonetization = (m) => m.type === "one_time";
|
|
26
|
+
const isSubscriptionMonetization = (m) => m.type === "subscription";
|
|
27
|
+
const isBundleMonetization = (m) => m.type === "bundle";
|
|
28
|
+
const isUsageMonetization = (m) => m.type === "usage";
|
|
29
|
+
/** The kind of a monetization value (its discriminant, narrowed to the union). */
|
|
30
|
+
const monetizationKindOf = (m) => m.type;
|
|
31
|
+
/**
|
|
32
|
+
* The single headline unit price for a monetization, or `null` when there isn't
|
|
33
|
+
* one. This is the ONE place that decides "which number is the price" per kind,
|
|
34
|
+
* so a cart line, an order snapshot and a storefront card never re-`switch` on
|
|
35
|
+
* `type` and drift (a host that read `plans[0].price` while another read
|
|
36
|
+
* `plans[1]` is exactly the divergence this prevents).
|
|
37
|
+
*
|
|
38
|
+
* - `one_time` → the base (list) price
|
|
39
|
+
* - `subscription` → the first plan's price (the default / most-frequent plan)
|
|
40
|
+
* - `bundle` → the FIXED bundle price; `null` in `dynamic` mode (that
|
|
41
|
+
* price is computed from the members, not stored here)
|
|
42
|
+
* - `free` → `null`
|
|
43
|
+
* - `usage` → `null` — a metered item has no single unit price; it is
|
|
44
|
+
* rated per consumption via {@link UsageRating}
|
|
45
|
+
*
|
|
46
|
+
* Exhaustive on purpose: a new kind fails to compile here until it declares how
|
|
47
|
+
* it prices, instead of silently returning `null`.
|
|
48
|
+
*/
|
|
49
|
+
function unitPriceOf(m) {
|
|
50
|
+
switch (m.type) {
|
|
51
|
+
case "one_time": return m.pricing.basePrice;
|
|
52
|
+
case "subscription": return m.plans[0]?.price ?? null;
|
|
53
|
+
case "bundle": return m.basePrice ?? null;
|
|
54
|
+
case "free":
|
|
55
|
+
case "usage": return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The strikethrough / MSRP price, if the kind carries one (only `one_time`). */
|
|
59
|
+
function compareAtPriceOf(m) {
|
|
60
|
+
return m.type === "one_time" ? m.pricing.compareAtPrice ?? null : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Canonical kind → revenue wire type. Everything that is not `free` or
|
|
64
|
+
* `subscription` settles as a `purchase` in revenue's ledger vocabulary
|
|
65
|
+
* (one_time, bundle, and a usage charge are all "a purchase happened").
|
|
66
|
+
*/
|
|
67
|
+
function toRevenueMonetizationType(kind) {
|
|
68
|
+
if (kind === "free") return "free";
|
|
69
|
+
if (kind === "subscription") return "subscription";
|
|
70
|
+
return "purchase";
|
|
71
|
+
}
|
|
72
|
+
/** Revenue wire type → canonical kind (`purchase` → `one_time`). */
|
|
73
|
+
function fromRevenueMonetizationType(type) {
|
|
74
|
+
if (type === "free") return "free";
|
|
75
|
+
if (type === "subscription") return "subscription";
|
|
76
|
+
return "one_time";
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { MONETIZATION_KINDS, compareAtPriceOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isSubscriptionMonetization, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
package/dist/outbox.d.mts
CHANGED
|
@@ -158,6 +158,31 @@ declare class InvalidOutboxEventError extends Error {
|
|
|
158
158
|
* store is responsible for quarantining them.
|
|
159
159
|
*/
|
|
160
160
|
interface OutboxStore {
|
|
161
|
+
/**
|
|
162
|
+
* Declares that `save` ENLISTS `options.session` in the caller's transaction,
|
|
163
|
+
* so the event row and the caller's domain write commit or roll back together.
|
|
164
|
+
*
|
|
165
|
+
* ## Why this must be declared rather than assumed
|
|
166
|
+
*
|
|
167
|
+
* `OutboxWriteOptions.session` is documented above as BEST-EFFORT — "stores may
|
|
168
|
+
* ignore fields they don't support". That is the right contract for a
|
|
169
|
+
* multi-backend interface, but it means a caller cannot infer atomicity from
|
|
170
|
+
* the mere presence of an outbox. A Redis-backed store, a store on a second
|
|
171
|
+
* Mongo connection, or one that simply drops the option will happily persist an
|
|
172
|
+
* event for a domain write that later rolls back — a GHOST event describing
|
|
173
|
+
* something that never happened. For an access-control or ledger consumer that
|
|
174
|
+
* is worse than a lost event, because downstream acts on it.
|
|
175
|
+
*
|
|
176
|
+
* Absent or `false` therefore means "assume NOT atomic". Callers that need the
|
|
177
|
+
* guarantee (see `@classytic/access`'s `atomically()`) check this flag and say
|
|
178
|
+
* so plainly at boot rather than claiming a property they cannot keep.
|
|
179
|
+
*
|
|
180
|
+
* Set `true` ONLY when `save` passes the session through to the same
|
|
181
|
+
* transactional resource the caller is writing (e.g. a mongoose model on the
|
|
182
|
+
* SAME connection). An in-memory store must NOT set it: it cannot participate
|
|
183
|
+
* in a Mongo transaction, so its write survives a rollback.
|
|
184
|
+
*/
|
|
185
|
+
readonly transactionalSave?: boolean;
|
|
161
186
|
/**
|
|
162
187
|
* Save an event to the outbox (typically called inside a business
|
|
163
188
|
* transaction). MUST reject events missing `type` or `meta.id` — throw
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Money } from "./money.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/money/proration.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* How to count days within a period.
|
|
6
|
+
*
|
|
7
|
+
* - `'whole_day'` (default) — integer calendar-day counts. Callers normalize
|
|
8
|
+
* `periodStart`/`periodEnd`/`asOf` to business-local midnight first, so this
|
|
9
|
+
* matches "day-of-month" proration (the Stripe default, how ~99% of SaaS
|
|
10
|
+
* bill). `periodDays` is clamped to `>= 1`.
|
|
11
|
+
* - `'exact'` — fractional wall-clock day counts (`ms / 86_400_000`). Use when
|
|
12
|
+
* cycles are short and a partial day must be billed precisely.
|
|
13
|
+
*/
|
|
14
|
+
type ProrationGranularity = 'whole_day' | 'exact';
|
|
15
|
+
/** The elapsed/remaining split of a billing period at a change instant. */
|
|
16
|
+
interface PeriodFraction {
|
|
17
|
+
/** Total days in the period. `>= 1` for `whole_day`; `> 0` for `exact`. */
|
|
18
|
+
readonly periodDays: number;
|
|
19
|
+
/** Days consumed as of the change instant, clamped to `[0, periodDays]`. */
|
|
20
|
+
readonly elapsedDays: number;
|
|
21
|
+
/** `periodDays - elapsedDays`. */
|
|
22
|
+
readonly remainingDays: number;
|
|
23
|
+
/** `remainingDays / periodDays`, in `[0, 1]`. */
|
|
24
|
+
readonly remainingFraction: number;
|
|
25
|
+
}
|
|
26
|
+
interface PeriodProgressInput {
|
|
27
|
+
/** Period start instant (business-tz-normalized by the caller). */
|
|
28
|
+
readonly periodStart: Date;
|
|
29
|
+
/** Period end instant — must be strictly after `periodStart`. */
|
|
30
|
+
readonly periodEnd: Date;
|
|
31
|
+
/** The change/quote instant. Clamped into `[periodStart, periodEnd]`. */
|
|
32
|
+
readonly asOf: Date;
|
|
33
|
+
/** Day-counting granularity. Default `'whole_day'`. */
|
|
34
|
+
readonly granularity?: ProrationGranularity;
|
|
35
|
+
}
|
|
36
|
+
type ProrationErrorCode = 'INVALID_PERIOD' | 'INVALID_FRACTION';
|
|
37
|
+
declare class ProrationError extends Error {
|
|
38
|
+
readonly name = "ProrationError";
|
|
39
|
+
readonly code: ProrationErrorCode;
|
|
40
|
+
constructor(code: ProrationErrorCode, message: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compute how much of a period is elapsed vs remaining at `asOf`.
|
|
44
|
+
*
|
|
45
|
+
* `asOf` before the period start ⇒ nothing elapsed (full remaining);
|
|
46
|
+
* `asOf` after the period end ⇒ fully elapsed (nothing remaining).
|
|
47
|
+
*
|
|
48
|
+
* @throws ProrationError('INVALID_PERIOD') if a date is invalid or
|
|
49
|
+
* `periodEnd <= periodStart`.
|
|
50
|
+
*/
|
|
51
|
+
declare function periodProgress(input: PeriodProgressInput): PeriodFraction;
|
|
52
|
+
/**
|
|
53
|
+
* Split a `Money` amount into its consumed and remaining parts across a
|
|
54
|
+
* {@link PeriodFraction}, drift-free: `consumed.amount + remaining.amount ===
|
|
55
|
+
* amount.amount` exactly (largest-remainder allocation). Sign is preserved, so
|
|
56
|
+
* a negative amount (a reversal) splits symmetrically.
|
|
57
|
+
*
|
|
58
|
+
* This is the load-bearing primitive for proration: the "unused value" a
|
|
59
|
+
* plan-change credits is `remaining`; the "already-consumed value" is
|
|
60
|
+
* `consumed`.
|
|
61
|
+
*/
|
|
62
|
+
declare function splitByPeriodFraction(amount: Money, fraction: PeriodFraction): {
|
|
63
|
+
readonly consumed: Money;
|
|
64
|
+
readonly remaining: Money;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
|
|
68
|
+
* complement (`1 - fraction`) is discarded but computed so rounding is exact
|
|
69
|
+
* against the whole. `fraction` must be in `[0, 1]`.
|
|
70
|
+
*
|
|
71
|
+
* @throws ProrationError('INVALID_FRACTION') if `fraction` is outside `[0, 1]`
|
|
72
|
+
* or not finite.
|
|
73
|
+
*/
|
|
74
|
+
declare function allocateMoneyByFraction(amount: Money, fraction: number): Money;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { PeriodFraction, PeriodProgressInput, ProrationError, ProrationErrorCode, ProrationGranularity, allocateMoneyByFraction, periodProgress, splitByPeriodFraction };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { allocate } from "./split-allocation.mjs";
|
|
2
|
+
//#region src/money/proration.ts
|
|
3
|
+
/**
|
|
4
|
+
* Pure proration ARITHMETIC — fraction / period / allocation / rounding only.
|
|
5
|
+
*
|
|
6
|
+
* This module deliberately holds **no commercial policy**. It does not know
|
|
7
|
+
* what "an upgrade" is, whether a discount is creditable, how tax is treated,
|
|
8
|
+
* or whether a downgrade yields cash vs store credit. Those decisions live in
|
|
9
|
+
* the subscription/contract kernel (`@classytic/contract`
|
|
10
|
+
* `calculatePlanChangeProration(...)`), which composes these primitives.
|
|
11
|
+
*
|
|
12
|
+
* It is also **timezone-agnostic**: callers pass already-resolved `Date`
|
|
13
|
+
* instants (normalized to the deployment's business timezone via
|
|
14
|
+
* `@classytic/primitives/timezone` upstream). This module never reads a clock
|
|
15
|
+
* and never applies an offset — pass instants, get numbers.
|
|
16
|
+
*
|
|
17
|
+
* Money is integer minor units throughout; splits use the largest-remainder
|
|
18
|
+
* allocator so `consumed + remaining === amount` exactly, with no penny drift.
|
|
19
|
+
*/
|
|
20
|
+
const MS_PER_DAY = 864e5;
|
|
21
|
+
var ProrationError = class extends Error {
|
|
22
|
+
name = "ProrationError";
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function isValidDate(d) {
|
|
30
|
+
return d instanceof Date && !Number.isNaN(d.getTime());
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compute how much of a period is elapsed vs remaining at `asOf`.
|
|
34
|
+
*
|
|
35
|
+
* `asOf` before the period start ⇒ nothing elapsed (full remaining);
|
|
36
|
+
* `asOf` after the period end ⇒ fully elapsed (nothing remaining).
|
|
37
|
+
*
|
|
38
|
+
* @throws ProrationError('INVALID_PERIOD') if a date is invalid or
|
|
39
|
+
* `periodEnd <= periodStart`.
|
|
40
|
+
*/
|
|
41
|
+
function periodProgress(input) {
|
|
42
|
+
const { periodStart, periodEnd, asOf, granularity = "whole_day" } = input;
|
|
43
|
+
if (!isValidDate(periodStart) || !isValidDate(periodEnd) || !isValidDate(asOf)) throw new ProrationError("INVALID_PERIOD", "periodStart, periodEnd and asOf must be valid Dates");
|
|
44
|
+
const startMs = periodStart.getTime();
|
|
45
|
+
const endMs = periodEnd.getTime();
|
|
46
|
+
if (endMs <= startMs) throw new ProrationError("INVALID_PERIOD", `periodEnd (${periodEnd.toISOString()}) must be after periodStart (${periodStart.toISOString()})`);
|
|
47
|
+
const clampedAsOfMs = Math.min(Math.max(asOf.getTime(), startMs), endMs);
|
|
48
|
+
const rawPeriodDays = (endMs - startMs) / MS_PER_DAY;
|
|
49
|
+
const rawElapsedDays = (clampedAsOfMs - startMs) / MS_PER_DAY;
|
|
50
|
+
let periodDays;
|
|
51
|
+
let elapsedDays;
|
|
52
|
+
if (granularity === "whole_day") {
|
|
53
|
+
periodDays = Math.max(1, Math.round(rawPeriodDays));
|
|
54
|
+
elapsedDays = Math.min(periodDays, Math.max(0, Math.round(rawElapsedDays)));
|
|
55
|
+
} else {
|
|
56
|
+
periodDays = rawPeriodDays;
|
|
57
|
+
elapsedDays = Math.min(periodDays, Math.max(0, rawElapsedDays));
|
|
58
|
+
}
|
|
59
|
+
const remainingDays = periodDays - elapsedDays;
|
|
60
|
+
return {
|
|
61
|
+
periodDays,
|
|
62
|
+
elapsedDays,
|
|
63
|
+
remainingDays,
|
|
64
|
+
remainingFraction: remainingDays / periodDays
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Split a `Money` amount into its consumed and remaining parts across a
|
|
69
|
+
* {@link PeriodFraction}, drift-free: `consumed.amount + remaining.amount ===
|
|
70
|
+
* amount.amount` exactly (largest-remainder allocation). Sign is preserved, so
|
|
71
|
+
* a negative amount (a reversal) splits symmetrically.
|
|
72
|
+
*
|
|
73
|
+
* This is the load-bearing primitive for proration: the "unused value" a
|
|
74
|
+
* plan-change credits is `remaining`; the "already-consumed value" is
|
|
75
|
+
* `consumed`.
|
|
76
|
+
*/
|
|
77
|
+
function splitByPeriodFraction(amount, fraction) {
|
|
78
|
+
const result = allocate(amount.amount, [{
|
|
79
|
+
id: "consumed",
|
|
80
|
+
weight: fraction.elapsedDays
|
|
81
|
+
}, {
|
|
82
|
+
id: "remaining",
|
|
83
|
+
weight: fraction.remainingDays
|
|
84
|
+
}], "by-weight");
|
|
85
|
+
const consumed = result.parts.find((p) => p.id === "consumed")?.amount ?? 0;
|
|
86
|
+
const remaining = result.parts.find((p) => p.id === "remaining")?.amount ?? 0;
|
|
87
|
+
return {
|
|
88
|
+
consumed: {
|
|
89
|
+
amount: consumed,
|
|
90
|
+
currency: amount.currency
|
|
91
|
+
},
|
|
92
|
+
remaining: {
|
|
93
|
+
amount: remaining,
|
|
94
|
+
currency: amount.currency
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
|
|
100
|
+
* complement (`1 - fraction`) is discarded but computed so rounding is exact
|
|
101
|
+
* against the whole. `fraction` must be in `[0, 1]`.
|
|
102
|
+
*
|
|
103
|
+
* @throws ProrationError('INVALID_FRACTION') if `fraction` is outside `[0, 1]`
|
|
104
|
+
* or not finite.
|
|
105
|
+
*/
|
|
106
|
+
function allocateMoneyByFraction(amount, fraction) {
|
|
107
|
+
if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1) throw new ProrationError("INVALID_FRACTION", `fraction must be in [0, 1], got ${fraction}`);
|
|
108
|
+
return {
|
|
109
|
+
amount: allocate(amount.amount, [{
|
|
110
|
+
id: "part",
|
|
111
|
+
weight: fraction
|
|
112
|
+
}, {
|
|
113
|
+
id: "rest",
|
|
114
|
+
weight: 1 - fraction
|
|
115
|
+
}], "by-weight").parts.find((p) => p.id === "part")?.amount ?? 0,
|
|
116
|
+
currency: amount.currency
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
export { ProrationError, allocateMoneyByFraction, periodProgress, splitByPeriodFraction };
|
package/dist/shelf-life.mjs
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
|
+
import { addDays } from "./calendar.mjs";
|
|
1
2
|
//#region src/scheduling/shelf-life.ts
|
|
3
|
+
/**
|
|
4
|
+
* Shelf-life / lot-tracking policy + pure expiry-date derivation.
|
|
5
|
+
*
|
|
6
|
+
* Describes whether a SKU is tracked by lot/serial, whether it carries an
|
|
7
|
+
* expiration date, and the day-offsets used to derive the four lifecycle
|
|
8
|
+
* dates of a freshly-received lot. The *policy* is product-level metadata
|
|
9
|
+
* (owned by a catalog); the *derived dates* are per-lot facts (owned by a
|
|
10
|
+
* WMS). This module is the shared contract both speak so the shape can't
|
|
11
|
+
* drift between packages.
|
|
12
|
+
*
|
|
13
|
+
* Modeled on Odoo's `product_expiry` addon, which puts `tracking` +
|
|
14
|
+
* `use_expiration_date` and four integer day-deltas on `product.template`,
|
|
15
|
+
* then computes four dates on each `stock.lot`:
|
|
16
|
+
*
|
|
17
|
+
* expiration = receivedAt + shelfLifeDays
|
|
18
|
+
* bestBefore = expiration − bestBeforeDays (still safe, starts deteriorating)
|
|
19
|
+
* removal = expiration − removalDays (stop selling — FEFO sorts on THIS)
|
|
20
|
+
* alert = expiration − alertDays (raise a reminder)
|
|
21
|
+
*
|
|
22
|
+
* Consumed by `@classytic/catalog` (variant tracking policy) and
|
|
23
|
+
* `@classytic/flow` (lot date derivation at receipt, FEFO, fresh-on-hand).
|
|
24
|
+
*
|
|
25
|
+
* Zero-dep, pure UTC day arithmetic — safe in unit tests with no clock.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const policy: ShelfLifePolicy = {
|
|
29
|
+
* mode: 'lot',
|
|
30
|
+
* useExpiration: true,
|
|
31
|
+
* shelfLifeDays: 540, // 18-month medicine
|
|
32
|
+
* removalDays: 30, // pull 30 days before expiry
|
|
33
|
+
* alertDays: 60, // remind 60 days before expiry
|
|
34
|
+
* };
|
|
35
|
+
* deriveShelfLifeDates(new Date('2026-01-01T00:00:00Z'), policy);
|
|
36
|
+
* // → { expiresAt: 2027-06-25…, removalDate: 2027-05-26…, alertDate: 2027-04-26… }
|
|
37
|
+
*/
|
|
2
38
|
var ShelfLifeError = class extends Error {
|
|
3
39
|
name = "ShelfLifeError";
|
|
4
40
|
code;
|
|
@@ -78,9 +114,6 @@ function shelfLifeStatus(dates, asOf) {
|
|
|
78
114
|
if (dates.alertDate && now >= dates.alertDate.getTime()) return "alert";
|
|
79
115
|
return "fresh";
|
|
80
116
|
}
|
|
81
|
-
function addDays(d, days) {
|
|
82
|
-
return new Date(d.getTime() + days * DAY_MS);
|
|
83
|
-
}
|
|
84
117
|
function defined(key, val) {
|
|
85
118
|
return val === void 0 ? {} : { [key]: val };
|
|
86
119
|
}
|