@happyvertical/smrt-subscriptions 0.60.13 → 0.60.15

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.
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-subscriptions",
5
- "packageVersion": "0.60.13",
5
+ "packageVersion": "0.60.15",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-subscriptions:SubscriptionPlanCollection": {
8
8
  "name": "subscriptionplancollection",
@@ -6901,6 +6901,7 @@
6901
6901
  ],
6902
6902
  "smrtDependencies": [
6903
6903
  "@happyvertical/smrt-core",
6904
+ "@happyvertical/smrt-features",
6904
6905
  "@happyvertical/smrt-tenancy"
6905
6906
  ],
6906
6907
  "browser": {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Entitlement change hook (#4759).
3
+ *
4
+ * A plan change arrives through the host's billing webhook (Stripe in
5
+ * smrt-saas-starter, #4667): the host writes the new `TenantSubscription`
6
+ * state. Code that must react to a feature key being gained or lost (hosted
7
+ * inference provisioning, #4759) registers a listener here; the host wraps
8
+ * its webhook write in {@link trackEntitlementChange}, which snapshots the
9
+ * tenant's feature grants before and after and notifies listeners with the
10
+ * difference.
11
+ *
12
+ * Listeners run after the write has been applied (and committed, when the
13
+ * host's write commits inside `apply`). A listener failure never undoes the
14
+ * write: it is collected in the outcome's `failures` so the host can log it,
15
+ * alert, or retry the reaction (listeners are expected to be idempotent;
16
+ * hosted provisioning is, through `runOnce()`). This package stays free of
17
+ * any listener's dependencies: listeners satisfy the structural
18
+ * {@link EntitlementChangeListener} type.
19
+ */
20
+ import type { PlanFeatureGrant } from '../types.js';
21
+ import type { SubscriptionResolver } from './subscription-resolver.js';
22
+ /** A tenant's entitled grants at one moment. */
23
+ export interface EntitlementSnapshot {
24
+ planId: string | null;
25
+ planKey: string | null;
26
+ /** Enabled feature keys, sorted. */
27
+ featureKeys: string[];
28
+ /** Enabled grants (with their `metadata`), sorted by key. */
29
+ grants: PlanFeatureGrant[];
30
+ }
31
+ /** The difference between two snapshots of one tenant. */
32
+ export interface EntitlementChange {
33
+ tenantId: string;
34
+ /** Keys granted after but not before. */
35
+ gained: string[];
36
+ /** Keys granted before but not after. */
37
+ lost: string[];
38
+ /**
39
+ * Keys granted both before and after whose grant `metadata` changed (a
40
+ * plan's budget for `inference.hosted`, for instance).
41
+ */
42
+ changed: string[];
43
+ before: EntitlementSnapshot;
44
+ after: EntitlementSnapshot;
45
+ }
46
+ /** Reacts to an {@link EntitlementChange}; may be async. */
47
+ export type EntitlementChangeListener = (change: EntitlementChange) => void | Promise<void>;
48
+ /** One listener that threw. */
49
+ export interface EntitlementChangeFailure {
50
+ error: unknown;
51
+ }
52
+ /** What {@link notifyEntitlementChange} reports. */
53
+ export interface EntitlementChangeOutcome {
54
+ change: EntitlementChange;
55
+ /** Listeners that threw, in registration order. Empty when all succeeded. */
56
+ failures: EntitlementChangeFailure[];
57
+ }
58
+ /** `Symbol.for()` key of the process-wide listener set. */
59
+ export declare const ENTITLEMENT_CHANGE_LISTENERS_SLOT_KEY = "@happyvertical/smrt-subscriptions:entitlement-change-listeners";
60
+ /**
61
+ * Register a process-wide listener. Returns a function that unregisters it.
62
+ * Registering the same function twice keeps one registration.
63
+ */
64
+ export declare function registerEntitlementChangeListener(listener: EntitlementChangeListener): () => void;
65
+ /** The registered listeners, in registration order. */
66
+ export declare function getEntitlementChangeListeners(): EntitlementChangeListener[];
67
+ /** An empty snapshot: no entitled subscription. */
68
+ export declare function emptyEntitlementSnapshot(): EntitlementSnapshot;
69
+ /**
70
+ * Snapshot the tenant's entitled grants through `resolver` (no usage
71
+ * meters are read). Missing subscription tables read as no plan, like
72
+ * `SubscriptionFeatureSource`; every other error propagates.
73
+ */
74
+ export declare function snapshotEntitlements(resolver: Pick<SubscriptionResolver, 'resolveFeatureGrants'>, tenantId: string, options?: {
75
+ now?: Date;
76
+ }): Promise<EntitlementSnapshot>;
77
+ /** Compute the {@link EntitlementChange} between two snapshots. */
78
+ export declare function diffEntitlements(tenantId: string, before: EntitlementSnapshot, after: EntitlementSnapshot): EntitlementChange;
79
+ /** True when the change gains, loses or changes at least one key. */
80
+ export declare function hasEntitlementChange(change: EntitlementChange): boolean;
81
+ /**
82
+ * Notify `listeners` (default: the registered ones) of `change`, one after
83
+ * another. A change with nothing gained, lost or changed notifies nobody.
84
+ * Never throws for a listener: failures are returned.
85
+ */
86
+ export declare function notifyEntitlementChange(change: EntitlementChange, options?: {
87
+ listeners?: readonly EntitlementChangeListener[];
88
+ }): Promise<EntitlementChangeOutcome>;
89
+ /** Options for {@link trackEntitlementChange}. */
90
+ export interface TrackEntitlementChangeOptions {
91
+ tenantId: string;
92
+ /** Resolver used for both snapshots (build one per webhook delivery). */
93
+ resolver: Pick<SubscriptionResolver, 'resolveFeatureGrants'>;
94
+ /** Listeners to notify instead of the registered ones. */
95
+ listeners?: readonly EntitlementChangeListener[];
96
+ /** Clock for both snapshots; defaults to the current time at each. */
97
+ now?: () => Date;
98
+ }
99
+ /**
100
+ * Snapshot the tenant's grants, run `apply` (the webhook's subscription
101
+ * write), snapshot again, and notify listeners of the difference. `apply`'s
102
+ * error propagates and notifies nobody. Listener failures are returned in
103
+ * `failures`, never thrown.
104
+ *
105
+ * ```ts
106
+ * const { failures } = await trackEntitlementChange(
107
+ * { tenantId, resolver: await SubscriptionResolver.create({ db }) },
108
+ * () => applyStripeSubscriptionUpdate(event),
109
+ * );
110
+ * ```
111
+ */
112
+ export declare function trackEntitlementChange<T>(options: TrackEntitlementChangeOptions, apply: () => Promise<T>): Promise<EntitlementChangeOutcome & {
113
+ result: T;
114
+ }>;
115
+ //# sourceMappingURL=entitlement-changes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entitlement-changes.d.ts","sourceRoot":"","sources":["../../src/services/entitlement-changes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Hosted AI spend policy (#4760, epic #4667).
3
+ *
4
+ * A plan caps platform-paid (`source: hosted`) inference with ordinary
5
+ * `PlanThreshold`s on two reserved metric keys, so the window, amount and
6
+ * enforcement come from the plan (and its pinned version) like every other
7
+ * threshold:
8
+ *
9
+ * - {@link AI_HOSTED_SPEND_METRIC}: the tenant's hosted spend per window.
10
+ * - {@link AI_HOSTED_USER_SPEND_METRIC}: each user's hosted spend per window
11
+ * (the per-user cap within the tenant's plan).
12
+ *
13
+ * `limit` is **integer micro-dollars** (1 USD = 1,000,000): the repo's
14
+ * integer-minor-unit money rule at the precision per-call AI costs need.
15
+ * `enforcement: 'block'` blocks at or over the cap; `'warn'` only warns;
16
+ * `'observe'` never affects a call. `warningRatio` (default 0.8) warns
17
+ * before the cap. A cap of 0 blocks every hosted call.
18
+ *
19
+ * Spend is read from `_smrt_ai_usage.estimated_cost` (USD, summed and
20
+ * rounded to micro-dollars) for rows of the tenant in the threshold's
21
+ * window whose `tags` record `source: hosted` (and, for the per-user cap,
22
+ * the `userId` tag), matched as case-sensitive exact JSON fragments. Blocked rows (`operation: 'blocked'`) and
23
+ * external-agent measurements never count. Rows without a cost rate add
24
+ * nothing; give hosted models a cost rate.
25
+ *
26
+ * These keys are evaluated by {@link HostedAiSpendEvaluator} only:
27
+ * `resolveEntitlements()` reports them with quantity 0 (the tenant usage
28
+ * meter has no hosted or per-user meter).
29
+ */
30
+ import type { DatabaseInterface } from '@happyvertical/sql';
31
+ import type { PlanThreshold, SmrtClassOptions, SubscriptionResolverOptions, UsageWindow } from '../types.js';
32
+ import { SubscriptionResolver } from './subscription-resolver.js';
33
+ /** Plan threshold key: the tenant's hosted AI spend, micro-dollars. */
34
+ export declare const AI_HOSTED_SPEND_METRIC = "ai.hosted.spend_micros";
35
+ /** Plan threshold key: each user's hosted AI spend, micro-dollars. */
36
+ export declare const AI_HOSTED_USER_SPEND_METRIC = "ai.hosted.user_spend_micros";
37
+ /** Micro-dollars per US dollar. */
38
+ export declare const MICROS_PER_USD = 1000000;
39
+ /** The cap a decision names: tenant or per-user. */
40
+ export type HostedAiSpendPolicy = 'tenant-cap' | 'user-cap';
41
+ /** One evaluated hosted spend threshold. */
42
+ export interface HostedAiSpendEvaluation {
43
+ policy: HostedAiSpendPolicy;
44
+ threshold: PlanThreshold;
45
+ /** The cap, integer micro-dollars. */
46
+ cap: number;
47
+ /** Metered hosted spend in the window, integer micro-dollars. */
48
+ spend: number;
49
+ window: UsageWindow;
50
+ /**
51
+ * When the cap lifts: the calendar window's end; for a blocked `rolling`
52
+ * cap, the moment enough counted spend has left the 30-day window to bring
53
+ * the total below the cap (null when no expiry can, e.g. a 0 cap, or the
54
+ * cap is not blocked).
55
+ */
56
+ resetsAt: Date | null;
57
+ state: 'ok' | 'warn' | 'blocked';
58
+ }
59
+ /** The most restrictive outcome over every hosted spend threshold. */
60
+ export interface HostedAiSpendDecision {
61
+ state: 'ok' | 'warn' | 'blocked';
62
+ /** False when the tenant has no current entitled subscription. */
63
+ entitled: boolean;
64
+ planId: string | null;
65
+ planKey: string | null;
66
+ /**
67
+ * The deciding evaluation: for `blocked`, the blocking cap that lifts
68
+ * last (the call runs again only when every blocking cap has lifted);
69
+ * for `warn`, the warning cap closest to its limit.
70
+ */
71
+ decidedBy: HostedAiSpendEvaluation | null;
72
+ evaluations: HostedAiSpendEvaluation[];
73
+ }
74
+ /** Hosted spend totals for one window. */
75
+ export interface HostedAiSpendSummary {
76
+ /** Integer micro-dollars. */
77
+ spend: number;
78
+ }
79
+ /** The scope of one spend read. */
80
+ export interface HostedAiSpendScope {
81
+ tenantId: string;
82
+ /** Only this user's rows (the `userId` tag); omitted: the whole tenant. */
83
+ userId?: string;
84
+ window: UsageWindow;
85
+ }
86
+ /** Reads hosted spend; the default reads `_smrt_ai_usage`. */
87
+ export interface HostedAiSpendReader {
88
+ summarize(options: HostedAiSpendScope): Promise<HostedAiSpendSummary>;
89
+ /**
90
+ * For a rolling window: the earliest moment the counted rows, expiring
91
+ * oldest first after `windowMs`, bring the scope's spend below `cap`;
92
+ * null when no expiry can. Optional: without it a blocked rolling cap
93
+ * reports no reset time.
94
+ */
95
+ liftsAt?(options: HostedAiSpendScope & {
96
+ cap: number;
97
+ windowMs: number;
98
+ }): Promise<Date | null>;
99
+ }
100
+ /** A plan cap the gate cannot evaluate: the caller must fail closed. */
101
+ export declare class HostedAiSpendPolicyError extends Error {
102
+ constructor(message: string);
103
+ }
104
+ /**
105
+ * The exact fragment one tag leaves in `_smrt_ai_usage.tags` as
106
+ * `JSON.stringify` writes it (core's persistence handler): `"key":"value"`.
107
+ * The value is JSON-encoded, so a quote inside it cannot end the match
108
+ * early, and the closing quote anchors it, so `u1` never matches `u10`.
109
+ */
110
+ export declare function hostedAiTagFragment(key: string, value: string): string;
111
+ /**
112
+ * The default {@link HostedAiSpendReader}: one aggregate query over
113
+ * `_smrt_ai_usage` by `tenant_id` and `created_at` (served by
114
+ * `idx_smrt_ai_usage_tenant_created`), filtered on the `source` and
115
+ * `userId` tag fragments (`strpos` on PostgreSQL, `instr` on SQLite and
116
+ * DuckDB). A fragment can only over-count (a row whose tags contain it
117
+ * elsewhere), never miss a row core wrote, so the error is always toward
118
+ * the cap.
119
+ */
120
+ export declare function createHostedAiSpendReader(db: DatabaseInterface): HostedAiSpendReader;
121
+ /**
122
+ * The hosted spend thresholds of a plan. A hosted key whose threshold is
123
+ * malformed (unknown window or enforcement, or a limit that is not a
124
+ * non-negative safe integer of micro-dollars) throws
125
+ * {@link HostedAiSpendPolicyError}: a broken cap must never read as "no cap".
126
+ */
127
+ export declare function hostedAiSpendThresholds(thresholds: readonly PlanThreshold[]): Array<{
128
+ policy: HostedAiSpendPolicy;
129
+ threshold: PlanThreshold;
130
+ }>;
131
+ /** Evaluate one cap against its metered spend. */
132
+ export declare function evaluateHostedAiSpendThreshold(policy: HostedAiSpendPolicy, threshold: PlanThreshold, summary: HostedAiSpendSummary, window: UsageWindow): HostedAiSpendEvaluation;
133
+ /** Fold evaluations into the most restrictive decision. */
134
+ export declare function decideHostedAiSpend(evaluations: HostedAiSpendEvaluation[], plan: {
135
+ entitled: boolean;
136
+ planId: string | null;
137
+ planKey: string | null;
138
+ }): HostedAiSpendDecision;
139
+ /** Options for {@link HostedAiSpendEvaluator}. */
140
+ export interface HostedAiSpendEvaluatorOptions {
141
+ resolver: Pick<SubscriptionResolver, 'resolvePlanThresholds'>;
142
+ spend: HostedAiSpendReader;
143
+ /**
144
+ * How long a tenant's plan thresholds are reused, in ms (default 30 000):
145
+ * entitlements resolve once per request burst, while spend is always read
146
+ * fresh. 0 disables the memo.
147
+ */
148
+ termsTtlMs?: number;
149
+ now?: () => Date;
150
+ }
151
+ /**
152
+ * Evaluates a tenant's (and one user's) hosted AI spend against the plan's
153
+ * caps. Plan thresholds come from `SubscriptionResolver` (memoized per
154
+ * tenant for `termsTtlMs`); spend is read on every call. Errors (a resolver
155
+ * or database outage, missing tables, a malformed cap) propagate: the gate
156
+ * turns them into a fail-closed block.
157
+ */
158
+ export declare class HostedAiSpendEvaluator {
159
+ private readonly options;
160
+ private readonly terms;
161
+ private readonly termsTtlMs;
162
+ private readonly now;
163
+ constructor(options: HostedAiSpendEvaluatorOptions);
164
+ /** Build the default resolver and `_smrt_ai_usage` reader over one db. */
165
+ static create(classOptions: SmrtClassOptions & {
166
+ db: DatabaseInterface;
167
+ }, options?: Omit<HostedAiSpendEvaluatorOptions, 'resolver' | 'spend'>): Promise<HostedAiSpendEvaluator>;
168
+ /** Drop memoized plan thresholds (all tenants, or one). */
169
+ invalidate(tenantId?: string): void;
170
+ private planThresholds;
171
+ /**
172
+ * Evaluate the plan's hosted caps for `tenantId` and, when given, the
173
+ * per-user caps for `userId`. Without a `userId` the per-user caps are
174
+ * skipped (no user to meter: jobs, shared agent clients).
175
+ */
176
+ evaluate(input: {
177
+ tenantId: string;
178
+ userId?: string;
179
+ resolverOptions?: SubscriptionResolverOptions;
180
+ }): Promise<HostedAiSpendDecision>;
181
+ }
182
+ //# sourceMappingURL=hosted-ai-spend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hosted-ai-spend.d.ts","sourceRoot":"","sources":["../../src/services/hosted-ai-spend.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,11 @@
1
1
  export type { EntitlementResolutionContext } from '../types.js';
2
2
  export type { AutoTopUpGrant, AutoTopUpHook, AutoTopUpRequest, BillingRelationshipReader, CommercialBillingStorage, CommercialUsageServiceOptions, CustomPricingContext, CustomPricingStrategy, GrantCreditInput, PriceUsageOptions, RateUsageOptions, SpendingDecision, SpendingEvaluationInput, SpendingPolicyEvaluatorExtensions, SpendingPolicyEvaluatorOptions, UsageRating, } from './commercial.js';
3
3
  export { assertCommercialBillingStorageSupported, CommercialBillingStorageConfigurationError, CommercialUsageService, SpendingPolicyEvaluator, UnsupportedCommercialBillingStorageError, } from './commercial.js';
4
+ export { diffEntitlements, ENTITLEMENT_CHANGE_LISTENERS_SLOT_KEY, type EntitlementChange, type EntitlementChangeFailure, type EntitlementChangeListener, type EntitlementChangeOutcome, type EntitlementSnapshot, emptyEntitlementSnapshot, getEntitlementChangeListeners, hasEntitlementChange, notifyEntitlementChange, registerEntitlementChangeListener, snapshotEntitlements, type TrackEntitlementChangeOptions, trackEntitlementChange, } from './entitlement-changes.js';
5
+ export { AI_HOSTED_SPEND_METRIC, AI_HOSTED_USER_SPEND_METRIC, createHostedAiSpendReader, decideHostedAiSpend, evaluateHostedAiSpendThreshold, type HostedAiSpendDecision, type HostedAiSpendEvaluation, HostedAiSpendEvaluator, type HostedAiSpendEvaluatorOptions, type HostedAiSpendPolicy, HostedAiSpendPolicyError, type HostedAiSpendReader, type HostedAiSpendScope, type HostedAiSpendSummary, hostedAiSpendThresholds, hostedAiTagFragment, MICROS_PER_USD, } from './hosted-ai-spend.js';
4
6
  export { type AssignPriceBooksInput, type DefinePriceBookPriceInput, type DelegatedSpendingPolicyInput, type GrantChildCreditInput, type PriceBookAssignmentView, type PriceBookLeg, type ResellerBillingAction, ResellerBillingService, type ResellerBillingServiceOptions, } from './reseller.js';
5
7
  export { ResellerBillingError, type ResellerBillingErrorCode, } from './reseller-errors.js';
8
+ export { FEATURE_GRANT_SOURCE_SLOT_KEY, type GrantingPlanReader, isMissingSubscriptionTableError, registerSubscriptionFeatureSource, type SubscriptionFeatureGrants, SubscriptionFeatureSource, type SubscriptionFeatureSourceOptions, type SubscriptionGrantingPlan, } from './subscription-feature-source.js';
6
9
  export { type SubscriptionPlanReader, type SubscriptionPlanVersionReader, SubscriptionResolver, type SubscriptionResolverReaders, type TenantSubscriptionReader, type UsageSummaryReader, } from './subscription-resolver.js';
7
10
  export { evaluateThreshold, evaluateThresholds, } from './threshold-evaluator.js';
8
11
  export { TenantUsageMeter } from './usage-meter.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/services/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,4BAA4B,EAAE,MAAM,aAAa,CAAC;AAChE,YAAY,EACV,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,yBAAyB,EACzB,wBAAwB,EACxB,6BAA6B,EAC7B,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,uBAAuB,EACvB,iCAAiC,EACjC,8BAA8B,EAC9B,WAAW,GACZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,uCAAuC,EACvC,0CAA0C,EAC1C,sBAAsB,EACtB,uBAAuB,EACvB,wCAAwC,GACzC,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,yBAAyB,EAC9B,KAAK,4BAA4B,EACjC,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,sBAAsB,EACtB,KAAK,6BAA6B,GACnC,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,oBAAoB,EACpB,KAAK,wBAAwB,GAC9B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,oBAAoB,EACpB,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,GACxB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/services/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,4BAA4B,EAAE,MAAM,aAAa,CAAC;AAChE,YAAY,EACV,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,yBAAyB,EACzB,wBAAwB,EACxB,6BAA6B,EAC7B,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,uBAAuB,EACvB,iCAAiC,EACjC,8BAA8B,EAC9B,WAAW,GACZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,uCAAuC,EACvC,0CAA0C,EAC1C,sBAAsB,EACtB,uBAAuB,EACvB,wCAAwC,GACzC,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,gBAAgB,EAChB,qCAAqC,EACrC,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC7B,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EACxB,wBAAwB,EACxB,6BAA6B,EAC7B,oBAAoB,EACpB,uBAAuB,EACvB,iCAAiC,EACjC,oBAAoB,EACpB,KAAK,6BAA6B,EAClC,sBAAsB,GACvB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,yBAAyB,EACzB,mBAAmB,EACnB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,sBAAsB,EACtB,KAAK,6BAA6B,EAClC,KAAK,mBAAmB,EACxB,wBAAwB,EACxB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,uBAAuB,EACvB,mBAAmB,EACnB,cAAc,GACf,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,yBAAyB,EAC9B,KAAK,4BAA4B,EACjC,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,sBAAsB,EACtB,KAAK,6BAA6B,GACnC,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,oBAAoB,EACpB,KAAK,wBAAwB,GAC9B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,6BAA6B,EAC7B,KAAK,kBAAkB,EACvB,+BAA+B,EAC/B,iCAAiC,EACjC,KAAK,yBAAyB,EAC9B,yBAAyB,EACzB,KAAK,gCAAgC,EACrC,KAAK,wBAAwB,GAC9B,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,oBAAoB,EACpB,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,GACxB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Plan feature grants as `smrt-features` flags (#4761).
3
+ *
4
+ * `SubscriptionFeatureSource` implements `smrt-features`' `FeatureGrantSource`
5
+ * structurally, so this package does not depend on `smrt-features` and
6
+ * `smrt-features` never depends on this one. Hosts either pass a per-request
7
+ * source as `grantSource` in the feature resolution context, or register an
8
+ * app-wide one with {@link registerSubscriptionFeatureSource}, which writes the
9
+ * `Symbol.for('@happyvertical/smrt-features:grant-source')` slot that
10
+ * `FeatureResolver` reads.
11
+ *
12
+ * Local installs: when the subscription tables do not exist, or the tenant has
13
+ * no entitled subscription, the source reports no grants and logs nothing, so
14
+ * paid keys stay at their (off) defaults. Every other failure propagates: a
15
+ * database outage must not silently look like "no plan".
16
+ */
17
+ import { type SmrtClassOptions } from '@happyvertical/smrt-core';
18
+ import type { SubscriptionPlan } from '../models/SubscriptionPlan.js';
19
+ import { SubscriptionResolver } from './subscription-resolver.js';
20
+ /** Structural mirror of `smrt-features`' `FeatureGrantSnapshot`. */
21
+ export interface SubscriptionFeatureGrants {
22
+ featureKeys: readonly string[];
23
+ planId: string | null;
24
+ planKey: string | null;
25
+ }
26
+ /** Structural mirror of `smrt-features`' `FeatureGrantingPlan`. */
27
+ export interface SubscriptionGrantingPlan {
28
+ planKey: string;
29
+ planId: string | null;
30
+ name: string | null;
31
+ }
32
+ /** Reader for the plan catalog an upgrade response searches. */
33
+ export interface GrantingPlanReader {
34
+ findActive(): Promise<SubscriptionPlan[]>;
35
+ }
36
+ export interface SubscriptionFeatureSourceOptions {
37
+ /** One resolver for the request (or app lifetime); never built per flag. */
38
+ resolver: SubscriptionResolver;
39
+ /** Plan catalog for `findGrantingPlan()`; omitted means no plan is named. */
40
+ plans?: GrantingPlanReader;
41
+ /**
42
+ * Memoize grants per tenant for this instance's lifetime (default `true`).
43
+ * Keep it on for a request-scoped source; a process-wide source registered
44
+ * with {@link registerSubscriptionFeatureSource} turns it off so a plan
45
+ * change is visible on the next check.
46
+ */
47
+ memoize?: boolean;
48
+ /** Clock for entitlement checks; defaults to the current time. */
49
+ now?: () => Date;
50
+ }
51
+ /** `Symbol.for()` key `smrt-features` reads its global grant source from. */
52
+ export declare const FEATURE_GRANT_SOURCE_SLOT_KEY = "@happyvertical/smrt-features:grant-source";
53
+ export declare class SubscriptionFeatureSource {
54
+ private readonly resolver;
55
+ private readonly plans;
56
+ private readonly memoize;
57
+ private readonly now;
58
+ private readonly grants;
59
+ private catalog;
60
+ constructor(options: SubscriptionFeatureSourceOptions);
61
+ /**
62
+ * Build one source with its own resolver and plan catalog. Call once per
63
+ * request (or once at setup with `memoize: false` for a global source).
64
+ */
65
+ static create(classOptions?: SmrtClassOptions, options?: Omit<SubscriptionFeatureSourceOptions, 'resolver' | 'plans'>): Promise<SubscriptionFeatureSource>;
66
+ /** Feature keys the tenant's entitled plan grants, or `null` for none. */
67
+ getGrants(tenantId: string): Promise<SubscriptionFeatureGrants | null>;
68
+ /**
69
+ * The first active plan (by `sortOrder`) visible in the current context
70
+ * that grants `featureKey`, for an upgrade response.
71
+ */
72
+ findGrantingPlan(featureKey: string, _tenantId: string | null): Promise<SubscriptionGrantingPlan | null>;
73
+ private loadGrants;
74
+ private loadCatalog;
75
+ }
76
+ /**
77
+ * Register `source` as `smrt-features`' process-wide grant source, or clear it
78
+ * with `undefined`. Equivalent to `setFeatureGrantSource()` from
79
+ * `smrt-features`, without importing that package. Register a source built
80
+ * with `memoize: false`; pass request-scoped sources per call instead.
81
+ */
82
+ export declare function registerSubscriptionFeatureSource(source: SubscriptionFeatureSource | undefined): void;
83
+ /**
84
+ * True when `error` reports that one of this package's tables does not exist
85
+ * — core's `DB_SCHEMA_MISSING` or a raw "no such table" / "relation does not
86
+ * exist" driver error naming a subscription table.
87
+ */
88
+ export declare function isMissingSubscriptionTableError(error: unknown): boolean;
89
+ //# sourceMappingURL=subscription-feature-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscription-feature-source.d.ts","sourceRoot":"","sources":["../../src/services/subscription-feature-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAElE,oEAAoE;AACpE,MAAM,WAAW,yBAAyB;IACxC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,mEAAmE;AACnE,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,gEAAgE;AAChE,MAAM,WAAW,kBAAkB;IACjC,UAAU,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,gCAAgC;IAC/C,4EAA4E;IAC5E,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,6EAA6E;IAC7E,KAAK,CAAC,EAAE,kBAAkB,CAAC;IAC3B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,6EAA6E;AAC7E,eAAO,MAAM,6BAA6B,8CACG,CAAC;AAQ9C,qBAAa,yBAAyB;IACpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuB;IAChD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAGnB;IACJ,OAAO,CAAC,OAAO,CAAmD;gBAEtD,OAAO,EAAE,gCAAgC;IAOrD;;;OAGG;WACU,MAAM,CACjB,YAAY,GAAE,gBAAqB,EACnC,OAAO,GAAE,IAAI,CAAC,gCAAgC,EAAE,UAAU,GAAG,OAAO,CAAM,GACzE,OAAO,CAAC,yBAAyB,CAAC;IAQrC,0EAA0E;IAC1E,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,GAAG,IAAI,CAAC;IActE;;;OAGG;IACG,gBAAgB,CACpB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,OAAO,CAAC,wBAAwB,GAAG,IAAI,CAAC;YAe7B,UAAU;IAuBxB,OAAO,CAAC,WAAW;CAyBpB;AAED;;;;;GAKG;AACH,wBAAgB,iCAAiC,CAC/C,MAAM,EAAE,yBAAyB,GAAG,SAAS,GAC5C,IAAI,CAIN;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAiCvE"}
@@ -1,7 +1,7 @@
1
1
  import type { SubscriptionPlan } from '../models/SubscriptionPlan.js';
2
2
  import type { SubscriptionPlanVersion } from '../models/SubscriptionPlanVersion.js';
3
3
  import type { TenantSubscription } from '../models/TenantSubscription.js';
4
- import type { EntitlementResolution, EntitlementResolutionContext, PlanThreshold, SmrtClassOptions, Subscriber, SubscriptionResolverOptions, UsageSummary, UsageWindow } from '../types.js';
4
+ import type { EntitlementResolution, EntitlementResolutionContext, PlanFeatureGrant, PlanThreshold, SmrtClassOptions, Subscriber, SubscriptionResolverOptions, UsageSummary, UsageWindow } from '../types.js';
5
5
  export interface SubscriptionPlanReader {
6
6
  get(criteria: {
7
7
  id: string;
@@ -82,6 +82,39 @@ export declare class SubscriptionResolver {
82
82
  * this once, then pass it back via `options.context`.
83
83
  */
84
84
  loadEntitlementContext(subscriberOrTenantId: Subscriber | string, options?: SubscriptionResolverOptions): Promise<EntitlementResolutionContext>;
85
+ /**
86
+ * Feature keys the subscriber's entitled terms grant, without evaluating
87
+ * usage thresholds (#4761). Same subscription, plan-version and entitlement
88
+ * rules as {@link resolveEntitlements}; used by `SubscriptionFeatureSource`
89
+ * so a flag check never reads usage meters.
90
+ */
91
+ resolveFeatureGrants(subscriber: Subscriber, options?: SubscriptionResolverOptions): Promise<{
92
+ featureKeys: string[];
93
+ /** The entitled terms' grants with their `metadata` (#4759). */
94
+ grants: PlanFeatureGrant[];
95
+ planId: string | null;
96
+ planKey: string | null;
97
+ }>;
98
+ /**
99
+ * The entitled plan terms' thresholds, without reading any usage meter
100
+ * (#4760). Same subscription, plan-version and entitlement rules as
101
+ * {@link resolveEntitlements}; `entitled` is false when there is no
102
+ * current subscription or it does not entitle now (thresholds are then
103
+ * empty). The hosted inference gate reads its spend caps from here and
104
+ * meters `_smrt_ai_usage` itself. Thresholds are returned as stored, valid
105
+ * or not (the models' `getThresholds()` drops invalid entries), so a caller
106
+ * can refuse a malformed cap instead of skipping it; thresholds JSON that
107
+ * is not an array throws.
108
+ */
109
+ resolvePlanThresholds(subscriber: Subscriber, options?: SubscriptionResolverOptions): Promise<{
110
+ entitled: boolean;
111
+ thresholds: PlanThreshold[];
112
+ planId: string | null;
113
+ planKey: string | null;
114
+ planVersionId: string | null;
115
+ subscriptionId: string | null;
116
+ }>;
117
+ private resolveEntitledTerms;
85
118
  /**
86
119
  * Polymorphic entitlement resolution. Works for both `'tenant'`-kind and
87
120
  * `'external'`-kind subscribers and is the preferred surface — the
@@ -1 +1 @@
1
- {"version":3,"file":"subscription-resolver.d.ts","sourceRoot":"","sources":["../../src/services/subscription-resolver.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"subscription-resolver.d.ts","sourceRoot":"","sources":["../../src/services/subscription-resolver.ts"],"names":[],"mappings":"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"}
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-subscriptions",
6
- "packageVersion": "0.60.13",
6
+ "packageVersion": "0.60.15",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "9ebdfe5f81fe4ede18fe7a048787a47aa22f60434b1d2f33304e60f545a3fc4b",
11
- "packageJson": "e904254cb753617e0b3eb5a4b10c4433ab8c112e905709d2d3d52d3ff4b84380",
12
- "agents": "5fcd3fec51594afb1d675ef3ea52aa239683416f0169fef8a96dfacaabb5580b"
10
+ "manifest": "f4816979dc2afcb8355251a0fc02a249c9098e0a8b5224d53066244daabfb538",
11
+ "packageJson": "59ca645f3cb564affced4b5417316c97ffb8cae0c561edf79239c7d11fafef44",
12
+ "agents": "51c6ab853fadd46068e2146e60313a0818cb1e10fdc28abb97fc00a5173e1bbb"
13
13
  },
14
14
  "exports": [
15
15
  ".",
@@ -22,6 +22,7 @@
22
22
  "@happyvertical/smrt-tenancy": "workspace:*",
23
23
  "@happyvertical/smrt-ui": "workspace:*",
24
24
  "@happyvertical/sql": "catalog:",
25
+ "@happyvertical/smrt-features": "workspace:*",
25
26
  "@happyvertical/smrt-vitest": "workspace:*",
26
27
  "@sveltejs/package": "^2.5.8",
27
28
  "@types/node": "24.13.6",
@@ -33,6 +34,7 @@
33
34
  },
34
35
  "smrtDependencies": [
35
36
  "@happyvertical/smrt-core",
37
+ "@happyvertical/smrt-features",
36
38
  "@happyvertical/smrt-tenancy",
37
39
  "@happyvertical/smrt-ui",
38
40
  "@happyvertical/smrt-vitest"
@@ -4230,7 +4232,7 @@
4230
4232
  "polymorphicAssociations": 0,
4231
4233
  "uuidColumns": 88
4232
4234
  },
4233
- "agentDoc": "# @happyvertical/smrt-subscriptions\n\nTenant subscriptions, plan features, immutable/idempotent usage evidence,\neffective-dated client pricing, scoped spending policies, and entitlement\nresolution for SMRT applications.\n\n## Validation\n\n```bash\npnpm --filter @happyvertical/smrt-subscriptions test\npnpm --filter @happyvertical/smrt-subscriptions test:postgres\npnpm --filter @happyvertical/smrt-subscriptions typecheck\npnpm --filter @happyvertical/smrt-subscriptions build\n```\n\n`test:postgres` is the lane that holds the money-column line — SQLite's type\naffinity stores a REAL in an INTEGER column without complaint, so a money field\nthat reverted to DECIMAL passes every SQLite suite.\n\n## Money\n\n- **Money is integer minor units** (cents) — `$19.99` is `1999`.\n `SubscriptionPlan.priceAmount`, `ClientCharge.amount`, `RetailCharge.amount`,\n `CreditGrant.amount`,\n `BillingAdjustment.amount` and `SpendingPolicy.limitAmount` all initialize\n `= 0`, never `= 0.0`: the integer literal is what maps them to INTEGER\n columns (BIGINT on fresh PostgreSQL/DuckDB databases; #2401, #2373). `ClientCharge.quantity` and `TenantUsageMetric.quantity` are\n the opposite case — metered quantities are genuinely fractional and stay\n DECIMAL.\n- **`PricingRule.terms` are minor units *per unit of usage*, and stay\n fractional.** A per-token rate is routinely a fraction of a cent, so an\n integer `unitPrice` would truncate it to zero. `calculateAmount()` rounds the\n computed result to a whole minor unit — that is the single boundary in the\n package where a rate meets money, and it is what lets `evaluateSpending()`\n compare charge sums against `limitAmount` exactly.\n- **`adjust()` rejects a fractional amount.** Negative amounts are legitimate\n (a credit); fractional ones almost always mean the caller passed major units.\n- **Billing rejects immediate JSON write-back.** The JSON adapter's default\n `writeStrategy: 'immediate'` and DuckDB with that write-back strategy export\n files before transaction commit, while adapter rollback restores only the\n database transaction. `CommercialUsageService` therefore fails before setup\n on those configurations. PostgreSQL, SQLite, ordinary DuckDB, and explicit\n non-immediate write strategies remain supported. When supplying an already\n resolved database handle, also pass `billingStorage` so this capability\n decision is explicit rather than inferred from private adapter state.\n- **Embedded sourced adjustments serialize per database.** SQLite, DuckDB, and\n supported non-immediate JSON handles multiplex local writes; the complete\n deterministic adjustment flow, including recovery reads, runs under one\n database-scoped lock. Every supported embedded adjustment transaction,\n sourced or unsourced, also holds the core embedded write queue for its full\n lifetime; transaction-bound model saves re-enter that hold so adjustments\n cannot absorb, roll back, or deadlock with unrelated ordinary writes.\n- **UI formats by dividing, never by `toFixed`.** `PlanPicker` and\n `CommercialOverview` scale back to major units using the currency's own\n minor-unit exponent, so zero-decimal currencies (JPY, KRW) are not divided.\n- **Migrating an existing database**: `preflightSubscriptionsMoneyMinorUnits(db)`\n reports which columns still hold major units and which rows would be rounded\n or exceed JavaScript's safe-integer range; `migrateSubscriptionsMoneyToMinorUnits(db)` converts them\n (idempotent via `_smrt_backfills`). On SQLite the values are rescaled but the\n declared type needs the table-rebuild path (#2370). Existing\n `PricingRule.terms` are **not** migrated automatically — only the operator\n knows which keys in a given rule are prices rather than quantities or ratios.\n- **Range and existing deployments**: fresh PostgreSQL/DuckDB INTEGER columns\n are BIGINT, and hydration rejects values outside JavaScript's safe-integer\n range. Existing PostgreSQL `int4` columns require the explicit widening in #2424.\n\n## Plan versions (#4032)\n\n- `SubscriptionPlan.save()` publishes the next immutable\n `SubscriptionPlanVersion` when its terms (`PLAN_TERM_FIELDS`) change; the\n logic lives in `src/plan-versioning.ts`. Versions are append-only,\n contiguous, and monotonic in `effectiveFrom`.\n- `TenantSubscription.validateBeforeSave()` maintains `planVersionId` via\n `pinSubscriptionPlanVersion()`: pin at period start, re-pin when\n `currentPeriodStart` moves, and refuse in-period changes that are not an\n `isEntitlementUpgrade()` unless `replacePlanVersion({ policy: 'authorized',\n reason })`. Never relax this to \"latest version wins\": a paid period must\n not lose entitlements.\n- The resolver reads the pinned version; a missing or inactive pinned version\n fails closed. Only unpinned rows on plans without versions fall back to the\n live plan. `migrateSubscriptionPlanVersions(db)` backfills both.\n- Renewal means the new `currentPeriodStart` is at or after the stored\n `currentPeriodEnd`; creation and renewal pin the boundary's version, and a\n caller-named other version needs an authorized replacement.\n- `canceled` subscriptions entitle until `currentPeriodEnd` only with\n `retainsPaidPeriod`, derived on save from an entitled pre-cancel row.\n- **Recipe `subscriptions.plans`** (`src/recipes.ts`, help in `src/plans.recipe.md`): lists plans, subscriptions, plan versions (read-only, written by `plan-versioning.ts`) and charges (read-only, written by the usage service) for the planner and other catalog hosts. Fields it shows carry `@field({ description })` in plain words, money fields saying \"in cents\"; keep them when editing the models. Reseller models and pricing rules stay out of it.\n\n## Reseller billing (#3059)\n\n- Billing ownership is consumed from `smrt-tenancy`'s\n `BillingRelationshipService` (`BillingRelationshipReader`); never re-derive\n parentage or owners here.\n- `ClientCharge` stays one row per usage event (`usage_event_id` unique) and\n `tenantId` is always the payer. That uniqueness is the double-billing guard\n across owner changes; do not widen its conflict key. The retail leg lives in\n `RetailCharge` (also one per event), written in the same transaction.\n- Price-book rules are `PricingRule` rows with `priceBookId`; `price()` must\n keep ignoring them, and `rateUsage()` only trusts rules whose tenant owns the\n book.\n- Delegated `SpendingPolicy` rows (`setByTenantId`) are guarded in\n `validateBeforeSave` and a `beforeDelete` hook, by id and by conflict key;\n `CreditGrant` applies the same parent-only rule to delegated balances.\n `PriceBookAssignmentCollection` is deliberately not a root export.\n- `ResellerBillingService` authorizes first, then writes in a system context\n (parent writes land on child-owned rows). The wholesale leg is authorized by\n the book's publisher and may never be published by the payer; whether a\n publisher is a legitimate provider for the reseller is a host obligation\n (the model records no upstream seller). Relationship reads precede\n authorization and stay under smrt-tenancy's own read rules. The only\n cross-tenant read in evaluation is the bounded `wholesale`-basis sum.\n- `balance` policies take their limit from `CreditGrant`s; `limitAmount` must\n be 0, and currency and period are fixed once saved (the ledger is scoped to\n them). Auto top-up is a host hook only — no payment provider calls. The\n evaluator re-reads credit after the hook returns (grant or not) and hands\n it `currentShortfall()`, so a hook that charges asynchronously can see a\n concurrent top-up (smrt-commerce's `autoTopUpHook()`, #3139).\n\n## Notes\n\n- Keep Stripe-specific fields as provider binding metadata. Runtime Stripe API\n calls belong in the HappyVertical SDK accounting provider.\n- Thresholds are evaluated from tenant usage metrics and optional AI usage\n summaries. Resolve multiple thresholds with the batch usage path where\n available; do not bypass tenant context in application code.\n- Use `SubscriptionResolver.create()` or an explicitly injected\n `SubscriptionResolver` once per request when resolving entitlements multiple\n times. Load and pass `EntitlementResolutionContext` to avoid re-reading the\n current subscription and plan.\n- Subscription UI components should stay provider-neutral and receive actions\n from the host app.\n",
4235
+ "agentDoc": "# @happyvertical/smrt-subscriptions\n\nTenant subscriptions, plan features, immutable/idempotent usage evidence,\neffective-dated client pricing, scoped spending policies, and entitlement\nresolution for SMRT applications.\n\n## Validation\n\n```bash\npnpm --filter @happyvertical/smrt-subscriptions test\npnpm --filter @happyvertical/smrt-subscriptions test:postgres\npnpm --filter @happyvertical/smrt-subscriptions typecheck\npnpm --filter @happyvertical/smrt-subscriptions build\n```\n\n`test:postgres` is the lane that holds the money-column line — SQLite's type\naffinity stores a REAL in an INTEGER column without complaint, so a money field\nthat reverted to DECIMAL passes every SQLite suite.\n\n## Money\n\n- **Money is integer minor units** (cents) — `$19.99` is `1999`.\n `SubscriptionPlan.priceAmount`, `ClientCharge.amount`, `RetailCharge.amount`,\n `CreditGrant.amount`,\n `BillingAdjustment.amount` and `SpendingPolicy.limitAmount` all initialize\n `= 0`, never `= 0.0`: the integer literal is what maps them to INTEGER\n columns (BIGINT on fresh PostgreSQL/DuckDB databases; #2401, #2373). `ClientCharge.quantity` and `TenantUsageMetric.quantity` are\n the opposite case — metered quantities are genuinely fractional and stay\n DECIMAL.\n- **`PricingRule.terms` are minor units *per unit of usage*, and stay\n fractional.** A per-token rate is routinely a fraction of a cent, so an\n integer `unitPrice` would truncate it to zero. `calculateAmount()` rounds the\n computed result to a whole minor unit — that is the single boundary in the\n package where a rate meets money, and it is what lets `evaluateSpending()`\n compare charge sums against `limitAmount` exactly.\n- **`adjust()` rejects a fractional amount.** Negative amounts are legitimate\n (a credit); fractional ones almost always mean the caller passed major units.\n- **Billing rejects immediate JSON write-back.** The JSON adapter's default\n `writeStrategy: 'immediate'` and DuckDB with that write-back strategy export\n files before transaction commit, while adapter rollback restores only the\n database transaction. `CommercialUsageService` therefore fails before setup\n on those configurations. PostgreSQL, SQLite, ordinary DuckDB, and explicit\n non-immediate write strategies remain supported. When supplying an already\n resolved database handle, also pass `billingStorage` so this capability\n decision is explicit rather than inferred from private adapter state.\n- **Embedded sourced adjustments serialize per database.** SQLite, DuckDB, and\n supported non-immediate JSON handles multiplex local writes; the complete\n deterministic adjustment flow, including recovery reads, runs under one\n database-scoped lock. Every supported embedded adjustment transaction,\n sourced or unsourced, also holds the core embedded write queue for its full\n lifetime; transaction-bound model saves re-enter that hold so adjustments\n cannot absorb, roll back, or deadlock with unrelated ordinary writes.\n- **UI formats by dividing, never by `toFixed`.** `PlanPicker` and\n `CommercialOverview` scale back to major units using the currency's own\n minor-unit exponent, so zero-decimal currencies (JPY, KRW) are not divided.\n- **Migrating an existing database**: `preflightSubscriptionsMoneyMinorUnits(db)`\n reports which columns still hold major units and which rows would be rounded\n or exceed JavaScript's safe-integer range; `migrateSubscriptionsMoneyToMinorUnits(db)` converts them\n (idempotent via `_smrt_backfills`). On SQLite the values are rescaled but the\n declared type needs the table-rebuild path (#2370). Existing\n `PricingRule.terms` are **not** migrated automatically — only the operator\n knows which keys in a given rule are prices rather than quantities or ratios.\n- **Range and existing deployments**: fresh PostgreSQL/DuckDB INTEGER columns\n are BIGINT, and hydration rejects values outside JavaScript's safe-integer\n range. Existing PostgreSQL `int4` columns require the explicit widening in #2424.\n\n## Plan versions (#4032)\n\n- `SubscriptionPlan.save()` publishes the next immutable\n `SubscriptionPlanVersion` when its terms (`PLAN_TERM_FIELDS`) change; the\n logic lives in `src/plan-versioning.ts`. Versions are append-only,\n contiguous, and monotonic in `effectiveFrom`.\n- `TenantSubscription.validateBeforeSave()` maintains `planVersionId` via\n `pinSubscriptionPlanVersion()`: pin at period start, re-pin when\n `currentPeriodStart` moves, and refuse in-period changes that are not an\n `isEntitlementUpgrade()` unless `replacePlanVersion({ policy: 'authorized',\n reason })`. Never relax this to \"latest version wins\": a paid period must\n not lose entitlements.\n- The resolver reads the pinned version; a missing or inactive pinned version\n fails closed. Only unpinned rows on plans without versions fall back to the\n live plan. `migrateSubscriptionPlanVersions(db)` backfills both.\n- Renewal means the new `currentPeriodStart` is at or after the stored\n `currentPeriodEnd`; creation and renewal pin the boundary's version, and a\n caller-named other version needs an authorized replacement.\n- `canceled` subscriptions entitle until `currentPeriodEnd` only with\n `retainsPaidPeriod`, derived on save from an entitled pre-cancel row.\n- **Recipe `subscriptions.plans`** (`src/recipes.ts`, help in `src/plans.recipe.md`): lists plans, subscriptions, plan versions (read-only, written by `plan-versioning.ts`) and charges (read-only, written by the usage service) for the planner and other catalog hosts. Fields it shows carry `@field({ description })` in plain words, money fields saying \"in cents\"; keep them when editing the models. Reseller models and pricing rules stay out of it.\n\n## Hosted AI spend caps (#4760)\n\n`src/services/hosted-ai-spend.ts`. Plans cap platform-paid (`source:\nhosted`) inference with ordinary `PlanThreshold`s on two reserved keys, so\nwindow, amount and enforcement come from the plan (pinned version included):\n`AI_HOSTED_SPEND_METRIC` (`ai.hosted.spend_micros`, the tenant) and\n`AI_HOSTED_USER_SPEND_METRIC` (`ai.hosted.user_spend_micros`, each user).\n\n- `limit` is **integer micro-dollars** (1 USD = 1,000,000; the money rule at\n per-call precision). `block` blocks at or over the cap, `warn` warns,\n `observe` never affects a call; `warningRatio` (default 0.8) warns early;\n a 0 cap blocks every hosted call. A malformed hosted cap (fractional or\n negative limit, unknown window/enforcement) throws\n `HostedAiSpendPolicyError` (the gate fails closed); it never reads as \"no\n cap\". `resolvePlanThresholds()` returns thresholds unfiltered for this\n reason (the models' `getThresholds()` drop invalid entries) and throws on\n non-array JSON.\n- Spend: `createHostedAiSpendReader(db)` sums `_smrt_ai_usage.estimated_cost`\n (USD, rounded to micro-dollars) by `tenant_id` and `created_at` in the\n cap's window for rows whose `tags` hold `\"source\":\"hosted\"` (and\n `\"userId\":\"<id>\"` for the per-user cap), as exact, case-sensitive JSON\n fragments (`hostedAiTagFragment()`; `strpos` on the PostgreSQL dialect,\n `instr` elsewhere, never `LIKE`, which folds ASCII case on SQLite and\n would merge `Alice` and `alice`); errors only over-count.\n `operation: 'blocked'` rows and external-agent measurements never count;\n uncosted rows add nothing (give hosted models a cost rate). The `userId`\n tag exists only while the hosted gate's ambient-user resolver is\n registered.\n- `resetsAt`: the calendar window's end; for a blocked `rolling` cap (30\n days), `HostedAiSpendReader.liftsAt()`: one window-function query (newest\n first running `SUM(estimated_cost)`, rounded as `summarize()` rounds)\n returns the row whose expiry first brings the total below the cap, plus\n 30 days (null for a 0 cap). A blocked decision names the blocking cap that\n lifts last.\n- `HostedAiSpendEvaluator` (`create({ db })`): plan thresholds via\n `SubscriptionResolver.resolvePlanThresholds()` (entitled terms, no usage\n meters; memoized per tenant for `termsTtlMs`, 30 s, failures evicted,\n `invalidate()`), spend read fresh per call; per-user caps only with a\n user. Errors propagate. smrt-inference's `./gate` consumes it.\n- `resolveEntitlements()` / the tenant usage meter report these keys with\n quantity 0: only the evaluator meters them. The meter's AI summary (and so\n `ai.requests`) skips `operation: 'blocked'` rows. `SpendingPolicy` rows stay the\n billed-charge (cents) policy; hosted AI caps are plan terms because the\n issue's window and amount come from the plan.\n\n## Reseller billing (#3059)\n\n- Billing ownership is consumed from `smrt-tenancy`'s\n `BillingRelationshipService` (`BillingRelationshipReader`); never re-derive\n parentage or owners here.\n- `ClientCharge` stays one row per usage event (`usage_event_id` unique) and\n `tenantId` is always the payer. That uniqueness is the double-billing guard\n across owner changes; do not widen its conflict key. The retail leg lives in\n `RetailCharge` (also one per event), written in the same transaction.\n- Price-book rules are `PricingRule` rows with `priceBookId`; `price()` must\n keep ignoring them, and `rateUsage()` only trusts rules whose tenant owns the\n book.\n- Delegated `SpendingPolicy` rows (`setByTenantId`) are guarded in\n `validateBeforeSave` and a `beforeDelete` hook, by id and by conflict key;\n `CreditGrant` applies the same parent-only rule to delegated balances.\n `PriceBookAssignmentCollection` is deliberately not a root export.\n- `ResellerBillingService` authorizes first, then writes in a system context\n (parent writes land on child-owned rows). The wholesale leg is authorized by\n the book's publisher and may never be published by the payer; whether a\n publisher is a legitimate provider for the reseller is a host obligation\n (the model records no upstream seller). Relationship reads precede\n authorization and stay under smrt-tenancy's own read rules. The only\n cross-tenant read in evaluation is the bounded `wholesale`-basis sum.\n- `balance` policies take their limit from `CreditGrant`s; `limitAmount` must\n be 0, and currency and period are fixed once saved (the ledger is scoped to\n them). Auto top-up is a host hook only — no payment provider calls. The\n evaluator re-reads credit after the hook returns (grant or not) and hands\n it `currentShortfall()`, so a hook that charges asynchronously can see a\n concurrent top-up (smrt-commerce's `autoTopUpHook()`, #3139).\n\n## Notes\n\n- Keep Stripe-specific fields as provider binding metadata. Runtime Stripe API\n calls belong in the HappyVertical SDK accounting provider.\n- Thresholds are evaluated from tenant usage metrics and optional AI usage\n summaries. Resolve multiple thresholds with the batch usage path where\n available; do not bypass tenant context in application code.\n- Use `SubscriptionResolver.create()` or an explicitly injected\n `SubscriptionResolver` once per request when resolving entitlements multiple\n times. Load and pass `EntitlementResolutionContext` to avoid re-reading the\n current subscription and plan.\n- Subscription UI components should stay provider-neutral and receive actions\n from the host app.\n- **Feature grants as flags (#4761).** `SubscriptionFeatureSource` implements\n `smrt-features`' `FeatureGrantSource` structurally (no dependency either\n way). Build one per request (`SubscriptionFeatureSource.create(options)` or\n `new SubscriptionFeatureSource({ resolver })`; it memoizes grants per\n tenant) and pass it as `grantSource`, or register an app-wide one built with\n `memoize: false` through `registerSubscriptionFeatureSource()`, which writes\n the features slot key `FEATURE_GRANT_SOURCE_SLOT_KEY`. It reads\n `SubscriptionResolver.resolveFeatureGrants()`, which shares the\n version-pinning and entitlement rules of `resolveEntitlements()` but never\n evaluates thresholds, so a flag check reads no usage meters. Missing\n subscription tables (`isMissingSubscriptionTableError`) and no entitled\n subscription report no grants silently; every other error propagates.\n `findGrantingPlan()` names the first active plan by `sortOrder` visible in\n the current context.\n- **Entitlement change hook (#4759).** `src/services/entitlement-changes.ts`.\n The host wraps its billing-webhook write in\n `trackEntitlementChange({ tenantId, resolver }, apply)`: it snapshots\n `resolveFeatureGrants()` (keys plus grant `metadata`, no usage reads)\n before and after, diffs `gained` / `lost` / `changed` (metadata, e.g. a\n plan budget) and calls listeners registered with\n `registerEntitlementChangeListener()` (a `Symbol.for` slot) or passed in.\n Listeners run after `apply`; their failures are returned in `failures`,\n never thrown, and never undo the write, so listeners must be idempotent.\n An `apply` error propagates and notifies nobody. A redelivered webhook\n diffs to no change, so a lost reaction is repaired by the consumer's\n reconcile entry point (hosted inference: `createHostedInferenceReconciler()`\n with `snapshotEntitlements()`), run on redelivery or a schedule. Listeners satisfy the\n structural type (smrt-inference's hosted listener), so this package\n depends on none of them.\n",
4234
4236
  "recipes": [
4235
4237
  {
4236
4238
  "id": "subscriptions.plans",
@@ -5,10 +5,10 @@ description: "Tenant subscriptions, entitlement resolution, usage thresholds, an
5
5
  status: "stable"
6
6
  generated:
7
7
  by: "smrt/scripts/generate-knowledge-okf.ts"
8
- at: "2026-11-05T15:48:19Z"
8
+ at: "2026-11-08T23:20:26Z"
9
9
  audience: "internal"
10
10
  evidence_level: "source-inspected"
11
- source_revision: "sha256:f3d3b98418f3d146b69c1d795ce8e0c7154827d061cb6e21da2862828961e7f8"
11
+ source_revision: "sha256:7c2168e5c5217ff6f6d7073b39f02053dadab3070527ce4c41698152a0ed878b"
12
12
  sources:
13
13
  - id: "manifest"
14
14
  resource: "https://github.com/happyvertical/smrt/blob/main/packages/subscriptions/package.json"
@@ -34,6 +34,7 @@ sources:
34
34
  ## Framework dependencies
35
35
 
36
36
  - [smrt-core](/components/smrt-core.md)
37
+ - [smrt-features](/components/smrt-features.md)
37
38
  - [smrt-tenancy](/components/smrt-tenancy.md)
38
39
  - [smrt-ui](/components/smrt-ui.md)
39
40
  - [smrt-vitest](/components/smrt-vitest.md)