@startsimpli/billing 0.4.19 → 0.4.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/index.ts +2 -1
- package/src/utils/plan.test.ts +227 -0
- package/src/utils/plan.ts +174 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
4
4
|
"description": "Universal billing integration for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./client": "./src/index.ts",
|
|
10
10
|
"./server": "./src/server/index.ts",
|
|
11
|
+
"./plan": "./src/utils/plan.ts",
|
|
11
12
|
"./types": "./src/types/index.ts",
|
|
12
13
|
"./mock": "./src/mock/index.ts"
|
|
13
14
|
},
|
package/src/index.ts
CHANGED
|
@@ -57,4 +57,5 @@ export type {
|
|
|
57
57
|
export { BillingApiClient } from "./utils/api";
|
|
58
58
|
|
|
59
59
|
// Pure helpers
|
|
60
|
-
export { isPaidSubscription } from "./utils/plan";
|
|
60
|
+
export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from "./utils/plan";
|
|
61
|
+
export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow } from "./utils/plan";
|
package/src/utils/plan.test.ts
CHANGED
|
@@ -85,3 +85,230 @@ describe('isPaidSubscription', () => {
|
|
|
85
85
|
expect(isPaidSubscription(sub)).toBe(false)
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Entitlement resolution (startsim-ol36d.9 / .12)
|
|
91
|
+
//
|
|
92
|
+
// `isPaidSubscription` answers "is this a paid plan". Gating needs three more
|
|
93
|
+
// things it cannot express:
|
|
94
|
+
// 1. what to do when billing is LOADING or ERRORED — the old crochet gate
|
|
95
|
+
// treated "unknown" as paid and opened Pro features to everyone;
|
|
96
|
+
// 2. how big the allowance is — read from the offer's own feature list, not
|
|
97
|
+
// hardcoded in each app;
|
|
98
|
+
// 3. which window to count usage in — a free subscription's
|
|
99
|
+
// current_period_start is frozen at signup and never rolls.
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
import { getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from './plan'
|
|
103
|
+
|
|
104
|
+
const freeOffer = makeOffer({
|
|
105
|
+
slug: 'crochet-free',
|
|
106
|
+
isFree: true,
|
|
107
|
+
unitPrice: '0.00',
|
|
108
|
+
features: [
|
|
109
|
+
{ key: 'patterns_per_month', name: 'Patterns per month', value: 3, limit: 3 },
|
|
110
|
+
{ key: 'save_patterns', name: 'Save patterns', value: true, limit: null },
|
|
111
|
+
],
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const proOffer = makeOffer({
|
|
115
|
+
slug: 'crochet-pro',
|
|
116
|
+
isFree: false,
|
|
117
|
+
unitPrice: '9.00',
|
|
118
|
+
trialDays: 7,
|
|
119
|
+
features: [
|
|
120
|
+
{ key: 'patterns_per_month', name: 'Patterns per month', value: -1, limit: null },
|
|
121
|
+
{ key: 'pdf_export', name: 'PDF export', value: true, limit: null },
|
|
122
|
+
],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('getFeatureLimit', () => {
|
|
126
|
+
it('reads the numeric allowance from the offer', () => {
|
|
127
|
+
expect(getFeatureLimit(freeOffer, ['patterns_per_month'])).toBe(3)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('maps -1 to Infinity (unlimited)', () => {
|
|
131
|
+
expect(getFeatureLimit(proOffer, ['patterns_per_month'])).toBe(Infinity)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('maps the string "unlimited" to Infinity', () => {
|
|
135
|
+
const offer = makeOffer({
|
|
136
|
+
features: [{ key: 'patterns_per_month', name: 'x', value: 'unlimited', limit: null }],
|
|
137
|
+
})
|
|
138
|
+
expect(getFeatureLimit(offer, ['patterns_per_month'])).toBe(Infinity)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('prefers `limit` over `value` when both are set', () => {
|
|
142
|
+
const offer = makeOffer({
|
|
143
|
+
features: [{ key: 'patterns_per_month', name: 'x', value: 99, limit: 3 }],
|
|
144
|
+
})
|
|
145
|
+
expect(getFeatureLimit(offer, ['patterns_per_month'])).toBe(3)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('accepts an alias key', () => {
|
|
149
|
+
const offer = makeOffer({
|
|
150
|
+
features: [{ key: 'monthly_generations', name: 'x', value: 7, limit: null }],
|
|
151
|
+
})
|
|
152
|
+
expect(getFeatureLimit(offer, ['patterns_per_month', 'monthly_generations'])).toBe(7)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('returns null when the offer declares no such feature', () => {
|
|
156
|
+
expect(getFeatureLimit(makeOffer(), ['patterns_per_month'])).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns null for a missing offer rather than throwing', () => {
|
|
160
|
+
expect(getFeatureLimit(null, ['patterns_per_month'])).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('resolvePlanEntitlement', () => {
|
|
165
|
+
const opts = { featureKeys: ['patterns_per_month'], freeFallbackLimit: 3 }
|
|
166
|
+
|
|
167
|
+
it('treats an active FREE offer as free and uses the offer allowance', () => {
|
|
168
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'active', offer: freeOffer }), opts)
|
|
169
|
+
expect(e.isFree).toBe(true)
|
|
170
|
+
expect(e.isPaid).toBe(false)
|
|
171
|
+
expect(e.limit).toBe(3)
|
|
172
|
+
expect(e.isUnlimited).toBe(false)
|
|
173
|
+
expect(e.unknown).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does NOT depend on the offer slug being the literal "free"', () => {
|
|
177
|
+
// The real slug is `crochet-free`. A slug === 'free' test silently
|
|
178
|
+
// misclassified all 120 real free holders as paid.
|
|
179
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'active', offer: freeOffer }), opts)
|
|
180
|
+
expect(e.isFree).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('treats an active PAID offer as paid and unlimited', () => {
|
|
184
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'active', offer: proOffer }), opts)
|
|
185
|
+
expect(e.isPaid).toBe(true)
|
|
186
|
+
expect(e.isFree).toBe(false)
|
|
187
|
+
expect(e.isUnlimited).toBe(true)
|
|
188
|
+
expect(e.limit).toBe(Infinity)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('treats a TRIALING paid plan as paid, not free', () => {
|
|
192
|
+
// A 7-day Pro trial is a paying customer mid-trial; the old gate gave them
|
|
193
|
+
// free limits because it tested `status === 'active'`.
|
|
194
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'trialing', offer: proOffer }), opts)
|
|
195
|
+
expect(e.isPaid).toBe(true)
|
|
196
|
+
expect(e.isFree).toBe(false)
|
|
197
|
+
expect(e.isUnlimited).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('fails CLOSED while billing is loading', () => {
|
|
201
|
+
const e = resolvePlanEntitlement(null, { ...opts, loading: true })
|
|
202
|
+
expect(e.isFree).toBe(true)
|
|
203
|
+
expect(e.isPaid).toBe(false)
|
|
204
|
+
expect(e.unknown).toBe(true)
|
|
205
|
+
expect(e.limit).toBe(3)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('fails CLOSED when the billing call errored', () => {
|
|
209
|
+
// The old gate did `!subscriptionError && (...)`, so an error made
|
|
210
|
+
// isFreeUser false — i.e. an outage handed everyone Pro.
|
|
211
|
+
const e = resolvePlanEntitlement(null, { ...opts, error: new Error('boom') })
|
|
212
|
+
expect(e.isFree).toBe(true)
|
|
213
|
+
expect(e.isPaid).toBe(false)
|
|
214
|
+
expect(e.unknown).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('treats no subscription at all as free', () => {
|
|
218
|
+
const e = resolvePlanEntitlement(null, opts)
|
|
219
|
+
expect(e.isFree).toBe(true)
|
|
220
|
+
expect(e.unknown).toBe(false)
|
|
221
|
+
expect(e.limit).toBe(3)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('treats a subscription with no offer as free', () => {
|
|
225
|
+
const sub = { ...makeSub(), offer: null as unknown as ProductOffer }
|
|
226
|
+
expect(resolvePlanEntitlement(sub, opts).isFree).toBe(true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const deadStatuses = ['past_due', 'cancelled', 'unpaid', 'trial_expired'] as const
|
|
230
|
+
for (const status of deadStatuses) {
|
|
231
|
+
it(`treats status=${status} on a paid offer as NOT paid`, () => {
|
|
232
|
+
const e = resolvePlanEntitlement(makeSub({ status, offer: proOffer }), opts)
|
|
233
|
+
expect(e.isPaid).toBe(false)
|
|
234
|
+
expect(e.isFree).toBe(true)
|
|
235
|
+
expect(e.limit).toBe(3)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
it('falls back to freeFallbackLimit when a free offer declares no allowance', () => {
|
|
240
|
+
const bare = makeOffer({ isFree: true, features: [] })
|
|
241
|
+
expect(resolvePlanEntitlement(makeSub({ offer: bare }), opts).limit).toBe(3)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('reads the allowance from the offer rather than the fallback when both exist', () => {
|
|
245
|
+
const e = resolvePlanEntitlement(makeSub({ offer: freeOffer }), {
|
|
246
|
+
...opts,
|
|
247
|
+
freeFallbackLimit: 5,
|
|
248
|
+
})
|
|
249
|
+
expect(e.limit).toBe(3)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('has a hasFeature() that gates a named Pro capability', () => {
|
|
253
|
+
const pro = resolvePlanEntitlement(makeSub({ offer: proOffer }), opts)
|
|
254
|
+
const free = resolvePlanEntitlement(makeSub({ offer: freeOffer }), opts)
|
|
255
|
+
expect(pro.hasFeature('pdf_export')).toBe(true)
|
|
256
|
+
expect(free.hasFeature('pdf_export')).toBe(false)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('hasFeature() is false while unknown (fail closed)', () => {
|
|
260
|
+
const e = resolvePlanEntitlement(null, { ...opts, error: new Error('boom') })
|
|
261
|
+
expect(e.hasFeature('pdf_export')).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('resolveUsageWindow', () => {
|
|
266
|
+
const now = new Date('2026-09-18T12:00:00.000Z')
|
|
267
|
+
|
|
268
|
+
it('uses the calendar month when the subscription has no period bounds', () => {
|
|
269
|
+
const w = resolveUsageWindow(null, now)
|
|
270
|
+
expect(w.start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
271
|
+
expect(w.end.toISOString()).toBe('2026-10-01T00:00:00.000Z')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('IGNORES a frozen free-subscription period that never rolls', () => {
|
|
275
|
+
// SubscribeFreeViewSet writes current_period_start=now, end=None at signup
|
|
276
|
+
// and never updates it. Counting from that start is "count patterns ever"
|
|
277
|
+
// wearing a different hat — the exact bug startsim-ol36d.12 names.
|
|
278
|
+
const frozen = makeSub({
|
|
279
|
+
currentPeriodStart: '2026-03-04T09:00:00.000Z',
|
|
280
|
+
currentPeriodEnd: null,
|
|
281
|
+
})
|
|
282
|
+
const w = resolveUsageWindow(frozen, now)
|
|
283
|
+
expect(w.start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('ignores a period whose end is already in the past', () => {
|
|
287
|
+
const stale = makeSub({
|
|
288
|
+
currentPeriodStart: '2026-07-01T00:00:00.000Z',
|
|
289
|
+
currentPeriodEnd: '2026-08-01T00:00:00.000Z',
|
|
290
|
+
})
|
|
291
|
+
expect(resolveUsageWindow(stale, now).start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('uses a real, current billing period when both bounds are present', () => {
|
|
295
|
+
const real = makeSub({
|
|
296
|
+
currentPeriodStart: '2026-09-04T09:00:00.000Z',
|
|
297
|
+
currentPeriodEnd: '2026-10-04T09:00:00.000Z',
|
|
298
|
+
})
|
|
299
|
+
const w = resolveUsageWindow(real, now)
|
|
300
|
+
expect(w.start.toISOString()).toBe('2026-09-04T09:00:00.000Z')
|
|
301
|
+
expect(w.end.toISOString()).toBe('2026-10-04T09:00:00.000Z')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('falls back to the calendar month on an unparseable period start', () => {
|
|
305
|
+
const bad = makeSub({ currentPeriodStart: 'not-a-date', currentPeriodEnd: 'also-bad' })
|
|
306
|
+
expect(resolveUsageWindow(bad, now).start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('rolls into the next calendar month at the boundary', () => {
|
|
310
|
+
const w = resolveUsageWindow(null, new Date('2026-12-31T23:59:59.000Z'))
|
|
311
|
+
expect(w.start.toISOString()).toBe('2026-12-01T00:00:00.000Z')
|
|
312
|
+
expect(w.end.toISOString()).toBe('2027-01-01T00:00:00.000Z')
|
|
313
|
+
})
|
|
314
|
+
})
|
package/src/utils/plan.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* they don't.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { SubscriptionInfo } from "../types";
|
|
10
|
+
import type { OfferFeature, ProductOffer, SubscriptionInfo } from "../types";
|
|
11
11
|
|
|
12
12
|
const PAID_STATUSES: ReadonlyArray<SubscriptionInfo["status"]> = ["active", "trialing"];
|
|
13
13
|
|
|
@@ -28,3 +28,176 @@ export function isPaidSubscription(
|
|
|
28
28
|
if (sub.offer.isFree) return false;
|
|
29
29
|
return PAID_STATUSES.includes(sub.status);
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a numeric allowance out of an offer's declared features.
|
|
34
|
+
*
|
|
35
|
+
* `keys` is tried in order so an app can accept an alias
|
|
36
|
+
* (e.g. `patterns_per_month` or `monthly_generations`).
|
|
37
|
+
*
|
|
38
|
+
* Returns `Infinity` for an explicit unlimited marker (`-1` or `"unlimited"`),
|
|
39
|
+
* and `null` when the offer declares no such allowance — which is NOT the same
|
|
40
|
+
* as zero, and callers must decide what an undeclared allowance means for them.
|
|
41
|
+
*
|
|
42
|
+
* `limit` wins over `value`: the backend puts the enforceable number in
|
|
43
|
+
* `limit` and uses `value` for display, and they can disagree.
|
|
44
|
+
*/
|
|
45
|
+
export function getFeatureLimit(
|
|
46
|
+
offer: ProductOffer | null | undefined,
|
|
47
|
+
keys: readonly string[]
|
|
48
|
+
): number | null {
|
|
49
|
+
if (!offer) return null;
|
|
50
|
+
const features: OfferFeature[] = offer.features ?? [];
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const feature = features.find((f) => f?.key === key);
|
|
53
|
+
if (!feature) continue;
|
|
54
|
+
const raw = feature.limit ?? feature.value;
|
|
55
|
+
if (raw === -1 || raw === "unlimited") return Infinity;
|
|
56
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return raw;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PlanEntitlement {
|
|
62
|
+
/**
|
|
63
|
+
* True when the user is on a free plan — or when we could not prove they
|
|
64
|
+
* are not. Gates read this, so it fails CLOSED: the worst case is showing
|
|
65
|
+
* an upgrade prompt to someone who has already paid, which is recoverable.
|
|
66
|
+
* The opposite mistake hands Pro away during a billing outage.
|
|
67
|
+
*/
|
|
68
|
+
isFree: boolean;
|
|
69
|
+
/** True only when an active or trialing PAID subscription is proven. */
|
|
70
|
+
isPaid: boolean;
|
|
71
|
+
/** The allowance for the metered feature. `Infinity` when unlimited. */
|
|
72
|
+
limit: number;
|
|
73
|
+
isUnlimited: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* True when billing could not be resolved (still loading, or the call
|
|
76
|
+
* failed), so `isFree` is a fail-closed assumption rather than a fact.
|
|
77
|
+
* Use it to soften the copy ("checking your plan…"), never to open a gate.
|
|
78
|
+
*/
|
|
79
|
+
unknown: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* True when the plan's offer explicitly declares the named capability.
|
|
82
|
+
* Always false while `unknown`.
|
|
83
|
+
*/
|
|
84
|
+
hasFeature: (key: string) => boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ResolvePlanEntitlementOptions {
|
|
88
|
+
/** Feature keys to read the metered allowance from, in priority order. */
|
|
89
|
+
featureKeys?: readonly string[];
|
|
90
|
+
/**
|
|
91
|
+
* Allowance to assume for a free user whose offer declares none — e.g. a
|
|
92
|
+
* user with no subscription row at all, who therefore has no offer to read.
|
|
93
|
+
*/
|
|
94
|
+
freeFallbackLimit?: number;
|
|
95
|
+
/** Billing is still in flight. */
|
|
96
|
+
loading?: boolean;
|
|
97
|
+
/** The billing call failed. */
|
|
98
|
+
error?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive what a subscription actually entitles someone to.
|
|
103
|
+
*
|
|
104
|
+
* This exists because the same three-line derivation was copied into every
|
|
105
|
+
* gate, and each copy got it wrong in a different way: testing
|
|
106
|
+
* `offer.slug === 'free'` when the real slug is `crochet-free`, testing
|
|
107
|
+
* `status === 'active'` so a paid trial counted as free, and treating a
|
|
108
|
+
* billing error as proof of a paid plan.
|
|
109
|
+
*/
|
|
110
|
+
export function resolvePlanEntitlement(
|
|
111
|
+
sub: SubscriptionInfo | null | undefined,
|
|
112
|
+
options: ResolvePlanEntitlementOptions = {}
|
|
113
|
+
): PlanEntitlement {
|
|
114
|
+
const {
|
|
115
|
+
featureKeys = [],
|
|
116
|
+
freeFallbackLimit = Infinity,
|
|
117
|
+
loading = false,
|
|
118
|
+
error = null,
|
|
119
|
+
} = options;
|
|
120
|
+
|
|
121
|
+
// Unresolved billing is not evidence of a paid plan.
|
|
122
|
+
if (loading || error) {
|
|
123
|
+
return {
|
|
124
|
+
isFree: true,
|
|
125
|
+
isPaid: false,
|
|
126
|
+
limit: freeFallbackLimit,
|
|
127
|
+
isUnlimited: freeFallbackLimit === Infinity,
|
|
128
|
+
unknown: true,
|
|
129
|
+
hasFeature: () => false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const offer = sub?.offer ?? null;
|
|
134
|
+
const paid = isPaidSubscription(sub);
|
|
135
|
+
const declared = getFeatureLimit(offer, featureKeys);
|
|
136
|
+
|
|
137
|
+
// An offer's allowance only counts while that offer is actually in force.
|
|
138
|
+
// A paid plan with no declared ceiling is unlimited; a free plan falls back
|
|
139
|
+
// to what the caller says free means. A LAPSED paid plan (cancelled,
|
|
140
|
+
// past_due, unpaid, expired trial) must not keep spending its old ceiling —
|
|
141
|
+
// it drops to the free allowance like anyone else.
|
|
142
|
+
let limit: number;
|
|
143
|
+
if (paid) {
|
|
144
|
+
limit = declared ?? Infinity;
|
|
145
|
+
} else if (offer?.isFree) {
|
|
146
|
+
limit = declared ?? freeFallbackLimit;
|
|
147
|
+
} else {
|
|
148
|
+
limit = freeFallbackLimit;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
isFree: !paid,
|
|
153
|
+
isPaid: paid,
|
|
154
|
+
limit,
|
|
155
|
+
isUnlimited: limit === Infinity,
|
|
156
|
+
unknown: false,
|
|
157
|
+
hasFeature: (key: string) =>
|
|
158
|
+
(offer?.features ?? []).some((f) => f?.key === key && f.value !== false),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface UsageWindow {
|
|
163
|
+
start: Date;
|
|
164
|
+
end: Date;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function startOfUtcMonth(d: Date): Date {
|
|
168
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parse(value: string | null | undefined): Date | null {
|
|
172
|
+
if (!value) return null;
|
|
173
|
+
const d = new Date(value);
|
|
174
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The window a per-period allowance should be counted over.
|
|
179
|
+
*
|
|
180
|
+
* Prefers the subscription's real billing period, but only when BOTH bounds
|
|
181
|
+
* are present and the period has not already ended. Free subscriptions are
|
|
182
|
+
* created with `current_period_start = now` and `current_period_end = NULL`
|
|
183
|
+
* and are never rolled forward, so trusting that start would count every
|
|
184
|
+
* pattern the user has ever made — which is the bug this replaces, not a fix
|
|
185
|
+
* for it. Everything else falls back to the calendar month.
|
|
186
|
+
*/
|
|
187
|
+
export function resolveUsageWindow(
|
|
188
|
+
sub: SubscriptionInfo | null | undefined,
|
|
189
|
+
now: Date = new Date()
|
|
190
|
+
): UsageWindow {
|
|
191
|
+
const start = parse(sub?.currentPeriodStart);
|
|
192
|
+
const end = parse(sub?.currentPeriodEnd);
|
|
193
|
+
|
|
194
|
+
if (start && end && end.getTime() > now.getTime() && start.getTime() <= now.getTime()) {
|
|
195
|
+
return { start, end };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const monthStart = startOfUtcMonth(now);
|
|
199
|
+
const monthEnd = new Date(
|
|
200
|
+
Date.UTC(monthStart.getUTCFullYear(), monthStart.getUTCMonth() + 1, 1)
|
|
201
|
+
);
|
|
202
|
+
return { start: monthStart, end: monthEnd };
|
|
203
|
+
}
|