@classytic/primitives 0.18.0 → 0.20.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 +906 -715
- package/README.md +160 -159
- package/dist/approval.d.mts +49 -10
- package/dist/approval.mjs +44 -10
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/cadence-BFQVC1Qp.d.mts +107 -0
- package/dist/cadence.d.mts +1 -92
- package/dist/cadence.mjs +133 -4
- package/dist/calendar-B6fm-FbR.d.mts +46 -0
- package/dist/calendar.d.mts +1 -45
- package/dist/canonical.mjs +17 -0
- package/dist/context.d.mts +28 -1
- package/dist/currency.d.mts +2 -2
- package/dist/currency.mjs +134 -19
- package/dist/event-infra.d.mts +1 -34
- package/dist/event-infra.mjs +16 -46
- package/dist/idempotency.d.mts +253 -0
- package/dist/idempotency.mjs +226 -0
- package/dist/memory-outbox.d.mts +64 -0
- package/dist/memory-outbox.mjs +172 -0
- package/dist/monetization.d.mts +195 -0
- package/dist/monetization.mjs +79 -0
- package/dist/money-DPo9fLBi.d.mts +347 -0
- package/dist/money.d.mts +1 -140
- package/dist/money.mjs +6 -15
- package/dist/outbox.d.mts +49 -1
- package/dist/outbox.mjs +64 -1
- package/dist/payment-events.d.mts +132 -4
- package/dist/payment-events.mjs +22 -2
- package/dist/payment-gateway.d.mts +210 -7
- package/dist/payment-gateway.mjs +18 -1
- package/dist/period.d.mts +138 -7
- package/dist/period.mjs +316 -5
- package/dist/person-parse.d.mts +82 -0
- package/dist/person-parse.mjs +109 -0
- package/dist/proration.d.mts +76 -0
- package/dist/proration.mjs +132 -0
- package/dist/sla-policy.d.mts +1 -1
- package/dist/state-machine.mjs +13 -0
- package/dist/suspension.d.mts +1 -1
- package/dist/timezone-tlG7ygwx.d.mts +114 -0
- package/dist/timezone.d.mts +2 -100
- package/dist/timezone.mjs +38 -3
- package/dist/unit-cost-rate.d.mts +1 -2
- package/package.json +244 -224
- package/dist/currency-BCJnJBlb.d.mts +0 -119
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { InvalidOutboxEventError, OutboxOwnershipError } from "./outbox.mjs";
|
|
2
|
+
//#region src/events/memory-outbox.ts
|
|
3
|
+
/**
|
|
4
|
+
* In-memory outbox store — reference implementation for dev / testing.
|
|
5
|
+
*
|
|
6
|
+
* Supports the full `OutboxStore` capability set (claim/lease, fail/retry,
|
|
7
|
+
* dedupe, visibleAt). Production deployments should pass a `repository` to
|
|
8
|
+
* `EventOutbox` instead of using this; the memory store exists for unit
|
|
9
|
+
* tests and single-process dev where durable persistence is noise.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_LEASE_MS = 3e4;
|
|
12
|
+
var MemoryOutboxStore = class {
|
|
13
|
+
entries = [];
|
|
14
|
+
seenDedupeKeys = /* @__PURE__ */ new Set();
|
|
15
|
+
/**
|
|
16
|
+
* Drop every entry (including delivered / failed / dead-lettered rows) and
|
|
17
|
+
* the dedupe-key memory.
|
|
18
|
+
*
|
|
19
|
+
* For tests only — it is the in-memory analogue of truncating the table, and
|
|
20
|
+
* exists so a suite can isolate cases against ONE store instance (the
|
|
21
|
+
* `reset` hook of `runOutboxStoreContract`). Not part of `OutboxStore`; a
|
|
22
|
+
* production store must never expose this.
|
|
23
|
+
*/
|
|
24
|
+
clear() {
|
|
25
|
+
this.entries.length = 0;
|
|
26
|
+
this.seenDedupeKeys.clear();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* For tests only — every stored event regardless of status, in insertion
|
|
30
|
+
* order. The in-memory analogue of `SELECT * FROM outbox`, used by the
|
|
31
|
+
* `/event-infra` contract test to assert what a `purge` left behind. Not part
|
|
32
|
+
* of `OutboxStore`; a production store must never expose this.
|
|
33
|
+
*/
|
|
34
|
+
all() {
|
|
35
|
+
return this.entries.map((e) => e.event);
|
|
36
|
+
}
|
|
37
|
+
async save(event, options) {
|
|
38
|
+
if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
39
|
+
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
40
|
+
if (options?.dedupeKey) {
|
|
41
|
+
if (this.seenDedupeKeys.has(options.dedupeKey)) return;
|
|
42
|
+
this.seenDedupeKeys.add(options.dedupeKey);
|
|
43
|
+
}
|
|
44
|
+
this.entries.push({
|
|
45
|
+
createdAt: Date.now(),
|
|
46
|
+
event,
|
|
47
|
+
status: "pending",
|
|
48
|
+
attempts: 0,
|
|
49
|
+
visibleAt: options?.visibleAt?.getTime() ?? 0,
|
|
50
|
+
leaseOwner: null,
|
|
51
|
+
leaseExpiresAt: 0,
|
|
52
|
+
deliveredAt: null,
|
|
53
|
+
firstFailedAt: null,
|
|
54
|
+
lastFailedAt: null,
|
|
55
|
+
lastError: null,
|
|
56
|
+
dedupeKey: options?.dedupeKey
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async getPending(limit) {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
return this.entries.filter((e) => e.status === "pending" && e.visibleAt <= now && (e.leaseOwner === null || e.leaseExpiresAt <= now)).slice(0, limit).map((e) => e.event);
|
|
62
|
+
}
|
|
63
|
+
async claimPending(options) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const limit = options?.limit ?? 100;
|
|
66
|
+
const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS;
|
|
67
|
+
const consumerId = options?.consumerId ?? "anonymous";
|
|
68
|
+
const typeFilter = options?.types ? new Set(options.types) : null;
|
|
69
|
+
const claimed = [];
|
|
70
|
+
for (const entry of this.entries) {
|
|
71
|
+
if (claimed.length >= limit) break;
|
|
72
|
+
if (entry.status !== "pending") continue;
|
|
73
|
+
if (entry.visibleAt > now) continue;
|
|
74
|
+
if (entry.leaseOwner !== null && entry.leaseExpiresAt > now) continue;
|
|
75
|
+
if (typeFilter && !typeFilter.has(entry.event.type)) continue;
|
|
76
|
+
entry.leaseOwner = consumerId;
|
|
77
|
+
entry.leaseExpiresAt = now + leaseMs;
|
|
78
|
+
entry.attempts++;
|
|
79
|
+
claimed.push(entry.event);
|
|
80
|
+
}
|
|
81
|
+
return claimed;
|
|
82
|
+
}
|
|
83
|
+
async acknowledge(eventId, options) {
|
|
84
|
+
const entry = this.entries.find((e) => e.event.meta.id === eventId);
|
|
85
|
+
if (!entry) return;
|
|
86
|
+
if (entry.status === "delivered") return;
|
|
87
|
+
if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
|
|
88
|
+
entry.status = "delivered";
|
|
89
|
+
entry.deliveredAt = Date.now();
|
|
90
|
+
entry.leaseOwner = null;
|
|
91
|
+
}
|
|
92
|
+
async fail(eventId, error, options) {
|
|
93
|
+
const entry = this.entries.find((e) => e.event.meta.id === eventId);
|
|
94
|
+
if (!entry) return;
|
|
95
|
+
if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
entry.lastError = error;
|
|
98
|
+
entry.leaseOwner = null;
|
|
99
|
+
entry.leaseExpiresAt = 0;
|
|
100
|
+
if (entry.firstFailedAt === null) entry.firstFailedAt = now;
|
|
101
|
+
entry.lastFailedAt = now;
|
|
102
|
+
if (options?.deadLetter) {
|
|
103
|
+
entry.status = "dead_letter";
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
entry.status = "pending";
|
|
107
|
+
entry.visibleAt = options?.retryAt ? options.retryAt.getTime() : 0;
|
|
108
|
+
}
|
|
109
|
+
async getDeadLettered(limit) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const entry of this.entries) {
|
|
112
|
+
if (out.length >= limit) break;
|
|
113
|
+
if (entry.status !== "dead_letter") continue;
|
|
114
|
+
out.push({
|
|
115
|
+
event: entry.event,
|
|
116
|
+
error: {
|
|
117
|
+
message: entry.lastError?.message ?? "unknown",
|
|
118
|
+
...entry.lastError?.code !== void 0 ? { code: entry.lastError.code } : {}
|
|
119
|
+
},
|
|
120
|
+
attempts: entry.attempts,
|
|
121
|
+
firstFailedAt: new Date(entry.firstFailedAt ?? entry.lastFailedAt ?? Date.now()),
|
|
122
|
+
lastFailedAt: new Date(entry.lastFailedAt ?? entry.firstFailedAt ?? Date.now())
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
async purge(olderThanMs) {
|
|
128
|
+
const cutoff = Date.now() - olderThanMs;
|
|
129
|
+
let purged = 0;
|
|
130
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
131
|
+
const entry = this.entries[i];
|
|
132
|
+
if (!entry) continue;
|
|
133
|
+
if (entry.status === "delivered" && entry.deliveredAt !== null && entry.deliveredAt < cutoff) {
|
|
134
|
+
if (entry.dedupeKey) this.seenDedupeKeys.delete(entry.dedupeKey);
|
|
135
|
+
this.entries.splice(i, 1);
|
|
136
|
+
purged++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return purged;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Re-drive one dead-lettered row. Scoped to `dead_letter`: a row that is merely
|
|
143
|
+
* backing off must keep its attempt count, and the id alone cannot tell the two apart.
|
|
144
|
+
*/
|
|
145
|
+
async requeue(eventId) {
|
|
146
|
+
const entry = this.entries.find((e) => e.event.meta.id === eventId && e.status === "dead_letter");
|
|
147
|
+
if (!entry) return false;
|
|
148
|
+
entry.status = "pending";
|
|
149
|
+
entry.attempts = 0;
|
|
150
|
+
entry.leaseOwner = null;
|
|
151
|
+
entry.leaseExpiresAt = 0;
|
|
152
|
+
entry.visibleAt = 0;
|
|
153
|
+
entry.lastError = null;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
async countByStatus(status) {
|
|
157
|
+
return this.entries.filter((e) => e.status === status).length;
|
|
158
|
+
}
|
|
159
|
+
/** Age of the oldest PENDING row — see `MemoryEntry.createdAt` for why not `visibleAt`. */
|
|
160
|
+
async oldestPendingAgeMs() {
|
|
161
|
+
const pending = this.entries.filter((e) => e.status === "pending");
|
|
162
|
+
if (pending.length === 0) return null;
|
|
163
|
+
const oldest = Math.min(...pending.map((e) => e.createdAt));
|
|
164
|
+
return Date.now() - oldest;
|
|
165
|
+
}
|
|
166
|
+
/** Test helper: inspect entry by id */
|
|
167
|
+
_getEntry(eventId) {
|
|
168
|
+
return this.entries.find((e) => e.event.meta.id === eventId);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
//#endregion
|
|
172
|
+
export { MemoryOutboxStore };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { n as Money } from "./money-DPo9fLBi.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 };
|