@happyvertical/smrt-subscriptions 0.57.9 → 0.57.10
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/AGENTS.md +21 -0
- package/README.md +112 -3
- package/dist/chunks/SubscriptionPlanCollection-CqlYYtPg.js +229 -0
- package/dist/chunks/SubscriptionPlanCollection-CqlYYtPg.js.map +1 -0
- package/dist/chunks/SubscriptionPlanVersionCollection-CV0QPmp0.js +193 -0
- package/dist/chunks/SubscriptionPlanVersionCollection-CV0QPmp0.js.map +1 -0
- package/dist/chunks/plan-terms-BoeXvDhs.js +219 -0
- package/dist/chunks/plan-terms-BoeXvDhs.js.map +1 -0
- package/dist/chunks/plan-versioning-Bp0NqTJf.js +142 -0
- package/dist/chunks/plan-versioning-Bp0NqTJf.js.map +1 -0
- package/dist/chunks/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/collections/SubscriptionPlanVersionCollection.d.ts +33 -0
- package/dist/collections/SubscriptionPlanVersionCollection.d.ts.map +1 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +181 -349
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +1114 -328
- package/dist/migrations/planVersions.d.ts +16 -0
- package/dist/migrations/planVersions.d.ts.map +1 -0
- package/dist/models/SubscriptionPlan.d.ts +39 -3
- package/dist/models/SubscriptionPlan.d.ts.map +1 -1
- package/dist/models/SubscriptionPlanVersion.d.ts +51 -0
- package/dist/models/SubscriptionPlanVersion.d.ts.map +1 -0
- package/dist/models/TenantSubscription.d.ts +49 -1
- package/dist/models/TenantSubscription.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/plan-terms.d.ts +26 -0
- package/dist/plan-terms.d.ts.map +1 -0
- package/dist/plan-versioning.d.ts +45 -0
- package/dist/plan-versioning.d.ts.map +1 -0
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/subscription-resolver.d.ts +26 -0
- package/dist/services/subscription-resolver.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +547 -7
- package/dist/types.d.ts +72 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -6
package/AGENTS.md
CHANGED
|
@@ -64,6 +64,27 @@ that reverted to DECIMAL passes every SQLite suite.
|
|
|
64
64
|
are BIGINT, and hydration rejects values outside JavaScript's safe-integer
|
|
65
65
|
range. Existing PostgreSQL `int4` columns require the explicit widening in #2424.
|
|
66
66
|
|
|
67
|
+
## Plan versions (#4032)
|
|
68
|
+
|
|
69
|
+
- `SubscriptionPlan.save()` publishes the next immutable
|
|
70
|
+
`SubscriptionPlanVersion` when its terms (`PLAN_TERM_FIELDS`) change; the
|
|
71
|
+
logic lives in `src/plan-versioning.ts`. Versions are append-only,
|
|
72
|
+
contiguous, and monotonic in `effectiveFrom`.
|
|
73
|
+
- `TenantSubscription.validateBeforeSave()` maintains `planVersionId` via
|
|
74
|
+
`pinSubscriptionPlanVersion()`: pin at period start, re-pin when
|
|
75
|
+
`currentPeriodStart` moves, and refuse in-period changes that are not an
|
|
76
|
+
`isEntitlementUpgrade()` unless `replacePlanVersion({ policy: 'authorized',
|
|
77
|
+
reason })`. Never relax this to "latest version wins": a paid period must
|
|
78
|
+
not lose entitlements.
|
|
79
|
+
- The resolver reads the pinned version; a missing or inactive pinned version
|
|
80
|
+
fails closed. Only unpinned rows on plans without versions fall back to the
|
|
81
|
+
live plan. `migrateSubscriptionPlanVersions(db)` backfills both.
|
|
82
|
+
- Renewal means the new `currentPeriodStart` is at or after the stored
|
|
83
|
+
`currentPeriodEnd`; creation and renewal pin the boundary's version, and a
|
|
84
|
+
caller-named other version needs an authorized replacement.
|
|
85
|
+
- `canceled` subscriptions entitle until `currentPeriodEnd` only with
|
|
86
|
+
`retainsPaidPeriod`, derived on save from an entitled pre-cancel row.
|
|
87
|
+
|
|
67
88
|
## Reseller billing (#3059)
|
|
68
89
|
|
|
69
90
|
- Billing ownership is consumed from `smrt-tenancy`'s
|
package/README.md
CHANGED
|
@@ -55,7 +55,8 @@ subscription and plan.
|
|
|
55
55
|
their agreed amount for pay-what-you-can, never the suggested amount, and
|
|
56
56
|
skips free plans. External subscriber invoice routing remains host-owned. Pricing
|
|
57
57
|
mode and minimum are immutable after the plan is saved; create a
|
|
58
|
-
new plan instead
|
|
58
|
+
new plan instead. Other term changes publish a new plan version (see
|
|
59
|
+
[Plan versions](#plan-versions-and-paid-periods)).
|
|
59
60
|
Invalid legacy pricing fails only its payer group at period close and reports
|
|
60
61
|
the subscription ID while other payer groups continue. Ordinary subscription
|
|
61
62
|
writes resolve only their own tenant's plans or legacy global plans; trusted
|
|
@@ -63,6 +64,8 @@ subscription and plan.
|
|
|
63
64
|
Entitlement resolution validates plan ownership on both reader and supplied
|
|
64
65
|
context paths; trusted seller-to-child resolution requires explicit system
|
|
65
66
|
context too.
|
|
67
|
+
- `SubscriptionPlanVersion` is an immutable, effective-dated snapshot of a
|
|
68
|
+
plan's terms; subscriptions pin the version their period paid for.
|
|
66
69
|
- `TenantUsageMetric` is immutable, idempotent usage evidence.
|
|
67
70
|
- `SubscriptionResolver` combines current subscription, plan, and batched usage
|
|
68
71
|
summaries into one entitlement decision.
|
|
@@ -87,8 +90,8 @@ pass `null` explicitly for legacy global plans. Update callers from the old
|
|
|
87
90
|
single-argument signature. Pricing mode and minimum cannot be edited after
|
|
88
91
|
persistence, even before the first subscriber, so a concurrent signup cannot
|
|
89
92
|
observe terms that change mode underneath it. Existing fixed-price edits and
|
|
90
|
-
current-period entitlement versioning
|
|
91
|
-
|
|
93
|
+
current-period entitlement versioning arrived with #4032 (see
|
|
94
|
+
[Plan versions](#plan-versions-and-paid-periods)). Concurrent first creates
|
|
92
95
|
for one `(tenantId, planKey)` use a strict insert: one succeeds and a collision
|
|
93
96
|
fails without changing the winner. Retry by reading the existing plan; a
|
|
94
97
|
sequential create with matching mode/minimum retains natural-key upsert behavior.
|
|
@@ -105,6 +108,112 @@ migration so different tenants can reuse a plan key. The script supports SQLite
|
|
|
105
108
|
and PostgreSQL and refuses DuckDB, whose inline unique constraint needs a
|
|
106
109
|
reviewed table rebuild.
|
|
107
110
|
|
|
111
|
+
## Plan versions and paid periods
|
|
112
|
+
|
|
113
|
+
A `SubscriptionPlan` is the stable tier identity, scoped by
|
|
114
|
+
`(tenantId, planKey)`. Its terms — `status`, `priceAmount`, `currency`,
|
|
115
|
+
`billingInterval`, `pricingMode`, `minimumAmount`, `suggestedAmount`,
|
|
116
|
+
feature grants and thresholds — are published as immutable
|
|
117
|
+
`SubscriptionPlanVersion` rows (#4032):
|
|
118
|
+
|
|
119
|
+
- Saving a plan whose terms differ from its latest version publishes the next
|
|
120
|
+
version (1, 2, 3 …), effective now. `plan.publishTerms({ effectiveFrom })`
|
|
121
|
+
schedules a future amendment instead. Versions are append-only (no update,
|
|
122
|
+
no delete) and monotonic: a version cannot start before the previous one.
|
|
123
|
+
Re-saving unchanged terms, or changing only `name`/`description`/metadata,
|
|
124
|
+
publishes nothing.
|
|
125
|
+
- A version is in force from its `effectiveFrom` until the next version's;
|
|
126
|
+
an instant before the first version resolves to version 1.
|
|
127
|
+
- `TenantSubscription.planVersionId` is the version frozen for the current
|
|
128
|
+
period (`planVersionPinnedAt`, `planVersionPinReason`, and
|
|
129
|
+
`planVersionPinNote` audit it). It is maintained on every save:
|
|
130
|
+
- a new subscription pins the version in force at its period start
|
|
131
|
+
(`currentPeriodStart ?? startedAt`);
|
|
132
|
+
- **renewal** — a save whose new `currentPeriodStart` is at or after the
|
|
133
|
+
stored `currentPeriodEnd` (or, for an open-ended row, after the stored
|
|
134
|
+
start) — re-pins the version in force at the new boundary, for the plan
|
|
135
|
+
the row now names. A downgrade or a plan change that removes features is
|
|
136
|
+
applied this way. Moving the start within a paid period is not a renewal,
|
|
137
|
+
and a renewal saved before its start date may change the pinned terms
|
|
138
|
+
only as an upgrade (otherwise save it once the period has started). It
|
|
139
|
+
pins the version scheduled for its boundary at save time; an amendment
|
|
140
|
+
published later but effective before that boundary applies from the
|
|
141
|
+
following renewal (or re-save the renewal after the period starts).
|
|
142
|
+
Period dates are host/provider-owned: shortening `currentPeriodEnd` ends
|
|
143
|
+
the paid period;
|
|
144
|
+
- at creation and renewal the boundary decides the version: naming any
|
|
145
|
+
other `planVersionId` (for example to grandfather an import) needs an
|
|
146
|
+
authorized replacement;
|
|
147
|
+
- within an unchanged period, a different plan or version is accepted only
|
|
148
|
+
when it is an **upgrade** (`isEntitlementUpgrade()`: every enabled feature
|
|
149
|
+
stays enabled and no threshold is added, lowered or made stricter), or
|
|
150
|
+
when the period no longer entitles. Anything else is rejected;
|
|
151
|
+
- `subscription.replacePlanVersion({ policy: 'authorized', reason })` is the
|
|
152
|
+
explicit, audited escape hatch for an approved in-period downgrade or
|
|
153
|
+
correction. `policy: 'upgrade'` applies an amended version of the same
|
|
154
|
+
plan mid-period when it only adds.
|
|
155
|
+
- **Cancellation** keeps the paid period: a subscription canceled while it
|
|
156
|
+
entitled (`active`/`trialing` within its period) records
|
|
157
|
+
`retainsPaidPeriod` and entitles until `currentPeriodEnd`, then resolves
|
|
158
|
+
nothing. A subscription canceled from `incomplete`, `past_due` or `unpaid`
|
|
159
|
+
(for example a provider's `incomplete_expired`) entitles nothing. For an
|
|
160
|
+
immediate cancellation of a paid period (e.g. with a refund) set
|
|
161
|
+
`currentPeriodEnd` to the cancellation time.
|
|
162
|
+
|
|
163
|
+
`SubscriptionResolver` resolves entitlements at `options.now` from the pinned
|
|
164
|
+
version, not the live plan: editing, archiving, or removing a feature from a
|
|
165
|
+
plan never changes a paid period. A pinned version that cannot be found, or
|
|
166
|
+
whose status is not `active`, fails closed. `EntitlementResolution` reports
|
|
167
|
+
`planVersionId` and `planVersion`; `EntitlementResolutionContext` carries
|
|
168
|
+
`planVersion`, validated against the subscription's plan, pin and tenant like
|
|
169
|
+
`plan`. Resolvers built from custom readers without `planVersions` keep the
|
|
170
|
+
live-plan behavior.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// A co-op removes a perk. Paying members keep it until their renewal.
|
|
174
|
+
plan.setFeatureGrants(['coop:vote']);
|
|
175
|
+
await plan.save(); // publishes version 2, effective now
|
|
176
|
+
|
|
177
|
+
// Schedule an entitlement change from a future date.
|
|
178
|
+
plan.setFeatureGrants(['coop:vote', 'coop:shift-swap']);
|
|
179
|
+
await plan.publishTerms({ effectiveFrom: new Date('2027-01-01T00:00:00Z') });
|
|
180
|
+
|
|
181
|
+
// Resolve what a member is entitled to at an instant.
|
|
182
|
+
const result = await resolver.resolveEntitlements(subscriber, { now });
|
|
183
|
+
result.planVersion; // the version their current period paid for
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The plan row always shows the latest published terms, including a scheduled
|
|
187
|
+
version's. Hosts that charge from the plan row must price from the pinned
|
|
188
|
+
version instead; commerce period-close still reads the live row until #4146,
|
|
189
|
+
so do not schedule a price change ahead of time while relying on it.
|
|
190
|
+
|
|
191
|
+
The plan row is written before its version. If publishing fails, the save
|
|
192
|
+
rejects with the row already updated and the next save publishes the missing
|
|
193
|
+
version; wrap the save in `plan.withTransaction()` when both writes must be
|
|
194
|
+
atomic.
|
|
195
|
+
|
|
196
|
+
### Migrating existing data
|
|
197
|
+
|
|
198
|
+
1. Run the ordinary manifest migration (`smrt db:migrate`). It adds the
|
|
199
|
+
`_smrt_subscription_plan_versions` table and the nullable `plan_version_id`,
|
|
200
|
+
`plan_version_pinned_at`, `plan_version_pin_reason` and
|
|
201
|
+
`plan_version_pin_note` and `retains_paid_period` columns on
|
|
202
|
+
`_smrt_tenant_subscriptions`. Nothing is dropped or rewritten. Existing
|
|
203
|
+
`canceled` rows default to `retains_paid_period = false`, so they stay
|
|
204
|
+
unentitled exactly as before.
|
|
205
|
+
2. Run `migrateSubscriptionPlanVersions(db)` once. Every plan without versions
|
|
206
|
+
gets version 1 — its current terms, effective from the plan's creation —
|
|
207
|
+
and every unpinned subscription is pinned to it with
|
|
208
|
+
`planVersionPinReason: 'migration'`. Entitlements are unchanged. The run is
|
|
209
|
+
idempotent, works across tenants in system context, and reports any
|
|
210
|
+
subscription it could not re-save in `failed` (that row keeps resolving
|
|
211
|
+
through version 1).
|
|
212
|
+
|
|
213
|
+
Until step 2, unpinned subscriptions to plans without versions keep resolving
|
|
214
|
+
from the live plan, and the first edit of such a plan records its pre-edit
|
|
215
|
+
terms as version 1 before publishing the change.
|
|
216
|
+
|
|
108
217
|
## Thresholds and spending
|
|
109
218
|
|
|
110
219
|
Thresholds define a metric key, time window, limit, and enforcement behavior.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import { f as normalizeFeatureGrants, g as stringifyJson, h as parseJsonObject, m as parseJsonArray, r as extractPlanTerms } from "./plan-terms-BoeXvDhs.js";
|
|
3
|
+
import { SmrtCollection, SmrtObject, smrt } from "@happyvertical/smrt-core";
|
|
4
|
+
import { TenantScoped, getTenantId, tenantId } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
//#region src/models/SubscriptionPlan.ts
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
value
|
|
13
|
+
}) : obj[key] = value;
|
|
14
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
15
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
16
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
17
|
+
if (kind && result) __defProp(target, key, result);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
21
|
+
var requestedEffectiveFrom = /* @__PURE__ */ new WeakMap();
|
|
22
|
+
var priorTerms = /* @__PURE__ */ new WeakMap();
|
|
23
|
+
var publishedVersions = /* @__PURE__ */ new WeakMap();
|
|
24
|
+
var SubscriptionPlan = class extends SmrtObject {
|
|
25
|
+
tenantId = null;
|
|
26
|
+
planKey = "";
|
|
27
|
+
name = "";
|
|
28
|
+
description = "";
|
|
29
|
+
status = "active";
|
|
30
|
+
sortOrder = 0;
|
|
31
|
+
/**
|
|
32
|
+
* Plan price in **integer minor units** of {@link currency} — `$19.99` is
|
|
33
|
+
* `1999`. The `= 0` initializer is load-bearing: SMRT maps an integer literal
|
|
34
|
+
* to INTEGER and a decimal literal to DECIMAL, and money is exact (#2401).
|
|
35
|
+
*/
|
|
36
|
+
priceAmount = 0;
|
|
37
|
+
currency = "USD";
|
|
38
|
+
billingInterval = "month";
|
|
39
|
+
pricingMode = "fixed";
|
|
40
|
+
minimumAmount = 0;
|
|
41
|
+
suggestedAmount = 0;
|
|
42
|
+
/** Optional provider binding; plan semantics never require a provider. */
|
|
43
|
+
externalProvider = null;
|
|
44
|
+
providerProductId = null;
|
|
45
|
+
providerPriceId = null;
|
|
46
|
+
/** @deprecated Use providerProductId. */
|
|
47
|
+
stripeProductId = null;
|
|
48
|
+
/** @deprecated Use providerPriceId. */
|
|
49
|
+
stripePriceId = null;
|
|
50
|
+
features = "[]";
|
|
51
|
+
thresholds = "[]";
|
|
52
|
+
metadata = "{}";
|
|
53
|
+
constructor(options = {}) {
|
|
54
|
+
super(options);
|
|
55
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
56
|
+
if (options.planKey !== void 0) this.planKey = options.planKey;
|
|
57
|
+
if (options.name !== void 0) this.name = options.name;
|
|
58
|
+
if (options.description !== void 0) this.description = options.description;
|
|
59
|
+
if (options.status !== void 0) this.status = options.status;
|
|
60
|
+
if (options.sortOrder !== void 0) this.sortOrder = options.sortOrder;
|
|
61
|
+
if (options.priceAmount !== void 0) this.priceAmount = options.priceAmount;
|
|
62
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
63
|
+
if (options.billingInterval !== void 0) this.billingInterval = options.billingInterval;
|
|
64
|
+
if (options.externalProvider !== void 0) this.externalProvider = options.externalProvider;
|
|
65
|
+
if (options.pricingMode !== void 0) this.pricingMode = options.pricingMode;
|
|
66
|
+
if (options.minimumAmount !== void 0) this.minimumAmount = options.minimumAmount;
|
|
67
|
+
if (options.suggestedAmount !== void 0) this.suggestedAmount = options.suggestedAmount;
|
|
68
|
+
if (options.providerProductId !== void 0) this.providerProductId = options.providerProductId;
|
|
69
|
+
if (options.providerPriceId !== void 0) this.providerPriceId = options.providerPriceId;
|
|
70
|
+
if (options.stripeProductId !== void 0) this.stripeProductId = options.stripeProductId;
|
|
71
|
+
if (options.stripePriceId !== void 0) this.stripePriceId = options.stripePriceId;
|
|
72
|
+
if (options.features !== void 0) this.features = options.features;
|
|
73
|
+
if (options.thresholds !== void 0) this.thresholds = options.thresholds;
|
|
74
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
75
|
+
}
|
|
76
|
+
isActive() {
|
|
77
|
+
return this.status === "active";
|
|
78
|
+
}
|
|
79
|
+
/** The versioned terms currently on this row (the latest published). */
|
|
80
|
+
getTerms() {
|
|
81
|
+
return extractPlanTerms(this);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Persist this plan. When its terms (status, price, pricing configuration,
|
|
85
|
+
* feature grants or thresholds) differ from the latest published version —
|
|
86
|
+
* or no version exists yet — the save then publishes the next immutable
|
|
87
|
+
* {@link SubscriptionPlanVersion}, effective now (#4032). Use
|
|
88
|
+
* {@link publishTerms} to choose a different effective date. Subscribers
|
|
89
|
+
* keep the version pinned for their current period; the new terms apply
|
|
90
|
+
* from their next renewal.
|
|
91
|
+
*
|
|
92
|
+
* A plan saved before versioning existed first records its pre-edit terms
|
|
93
|
+
* as version 1, effective from its creation, so subscriptions sold under
|
|
94
|
+
* them are not re-priced.
|
|
95
|
+
*
|
|
96
|
+
* The row is written before the version. If publishing fails the save
|
|
97
|
+
* rejects with the row already updated; saving again publishes the
|
|
98
|
+
* missing version. Wrap the call in `plan.withTransaction()` when the
|
|
99
|
+
* adapter supports transactions and the two writes must be atomic.
|
|
100
|
+
*/
|
|
101
|
+
async save(options) {
|
|
102
|
+
const effectiveFrom = requestedEffectiveFrom.get(this) ?? /* @__PURE__ */ new Date();
|
|
103
|
+
requestedEffectiveFrom.delete(this);
|
|
104
|
+
await super.save(options);
|
|
105
|
+
const { publishPlanVersion } = await import("./plan-versioning-Bp0NqTJf.js").then((n) => n.n);
|
|
106
|
+
const prior = priorTerms.get(this);
|
|
107
|
+
priorTerms.delete(this);
|
|
108
|
+
publishedVersions.set(this, await publishPlanVersion(this, effectiveFrom, prior));
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Save this plan and publish its current terms as a version effective at
|
|
113
|
+
* `effectiveFrom` (default now). A future date schedules an amendment: the
|
|
114
|
+
* plan row shows the new terms immediately, but subscriptions starting or
|
|
115
|
+
* renewing before that date pin the earlier version. Versions are
|
|
116
|
+
* monotonic, so `effectiveFrom` may not precede the latest version's.
|
|
117
|
+
*
|
|
118
|
+
* @returns the latest version, which carries the saved terms.
|
|
119
|
+
*/
|
|
120
|
+
async publishTerms(options = {}) {
|
|
121
|
+
if (options.effectiveFrom) requestedEffectiveFrom.set(this, options.effectiveFrom);
|
|
122
|
+
await this.save();
|
|
123
|
+
const version = publishedVersions.get(this);
|
|
124
|
+
if (!version) throw new Error("Plan version was not published");
|
|
125
|
+
return version;
|
|
126
|
+
}
|
|
127
|
+
/** The version of this plan in force at `at` (default now). */
|
|
128
|
+
async getVersionInForce(at = /* @__PURE__ */ new Date()) {
|
|
129
|
+
if (!this.id) return null;
|
|
130
|
+
const { SubscriptionPlanVersionCollection } = await import("./SubscriptionPlanVersionCollection-CV0QPmp0.js").then((n) => n.n);
|
|
131
|
+
return (await SubscriptionPlanVersionCollection.create({ db: this.db })).findInForce(String(this.id), at);
|
|
132
|
+
}
|
|
133
|
+
getFeatureGrants() {
|
|
134
|
+
return normalizeFeatureGrants(parseJsonArray(this.features));
|
|
135
|
+
}
|
|
136
|
+
setFeatureGrants(grants) {
|
|
137
|
+
this.features = stringifyJson(normalizeFeatureGrants(grants));
|
|
138
|
+
}
|
|
139
|
+
getFeatureKeys() {
|
|
140
|
+
return this.getFeatureGrants().filter((grant) => grant.enabled !== false).map((grant) => grant.featureKey);
|
|
141
|
+
}
|
|
142
|
+
getThresholds() {
|
|
143
|
+
return parseJsonArray(this.thresholds).filter((threshold) => threshold.metricKey);
|
|
144
|
+
}
|
|
145
|
+
setThresholds(thresholds) {
|
|
146
|
+
this.thresholds = stringifyJson(thresholds);
|
|
147
|
+
}
|
|
148
|
+
getMetadata() {
|
|
149
|
+
return parseJsonObject(this.metadata, {});
|
|
150
|
+
}
|
|
151
|
+
setMetadata(metadata) {
|
|
152
|
+
this.metadata = stringifyJson(metadata);
|
|
153
|
+
}
|
|
154
|
+
async validateBeforeSave() {
|
|
155
|
+
await super.validateBeforeSave();
|
|
156
|
+
const { SubscriptionPlanCollection } = await Promise.resolve().then(() => SubscriptionPlanCollection_exports);
|
|
157
|
+
const plans = await SubscriptionPlanCollection.create({ db: this.db });
|
|
158
|
+
const persisted = (this.id ? await plans.get({ id: String(this.id) }, { cache: false }) : null) ?? await plans.findByPlanKey(this.tenantId ?? getTenantId() ?? null, this.planKey);
|
|
159
|
+
if (!persisted) this.requireInsertOnSave();
|
|
160
|
+
else priorTerms.set(this, persisted);
|
|
161
|
+
if (persisted && (persisted.pricingMode !== this.pricingMode || persisted.minimumAmount !== this.minimumAmount)) throw new Error("Create a new plan to change pricing mode or minimum after persistence");
|
|
162
|
+
if (![
|
|
163
|
+
"free",
|
|
164
|
+
"fixed",
|
|
165
|
+
"pay_what_you_can"
|
|
166
|
+
].includes(this.pricingMode)) throw new Error("Invalid pricingMode");
|
|
167
|
+
if (this.pricingMode !== "fixed" && this.priceAmount !== 0) throw new Error("priceAmount must be zero for free and pay_what_you_can plans");
|
|
168
|
+
for (const [name, value] of [
|
|
169
|
+
["priceAmount", this.priceAmount],
|
|
170
|
+
["minimumAmount", this.minimumAmount],
|
|
171
|
+
["suggestedAmount", this.suggestedAmount]
|
|
172
|
+
]) if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer minor-unit amount`);
|
|
173
|
+
if (this.pricingMode === "pay_what_you_can" && this.suggestedAmount > 0 && this.suggestedAmount < this.minimumAmount) throw new Error("suggestedAmount must be at least minimumAmount");
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
__publicField(SubscriptionPlan, "__smrtPackage__", "@happyvertical/smrt-subscriptions");
|
|
177
|
+
__decorateClass([tenantId({ nullable: true })], SubscriptionPlan.prototype, "tenantId", 2);
|
|
178
|
+
SubscriptionPlan = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
179
|
+
tableName: "_smrt_subscription_plans",
|
|
180
|
+
api: { include: [
|
|
181
|
+
"list",
|
|
182
|
+
"get",
|
|
183
|
+
"create",
|
|
184
|
+
"update"
|
|
185
|
+
] },
|
|
186
|
+
cli: { skipApiCheck: true },
|
|
187
|
+
mcp: { include: ["list", "get"] },
|
|
188
|
+
conflictColumns: ["tenant_id", "plan_key"]
|
|
189
|
+
})], SubscriptionPlan);
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/collections/SubscriptionPlanCollection.ts
|
|
192
|
+
var SubscriptionPlanCollection_exports = /* @__PURE__ */ __exportAll({ SubscriptionPlanCollection: () => SubscriptionPlanCollection });
|
|
193
|
+
var SubscriptionPlanCollection = class extends SmrtCollection {
|
|
194
|
+
static _itemClass = SubscriptionPlan;
|
|
195
|
+
async findByPlanKey(tenantId, planKey) {
|
|
196
|
+
return (await this.list({
|
|
197
|
+
where: {
|
|
198
|
+
tenantId,
|
|
199
|
+
planKey
|
|
200
|
+
},
|
|
201
|
+
limit: 1
|
|
202
|
+
}))[0] ?? null;
|
|
203
|
+
}
|
|
204
|
+
async findActive() {
|
|
205
|
+
return this.list({
|
|
206
|
+
where: { status: "active" },
|
|
207
|
+
orderBy: "sortOrder ASC"
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async findByProviderPriceId(providerPriceId) {
|
|
211
|
+
const plans = await this.list({
|
|
212
|
+
where: { providerPriceId },
|
|
213
|
+
limit: 1
|
|
214
|
+
});
|
|
215
|
+
if (plans[0]) return plans[0];
|
|
216
|
+
return (await this.list({ where: { stripePriceId: providerPriceId } })).find((plan) => !plan.externalProvider || plan.externalProvider === "stripe") ?? null;
|
|
217
|
+
}
|
|
218
|
+
/** @deprecated Provider-neutral integrations should use findByProviderPriceId. */
|
|
219
|
+
async findByStripePriceId(stripePriceId) {
|
|
220
|
+
return (await this.list({
|
|
221
|
+
where: { stripePriceId },
|
|
222
|
+
limit: 1
|
|
223
|
+
}))[0] ?? null;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
//#endregion
|
|
227
|
+
export { SubscriptionPlanCollection_exports as n, SubscriptionPlan as r, SubscriptionPlanCollection as t };
|
|
228
|
+
|
|
229
|
+
//# sourceMappingURL=SubscriptionPlanCollection-CqlYYtPg.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SubscriptionPlanCollection-CqlYYtPg.js","names":[],"sources":["../../src/models/SubscriptionPlan.ts","../../src/collections/SubscriptionPlanCollection.ts"],"sourcesContent":["import {\n SmrtObject,\n type SmrtSaveOptions,\n smrt,\n} from '@happyvertical/smrt-core';\nimport {\n getTenantId,\n TenantScoped,\n tenantId,\n} from '@happyvertical/smrt-tenancy';\nimport { extractPlanTerms } from '../plan-terms.js';\nimport type {\n BillingInterval,\n JsonObject,\n PlanFeatureGrant,\n PlanTerms,\n PlanThreshold,\n PricingMode,\n SubscriptionPlanOptions,\n SubscriptionPlanStatus,\n} from '../types.js';\nimport {\n normalizeFeatureGrants,\n parseJsonArray,\n parseJsonObject,\n stringifyJson,\n} from '../utils.js';\nimport type { SubscriptionPlanVersion } from './SubscriptionPlanVersion.js';\n\n/** Effective date requested by {@link SubscriptionPlan.publishTerms}. */\nconst requestedEffectiveFrom = new WeakMap<SubscriptionPlan, Date>();\n/** The persisted row a save is about to overwrite, read during validation. */\nconst priorTerms = new WeakMap<SubscriptionPlan, SubscriptionPlan>();\n/** The latest version after the most recent save. */\nconst publishedVersions = new WeakMap<\n SubscriptionPlan,\n SubscriptionPlanVersion\n>();\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: '_smrt_subscription_plans',\n api: { include: ['list', 'get', 'create', 'update'] },\n cli: { skipApiCheck: true },\n mcp: { include: ['list', 'get'] },\n conflictColumns: ['tenant_id', 'plan_key'],\n})\nexport class SubscriptionPlan extends SmrtObject implements PlanTerms { static __smrtPackage__ = \"@happyvertical/smrt-subscriptions\";\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n planKey: string = '';\n name: string = '';\n description: string = '';\n status: SubscriptionPlanStatus = 'active';\n sortOrder: number = 0;\n /**\n * Plan price in **integer minor units** of {@link currency} — `$19.99` is\n * `1999`. The `= 0` initializer is load-bearing: SMRT maps an integer literal\n * to INTEGER and a decimal literal to DECIMAL, and money is exact (#2401).\n */\n priceAmount: number = 0;\n currency: string = 'USD';\n billingInterval: BillingInterval = 'month';\n pricingMode: PricingMode = 'fixed';\n minimumAmount: number = 0;\n suggestedAmount: number = 0;\n /** Optional provider binding; plan semantics never require a provider. */\n externalProvider: string | null = null;\n providerProductId: string | null = null;\n providerPriceId: string | null = null;\n /** @deprecated Use providerProductId. */\n stripeProductId: string | null = null;\n /** @deprecated Use providerPriceId. */\n stripePriceId: string | null = null;\n features: string = '[]';\n thresholds: string = '[]';\n metadata: string = '{}';\n\n constructor(options: SubscriptionPlanOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.planKey !== undefined) this.planKey = options.planKey;\n if (options.name !== undefined) this.name = options.name;\n if (options.description !== undefined)\n this.description = options.description;\n if (options.status !== undefined) this.status = options.status;\n if (options.sortOrder !== undefined) this.sortOrder = options.sortOrder;\n if (options.priceAmount !== undefined)\n this.priceAmount = options.priceAmount;\n if (options.currency !== undefined) this.currency = options.currency;\n if (options.billingInterval !== undefined) {\n this.billingInterval = options.billingInterval;\n }\n if (options.externalProvider !== undefined) {\n this.externalProvider = options.externalProvider;\n }\n if (options.pricingMode !== undefined)\n this.pricingMode = options.pricingMode;\n if (options.minimumAmount !== undefined)\n this.minimumAmount = options.minimumAmount;\n if (options.suggestedAmount !== undefined)\n this.suggestedAmount = options.suggestedAmount;\n if (options.providerProductId !== undefined) {\n this.providerProductId = options.providerProductId;\n }\n if (options.providerPriceId !== undefined) {\n this.providerPriceId = options.providerPriceId;\n }\n if (options.stripeProductId !== undefined) {\n this.stripeProductId = options.stripeProductId;\n }\n if (options.stripePriceId !== undefined) {\n this.stripePriceId = options.stripePriceId;\n }\n if (options.features !== undefined) this.features = options.features;\n if (options.thresholds !== undefined) this.thresholds = options.thresholds;\n if (options.metadata !== undefined) this.metadata = options.metadata;\n }\n\n isActive(): boolean {\n return this.status === 'active';\n }\n\n /** The versioned terms currently on this row (the latest published). */\n getTerms(): PlanTerms {\n return extractPlanTerms(this);\n }\n\n /**\n * Persist this plan. When its terms (status, price, pricing configuration,\n * feature grants or thresholds) differ from the latest published version —\n * or no version exists yet — the save then publishes the next immutable\n * {@link SubscriptionPlanVersion}, effective now (#4032). Use\n * {@link publishTerms} to choose a different effective date. Subscribers\n * keep the version pinned for their current period; the new terms apply\n * from their next renewal.\n *\n * A plan saved before versioning existed first records its pre-edit terms\n * as version 1, effective from its creation, so subscriptions sold under\n * them are not re-priced.\n *\n * The row is written before the version. If publishing fails the save\n * rejects with the row already updated; saving again publishes the\n * missing version. Wrap the call in `plan.withTransaction()` when the\n * adapter supports transactions and the two writes must be atomic.\n */\n override async save(options?: SmrtSaveOptions): Promise<this> {\n const effectiveFrom = requestedEffectiveFrom.get(this) ?? new Date();\n requestedEffectiveFrom.delete(this);\n await super.save(options);\n const { publishPlanVersion } = await import('../plan-versioning.js');\n const prior = priorTerms.get(this);\n priorTerms.delete(this);\n publishedVersions.set(\n this,\n await publishPlanVersion(this, effectiveFrom, prior),\n );\n return this;\n }\n\n /**\n * Save this plan and publish its current terms as a version effective at\n * `effectiveFrom` (default now). A future date schedules an amendment: the\n * plan row shows the new terms immediately, but subscriptions starting or\n * renewing before that date pin the earlier version. Versions are\n * monotonic, so `effectiveFrom` may not precede the latest version's.\n *\n * @returns the latest version, which carries the saved terms.\n */\n async publishTerms(\n options: { effectiveFrom?: Date } = {},\n ): Promise<SubscriptionPlanVersion> {\n if (options.effectiveFrom) {\n requestedEffectiveFrom.set(this, options.effectiveFrom);\n }\n await this.save();\n const version = publishedVersions.get(this);\n if (!version) throw new Error('Plan version was not published');\n return version;\n }\n\n /** The version of this plan in force at `at` (default now). */\n async getVersionInForce(\n at = new Date(),\n ): Promise<SubscriptionPlanVersion | null> {\n if (!this.id) return null;\n const { SubscriptionPlanVersionCollection } = await import(\n '../collections/SubscriptionPlanVersionCollection.js'\n );\n const versions = await SubscriptionPlanVersionCollection.create({\n db: this.db,\n });\n return versions.findInForce(String(this.id), at);\n }\n\n getFeatureGrants(): PlanFeatureGrant[] {\n return normalizeFeatureGrants(\n parseJsonArray<string | PlanFeatureGrant>(this.features),\n );\n }\n\n setFeatureGrants(grants: Array<string | PlanFeatureGrant>): void {\n this.features = stringifyJson(normalizeFeatureGrants(grants));\n }\n\n getFeatureKeys(): string[] {\n return this.getFeatureGrants()\n .filter((grant) => grant.enabled !== false)\n .map((grant) => grant.featureKey);\n }\n\n getThresholds(): PlanThreshold[] {\n return parseJsonArray<PlanThreshold>(this.thresholds).filter(\n (threshold) => threshold.metricKey,\n );\n }\n\n setThresholds(thresholds: PlanThreshold[]): void {\n this.thresholds = stringifyJson(thresholds);\n }\n\n getMetadata(): JsonObject {\n return parseJsonObject(this.metadata, {});\n }\n\n setMetadata(metadata: JsonObject): void {\n this.metadata = stringifyJson(metadata);\n }\n\n protected async validateBeforeSave(): Promise<void> {\n await super.validateBeforeSave();\n const { SubscriptionPlanCollection } = await import(\n '../collections/SubscriptionPlanCollection.js'\n );\n const plans = await SubscriptionPlanCollection.create({ db: this.db });\n const persisted =\n (this.id\n ? await plans.get({ id: String(this.id) }, { cache: false })\n : null) ??\n (await plans.findByPlanKey(\n this.tenantId ?? getTenantId() ?? null,\n this.planKey,\n ));\n // A concurrently created natural-key row must never be adopted after this\n // validation: its pricing terms may differ from the terms checked here.\n if (!persisted) this.requireInsertOnSave();\n else priorTerms.set(this, persisted);\n if (\n persisted &&\n (persisted.pricingMode !== this.pricingMode ||\n persisted.minimumAmount !== this.minimumAmount)\n ) {\n throw new Error(\n 'Create a new plan to change pricing mode or minimum after persistence',\n );\n }\n if (!['free', 'fixed', 'pay_what_you_can'].includes(this.pricingMode)) {\n throw new Error('Invalid pricingMode');\n }\n if (this.pricingMode !== 'fixed' && this.priceAmount !== 0) {\n throw new Error(\n 'priceAmount must be zero for free and pay_what_you_can plans',\n );\n }\n for (const [name, value] of [\n ['priceAmount', this.priceAmount],\n ['minimumAmount', this.minimumAmount],\n ['suggestedAmount', this.suggestedAmount],\n ] as const) {\n if (!Number.isSafeInteger(value) || value < 0) {\n throw new Error(\n `${name} must be a non-negative integer minor-unit amount`,\n );\n }\n }\n if (\n this.pricingMode === 'pay_what_you_can' &&\n this.suggestedAmount > 0 &&\n this.suggestedAmount < this.minimumAmount\n ) {\n throw new Error('suggestedAmount must be at least minimumAmount');\n }\n }\n}\n\nexport default SubscriptionPlan;\n","import { SmrtCollection } from '@happyvertical/smrt-core';\nimport { SubscriptionPlan } from '../models/SubscriptionPlan.js';\n\nexport class SubscriptionPlanCollection extends SmrtCollection<SubscriptionPlan> {\n static readonly _itemClass = SubscriptionPlan;\n\n async findByPlanKey(\n tenantId: string | null,\n planKey: string,\n ): Promise<SubscriptionPlan | null> {\n const plans = await this.list({ where: { tenantId, planKey }, limit: 1 });\n return plans[0] ?? null;\n }\n\n async findActive(): Promise<SubscriptionPlan[]> {\n return this.list({\n where: { status: 'active' },\n orderBy: 'sortOrder ASC',\n });\n }\n\n async findByProviderPriceId(\n providerPriceId: string,\n ): Promise<SubscriptionPlan | null> {\n const plans = await this.list({ where: { providerPriceId }, limit: 1 });\n if (plans[0]) return plans[0];\n const legacy = await this.list({\n where: { stripePriceId: providerPriceId },\n });\n return (\n legacy.find(\n (plan) => !plan.externalProvider || plan.externalProvider === 'stripe',\n ) ?? null\n );\n }\n\n /** @deprecated Provider-neutral integrations should use findByProviderPriceId. */\n async findByStripePriceId(\n stripePriceId: string,\n ): Promise<SubscriptionPlan | null> {\n const plans = await this.list({ where: { stripePriceId }, limit: 1 });\n return plans[0] ?? null;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import { f as normalizeFeatureGrants, m as parseJsonArray, n as enabledFeatureKeys, o as validThresholds, r as extractPlanTerms } from "./plan-terms-BoeXvDhs.js";
|
|
3
|
+
import { SmrtCollection, SmrtObject, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
4
|
+
import { TenantScoped, getTenantId, isSystemContext, tenantId, withSystemContext } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
//#region src/models/SubscriptionPlanVersion.ts
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
value
|
|
13
|
+
}) : obj[key] = value;
|
|
14
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
15
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
16
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
17
|
+
if (kind && result) __defProp(target, key, result);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
21
|
+
var PRICING_MODES = [
|
|
22
|
+
"free",
|
|
23
|
+
"fixed",
|
|
24
|
+
"pay_what_you_can"
|
|
25
|
+
];
|
|
26
|
+
var SubscriptionPlanVersion = class extends SmrtObject {
|
|
27
|
+
tenantId = null;
|
|
28
|
+
planId = "";
|
|
29
|
+
/** The plan's natural key, copied for auditing and resolution output. */
|
|
30
|
+
planKey = "";
|
|
31
|
+
/** 1-based sequence within the plan; contiguous and monotonic. */
|
|
32
|
+
version = 0;
|
|
33
|
+
/** Start of this version's effective interval. */
|
|
34
|
+
effectiveFrom = /* @__PURE__ */ new Date();
|
|
35
|
+
/** Plan name when this version was published (display only). */
|
|
36
|
+
name = "";
|
|
37
|
+
status = "active";
|
|
38
|
+
/** Integer minor units of {@link currency}. */
|
|
39
|
+
priceAmount = 0;
|
|
40
|
+
currency = "USD";
|
|
41
|
+
billingInterval = "month";
|
|
42
|
+
pricingMode = "fixed";
|
|
43
|
+
/** Integer minor units. */
|
|
44
|
+
minimumAmount = 0;
|
|
45
|
+
/** Integer minor units. */
|
|
46
|
+
suggestedAmount = 0;
|
|
47
|
+
features = "[]";
|
|
48
|
+
thresholds = "[]";
|
|
49
|
+
constructor(options = {}) {
|
|
50
|
+
super(options);
|
|
51
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
52
|
+
if (options.planId !== void 0) this.planId = options.planId;
|
|
53
|
+
if (options.planKey !== void 0) this.planKey = options.planKey;
|
|
54
|
+
if (options.version !== void 0) this.version = options.version;
|
|
55
|
+
if (options.effectiveFrom !== void 0) this.effectiveFrom = options.effectiveFrom;
|
|
56
|
+
if (options.name !== void 0) this.name = options.name;
|
|
57
|
+
if (options.status !== void 0) this.status = options.status;
|
|
58
|
+
if (options.priceAmount !== void 0) this.priceAmount = options.priceAmount;
|
|
59
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
60
|
+
if (options.billingInterval !== void 0) this.billingInterval = options.billingInterval;
|
|
61
|
+
if (options.pricingMode !== void 0) this.pricingMode = options.pricingMode;
|
|
62
|
+
if (options.minimumAmount !== void 0) this.minimumAmount = options.minimumAmount;
|
|
63
|
+
if (options.suggestedAmount !== void 0) this.suggestedAmount = options.suggestedAmount;
|
|
64
|
+
if (options.features !== void 0) this.features = options.features;
|
|
65
|
+
if (options.thresholds !== void 0) this.thresholds = options.thresholds;
|
|
66
|
+
}
|
|
67
|
+
isActive() {
|
|
68
|
+
return this.status === "active";
|
|
69
|
+
}
|
|
70
|
+
getTerms() {
|
|
71
|
+
return extractPlanTerms(this);
|
|
72
|
+
}
|
|
73
|
+
getFeatureGrants() {
|
|
74
|
+
return normalizeFeatureGrants(parseJsonArray(this.features));
|
|
75
|
+
}
|
|
76
|
+
getFeatureKeys() {
|
|
77
|
+
return enabledFeatureKeys(this);
|
|
78
|
+
}
|
|
79
|
+
getThresholds() {
|
|
80
|
+
return validThresholds(this);
|
|
81
|
+
}
|
|
82
|
+
/** Append-only: a version is corrected by publishing the next one. */
|
|
83
|
+
refuseDelete() {
|
|
84
|
+
throw new Error("Subscription plan versions are append-only; publish a new version instead of deleting.");
|
|
85
|
+
}
|
|
86
|
+
async validateBeforeSave() {
|
|
87
|
+
await super.validateBeforeSave();
|
|
88
|
+
if (this.isPersisted) throw new Error("Subscription plan versions are immutable; publish a new version instead.");
|
|
89
|
+
this.requireInsertOnSave();
|
|
90
|
+
if (!Number.isSafeInteger(this.version) || this.version < 1) throw new Error("Plan version must be a positive integer");
|
|
91
|
+
if (!(this.effectiveFrom instanceof Date) || Number.isNaN(this.effectiveFrom.getTime())) throw new Error("Plan version effectiveFrom must be a valid date");
|
|
92
|
+
if (!PRICING_MODES.includes(this.pricingMode)) throw new Error("Invalid pricingMode");
|
|
93
|
+
for (const [name, value] of [
|
|
94
|
+
["priceAmount", this.priceAmount],
|
|
95
|
+
["minimumAmount", this.minimumAmount],
|
|
96
|
+
["suggestedAmount", this.suggestedAmount]
|
|
97
|
+
]) if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer minor-unit amount`);
|
|
98
|
+
const { SubscriptionPlanCollection } = await import("./SubscriptionPlanCollection-CqlYYtPg.js").then((n) => n.n);
|
|
99
|
+
const { SubscriptionPlanVersionCollection } = await Promise.resolve().then(() => SubscriptionPlanVersionCollection_exports);
|
|
100
|
+
const owner = this.tenantId ?? null;
|
|
101
|
+
const [plan, latest] = await withSystemContext(async () => {
|
|
102
|
+
const plans = await SubscriptionPlanCollection.create({ db: this.db });
|
|
103
|
+
const versions = await SubscriptionPlanVersionCollection.create({ db: this.db });
|
|
104
|
+
return Promise.all([plans.get({
|
|
105
|
+
id: this.planId,
|
|
106
|
+
tenantId: owner
|
|
107
|
+
}, { cache: false }), versions.findLatest(this.planId, owner)]);
|
|
108
|
+
});
|
|
109
|
+
if (!plan || plan.planKey !== this.planKey) throw new Error("Plan version must belong to an existing plan");
|
|
110
|
+
const expected = (latest?.version ?? 0) + 1;
|
|
111
|
+
if (this.version !== expected) throw new Error(`Next version of plan ${plan.planKey} is ${expected}`);
|
|
112
|
+
if (latest && this.effectiveFrom.getTime() < latest.effectiveFrom.getTime()) throw new Error(`Plan ${plan.planKey} version ${latest.version} is effective from ${latest.effectiveFrom.toISOString()}; a later version cannot start earlier`);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
__publicField(SubscriptionPlanVersion, "__smrtPackage__", "@happyvertical/smrt-subscriptions");
|
|
116
|
+
__decorateClass([tenantId({ nullable: true })], SubscriptionPlanVersion.prototype, "tenantId", 2);
|
|
117
|
+
__decorateClass([foreignKey("SubscriptionPlan")], SubscriptionPlanVersion.prototype, "planId", 2);
|
|
118
|
+
SubscriptionPlanVersion = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
119
|
+
tableName: "_smrt_subscription_plan_versions",
|
|
120
|
+
api: { include: ["list", "get"] },
|
|
121
|
+
cli: { include: ["list", "get"] },
|
|
122
|
+
mcp: { include: ["list", "get"] },
|
|
123
|
+
conflictColumns: ["plan_id", "version"],
|
|
124
|
+
hooks: { beforeDelete: "refuseDelete" }
|
|
125
|
+
})], SubscriptionPlanVersion);
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/collections/SubscriptionPlanVersionCollection.ts
|
|
128
|
+
var SubscriptionPlanVersionCollection_exports = /* @__PURE__ */ __exportAll({ SubscriptionPlanVersionCollection: () => SubscriptionPlanVersionCollection });
|
|
129
|
+
var SubscriptionPlanVersionCollection = class extends SmrtCollection {
|
|
130
|
+
static _itemClass = SubscriptionPlanVersion;
|
|
131
|
+
/**
|
|
132
|
+
* One version by id, through the caller's tenancy plus the bounded
|
|
133
|
+
* fallback to legacy global plans' versions.
|
|
134
|
+
*/
|
|
135
|
+
async findVisibleById(id) {
|
|
136
|
+
return (await this.withGlobalFallback((where) => this.list({
|
|
137
|
+
where: {
|
|
138
|
+
...where,
|
|
139
|
+
id
|
|
140
|
+
},
|
|
141
|
+
limit: 1
|
|
142
|
+
})))[0] ?? null;
|
|
143
|
+
}
|
|
144
|
+
/** Every version of a plan, oldest first. */
|
|
145
|
+
async listForPlan(planId) {
|
|
146
|
+
return this.withGlobalFallback((where) => this.list({
|
|
147
|
+
where: {
|
|
148
|
+
...where,
|
|
149
|
+
planId
|
|
150
|
+
},
|
|
151
|
+
orderBy: "version ASC"
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The most recently published version of a plan. Pass `tenantId` to name
|
|
156
|
+
* the owner explicitly (`null` for a global plan); omit it to read through
|
|
157
|
+
* the caller's tenancy.
|
|
158
|
+
*/
|
|
159
|
+
async findLatest(planId, tenantId) {
|
|
160
|
+
const read = (where) => this.list({
|
|
161
|
+
where: {
|
|
162
|
+
...where,
|
|
163
|
+
planId
|
|
164
|
+
},
|
|
165
|
+
orderBy: "version DESC",
|
|
166
|
+
limit: 1
|
|
167
|
+
});
|
|
168
|
+
return (tenantId === void 0 ? await this.withGlobalFallback(read) : await read({ tenantId }))[0] ?? null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The version of a plan in force at `at`: the latest version whose
|
|
172
|
+
* `effectiveFrom` is at or before `at`. An instant before the first
|
|
173
|
+
* version resolves to the first version, so subscriptions that predate
|
|
174
|
+
* versioning keep the terms they were sold. Returns null only when the plan
|
|
175
|
+
* has no versions.
|
|
176
|
+
*/
|
|
177
|
+
async findInForce(planId, at) {
|
|
178
|
+
const versions = await this.listForPlan(planId);
|
|
179
|
+
const time = at.getTime();
|
|
180
|
+
let inForce = versions[0] ?? null;
|
|
181
|
+
for (const version of versions) if (version.effectiveFrom.getTime() <= time) inForce = version;
|
|
182
|
+
return inForce;
|
|
183
|
+
}
|
|
184
|
+
async withGlobalFallback(read) {
|
|
185
|
+
const scoped = await read({});
|
|
186
|
+
if (scoped.length > 0 || isSystemContext() || !getTenantId()) return scoped;
|
|
187
|
+
return withSystemContext(() => read({ tenantId: null }));
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
//#endregion
|
|
191
|
+
export { SubscriptionPlanVersionCollection_exports as n, SubscriptionPlanVersion as r, SubscriptionPlanVersionCollection as t };
|
|
192
|
+
|
|
193
|
+
//# sourceMappingURL=SubscriptionPlanVersionCollection-CV0QPmp0.js.map
|