@startsimpli/billing 0.4.14 → 0.4.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
4
4
|
"description": "Universal billing integration for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -18,15 +18,6 @@
|
|
|
18
18
|
"publishConfig": {
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build": "tsup",
|
|
23
|
-
"dev": "tsup --watch",
|
|
24
|
-
"type-check": "tsc --noEmit",
|
|
25
|
-
"test": "vitest run",
|
|
26
|
-
"test:watch": "vitest",
|
|
27
|
-
"test:coverage": "vitest run --coverage",
|
|
28
|
-
"clean": "rm -rf dist"
|
|
29
|
-
},
|
|
30
21
|
"peerDependencies": {
|
|
31
22
|
"next": ">=14.0.0",
|
|
32
23
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -54,5 +45,14 @@
|
|
|
54
45
|
"nextjs",
|
|
55
46
|
"django",
|
|
56
47
|
"startsimpli"
|
|
57
|
-
]
|
|
58
|
-
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"type-check": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"test:coverage": "vitest run --coverage",
|
|
56
|
+
"clean": "rm -rf dist"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect, vi } from "vitest";
|
|
17
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
17
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
18
18
|
import userEvent from "@testing-library/user-event";
|
|
19
19
|
import React from "react";
|
|
20
20
|
import { BillingProvider } from "../hooks/BillingProvider";
|
|
@@ -1212,4 +1212,71 @@ describe("SubscriptionManager", () => {
|
|
|
1212
1212
|
// strictly above proCurrent.offer.sortOrder=1.
|
|
1213
1213
|
expect(upgrade?.textContent).toMatch(/upgrade to enterprise/i);
|
|
1214
1214
|
});
|
|
1215
|
+
|
|
1216
|
+
// Regression: the subscription/current endpoint's Redis cache path returns a
|
|
1217
|
+
// FLAT payload (offerId/offerSlug/productSlug, NO nested `offer`). The UI used
|
|
1218
|
+
// to deref `subscription.offer.isFree` and white-screen ("client-side
|
|
1219
|
+
// exception"). BillingApiClient now hydrates the offer from the product, so
|
|
1220
|
+
// the rich CurrentPlanCard renders instead. startsim — billing-offer-null.
|
|
1221
|
+
it("hydrates the flat cache-shape subscription payload instead of crashing", async () => {
|
|
1222
|
+
const flatCachePayload = {
|
|
1223
|
+
// Shape of CachedSubscriptionResponseSerializer after the camelCase proxy:
|
|
1224
|
+
subscriptionId: "sub-cache-1",
|
|
1225
|
+
providerSubscriptionId: "stripe_sub_1",
|
|
1226
|
+
status: "active",
|
|
1227
|
+
customerId: "cus_1",
|
|
1228
|
+
priceId: "price_1",
|
|
1229
|
+
offerId: "offer-2", // Pro
|
|
1230
|
+
productSlug: "raise-simpli",
|
|
1231
|
+
offerSlug: "pro-monthly",
|
|
1232
|
+
currentPeriodStart: 1735689600, // unix seconds, not ISO
|
|
1233
|
+
currentPeriodEnd: 1738368000,
|
|
1234
|
+
cancelAtPeriodEnd: false,
|
|
1235
|
+
trialEnd: null,
|
|
1236
|
+
paymentMethod: null,
|
|
1237
|
+
};
|
|
1238
|
+
const fetcher = mockFetchRouter({
|
|
1239
|
+
"/billing/subscription/current/": flatCachePayload,
|
|
1240
|
+
"/billing/products/": mockProduct,
|
|
1241
|
+
});
|
|
1242
|
+
const Wrapper = createWrapper(fetcher);
|
|
1243
|
+
|
|
1244
|
+
render(
|
|
1245
|
+
<Wrapper>
|
|
1246
|
+
<SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
|
|
1247
|
+
</Wrapper>
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
// Renders the rich current-plan card (Pro) — proves no crash AND that the
|
|
1251
|
+
// offer was hydrated from the product rather than dropped.
|
|
1252
|
+
await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
|
|
1253
|
+
const card = screen.getByTestId("current-plan");
|
|
1254
|
+
expect(within(card).getByText("Pro")).toBeDefined();
|
|
1255
|
+
// Active paid sub → "Next billing date" derived from the unix-seconds period.
|
|
1256
|
+
expect(within(card).getByTestId("next-billing-date")).toBeDefined();
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
it("degrades to 'no active subscription' when a flat payload can't resolve an offer", async () => {
|
|
1260
|
+
const fetcher = mockFetchRouter({
|
|
1261
|
+
"/billing/subscription/current/": {
|
|
1262
|
+
status: "active",
|
|
1263
|
+
offerId: "does-not-exist",
|
|
1264
|
+
offerSlug: "ghost",
|
|
1265
|
+
productSlug: "raise-simpli",
|
|
1266
|
+
},
|
|
1267
|
+
"/billing/products/": mockProduct,
|
|
1268
|
+
});
|
|
1269
|
+
const Wrapper = createWrapper(fetcher);
|
|
1270
|
+
|
|
1271
|
+
render(
|
|
1272
|
+
<Wrapper>
|
|
1273
|
+
<SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
|
|
1274
|
+
</Wrapper>
|
|
1275
|
+
);
|
|
1276
|
+
|
|
1277
|
+
// No crash; falls through to the empty-state copy.
|
|
1278
|
+
await waitFor(() =>
|
|
1279
|
+
expect(screen.getByText("No active subscription")).toBeDefined()
|
|
1280
|
+
);
|
|
1281
|
+
});
|
|
1215
1282
|
});
|
|
@@ -228,8 +228,13 @@ export function SubscriptionManager({
|
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
// A subscription is only renderable if it carries a nested `offer` object —
|
|
232
|
+
// the cache-path payload (offerId/offerSlug, no `offer`) is normalized away in
|
|
233
|
+
// BillingApiClient, but guard here too so CurrentPlanCard can never deref
|
|
234
|
+
// `subscription.offer.*` on undefined and white-screen the page.
|
|
235
|
+
const usableSubscription = subscription?.offer ? subscription : null;
|
|
236
|
+
const currentOfferId = usableSubscription?.offer?.id ?? null;
|
|
237
|
+
const currentOffer = usableSubscription?.offer ?? null;
|
|
233
238
|
// Show ALL offers in the Change Plan grid (not just non-current ones) so users
|
|
234
239
|
// can compare and see what they have. The card matching their current sub
|
|
235
240
|
// gets a "Current Plan" badge + disabled CTA. startsim-1f1.
|
|
@@ -339,30 +344,30 @@ export function SubscriptionManager({
|
|
|
339
344
|
</div>
|
|
340
345
|
)}
|
|
341
346
|
|
|
342
|
-
{
|
|
347
|
+
{usableSubscription ? (
|
|
343
348
|
<>
|
|
344
349
|
{/* Non-owner team members get a clear read-only callout. The OWNER
|
|
345
350
|
gates that follow (Upgrade CTA, portal button, plan-change CTAs)
|
|
346
351
|
all flip off when viewerIsOwner === false. startsim-wo9. */}
|
|
347
|
-
{
|
|
352
|
+
{usableSubscription.viewerIsOwner === false && (
|
|
348
353
|
<div
|
|
349
354
|
data-testid="non-owner-billing-notice"
|
|
350
355
|
className="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900"
|
|
351
356
|
role="status"
|
|
352
357
|
>
|
|
353
358
|
You're viewing your team's billing.{" "}
|
|
354
|
-
{
|
|
355
|
-
? <>The team owner <span className="font-medium">{
|
|
359
|
+
{usableSubscription.ownerEmail
|
|
360
|
+
? <>The team owner <span className="font-medium">{usableSubscription.ownerEmail}</span> manages plan changes.</>
|
|
356
361
|
: <>The team owner manages plan changes.</>
|
|
357
362
|
}
|
|
358
363
|
</div>
|
|
359
364
|
)}
|
|
360
365
|
<CurrentPlanCard
|
|
361
|
-
subscription={
|
|
366
|
+
subscription={usableSubscription}
|
|
362
367
|
usage={usage}
|
|
363
|
-
onPortalClick={
|
|
368
|
+
onPortalClick={usableSubscription.viewerIsOwner === false ? undefined : handlePortalClick}
|
|
364
369
|
upgradeCta={
|
|
365
|
-
|
|
370
|
+
usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
|
|
366
371
|
<button
|
|
367
372
|
data-testid="upgrade-cta-current-plan"
|
|
368
373
|
onClick={() => handleOfferClick(featuredPaidOffer)}
|
|
@@ -501,7 +506,7 @@ export function SubscriptionManager({
|
|
|
501
506
|
Free-tier users (provider='none') have no Stripe customer, so the
|
|
502
507
|
portal call would 404 — they get the Upgrade CTA on the current-plan
|
|
503
508
|
card instead. startsim-b1z. */}
|
|
504
|
-
{
|
|
509
|
+
{usableSubscription && usableSubscription.status !== "cancelled" && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
|
|
505
510
|
<div className="pt-4 border-t border-gray-100">
|
|
506
511
|
<button
|
|
507
512
|
data-testid="portal-button"
|
package/src/utils/api.ts
CHANGED
|
@@ -149,6 +149,61 @@ export class BillingApiClient {
|
|
|
149
149
|
data.detail ?? `Failed to fetch subscription: ${resp.status}`
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
|
-
return resp.json();
|
|
152
|
+
return this.normalizeSubscription(await resp.json());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `subscription/current/` endpoint is polymorphic: the DB path returns
|
|
157
|
+
* the rich SubscriptionInfo (nested `offer` object), but the Redis cache path
|
|
158
|
+
* returns the flat CachedSubscriptionState (offerId/offerSlug/productSlug, no
|
|
159
|
+
* nested `offer`). The UI reads `subscription.offer.*` unconditionally, so a
|
|
160
|
+
* flat payload white-screens the page. This guard hydrates the flat shape
|
|
161
|
+
* into the rich one by resolving the offer from the product; if it can't, it
|
|
162
|
+
* returns null ("no usable subscription") rather than letting the page crash.
|
|
163
|
+
* Defense-in-depth against backend contract drift. startsim — billing-offer-null.
|
|
164
|
+
*/
|
|
165
|
+
private async normalizeSubscription(raw: unknown): Promise<SubscriptionInfo | null> {
|
|
166
|
+
if (!raw || typeof raw !== "object") return null;
|
|
167
|
+
const r = raw as Record<string, unknown>;
|
|
168
|
+
|
|
169
|
+
// Already the rich shape — nested offer object present.
|
|
170
|
+
if (r.offer && typeof r.offer === "object") return raw as SubscriptionInfo;
|
|
171
|
+
|
|
172
|
+
// Flat cache shape: try to hydrate the offer from the product.
|
|
173
|
+
const productSlug = r.productSlug as string | undefined;
|
|
174
|
+
const offerId = r.offerId as string | undefined;
|
|
175
|
+
const offerSlug = r.offerSlug as string | undefined;
|
|
176
|
+
const status = r.status as string | undefined;
|
|
177
|
+
// No status or nothing to resolve an offer from → not a renderable sub.
|
|
178
|
+
if (!status || status === "none" || !productSlug || (!offerId && !offerSlug)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const product = await this.getProduct(productSlug);
|
|
184
|
+
const offer = product.offers.find(
|
|
185
|
+
(o) => (offerId && o.id === offerId) || (offerSlug && o.slug === offerSlug)
|
|
186
|
+
);
|
|
187
|
+
if (!offer) return null;
|
|
188
|
+
const toIso = (v: unknown): string | null => {
|
|
189
|
+
if (v == null) return null;
|
|
190
|
+
// Cache stores unix seconds; SubscriptionInfo expects ISO strings.
|
|
191
|
+
if (typeof v === "number") return new Date(v * 1000).toISOString();
|
|
192
|
+
if (typeof v === "string") return v;
|
|
193
|
+
return null;
|
|
194
|
+
};
|
|
195
|
+
return {
|
|
196
|
+
id: (r.subscriptionId as string) ?? offer.id,
|
|
197
|
+
offer,
|
|
198
|
+
status: status as SubscriptionInfo["status"],
|
|
199
|
+
currentPeriodStart: toIso(r.currentPeriodStart),
|
|
200
|
+
currentPeriodEnd: toIso(r.currentPeriodEnd),
|
|
201
|
+
cancelAtPeriodEnd: Boolean(r.cancelAtPeriodEnd),
|
|
202
|
+
trialEnd: toIso(r.trialEnd),
|
|
203
|
+
};
|
|
204
|
+
} catch {
|
|
205
|
+
// Product fetch failed — degrade to "no subscription" instead of crashing.
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
153
208
|
}
|
|
154
209
|
}
|