@happyvertical/smrt-subscriptions 0.57.8 → 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.
Files changed (42) hide show
  1. package/AGENTS.md +21 -0
  2. package/README.md +112 -3
  3. package/dist/chunks/SubscriptionPlanCollection-CqlYYtPg.js +229 -0
  4. package/dist/chunks/SubscriptionPlanCollection-CqlYYtPg.js.map +1 -0
  5. package/dist/chunks/SubscriptionPlanVersionCollection-CV0QPmp0.js +193 -0
  6. package/dist/chunks/SubscriptionPlanVersionCollection-CV0QPmp0.js.map +1 -0
  7. package/dist/chunks/plan-terms-BoeXvDhs.js +219 -0
  8. package/dist/chunks/plan-terms-BoeXvDhs.js.map +1 -0
  9. package/dist/chunks/plan-versioning-Bp0NqTJf.js +142 -0
  10. package/dist/chunks/plan-versioning-Bp0NqTJf.js.map +1 -0
  11. package/dist/chunks/rolldown-runtime-D7D4PA-g.js +13 -0
  12. package/dist/collections/SubscriptionPlanVersionCollection.d.ts +33 -0
  13. package/dist/collections/SubscriptionPlanVersionCollection.d.ts.map +1 -0
  14. package/dist/collections/index.d.ts +1 -0
  15. package/dist/collections/index.d.ts.map +1 -1
  16. package/dist/index.d.ts +7 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +181 -349
  19. package/dist/index.js.map +1 -1
  20. package/dist/manifest.json +1114 -328
  21. package/dist/migrations/planVersions.d.ts +16 -0
  22. package/dist/migrations/planVersions.d.ts.map +1 -0
  23. package/dist/models/SubscriptionPlan.d.ts +39 -3
  24. package/dist/models/SubscriptionPlan.d.ts.map +1 -1
  25. package/dist/models/SubscriptionPlanVersion.d.ts +51 -0
  26. package/dist/models/SubscriptionPlanVersion.d.ts.map +1 -0
  27. package/dist/models/TenantSubscription.d.ts +49 -1
  28. package/dist/models/TenantSubscription.d.ts.map +1 -1
  29. package/dist/models/index.d.ts +1 -0
  30. package/dist/models/index.d.ts.map +1 -1
  31. package/dist/plan-terms.d.ts +26 -0
  32. package/dist/plan-terms.d.ts.map +1 -0
  33. package/dist/plan-versioning.d.ts +45 -0
  34. package/dist/plan-versioning.d.ts.map +1 -0
  35. package/dist/services/index.d.ts +1 -1
  36. package/dist/services/index.d.ts.map +1 -1
  37. package/dist/services/subscription-resolver.d.ts +26 -0
  38. package/dist/services/subscription-resolver.d.ts.map +1 -1
  39. package/dist/smrt-knowledge.json +547 -7
  40. package/dist/types.d.ts +72 -0
  41. package/dist/types.d.ts.map +1 -1
  42. 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 (effective-dated transitions are tracked in #4032).
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 remain separate work in #4032; this
91
- foundation does not provide immutable period snapshots. Concurrent first creates
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