@startsimpli/billing 0.4.10 → 0.4.14
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/README.md +285 -87
- package/package.json +3 -2
- package/src/components/CurrentPlanCard.test.tsx +289 -0
- package/src/components/CurrentPlanCard.tsx +317 -0
- package/src/components/LimitNudge.test.tsx +132 -0
- package/src/components/LimitNudge.tsx +84 -0
- package/src/components/SubscriptionManager.test.tsx +704 -19
- package/src/components/SubscriptionManager.tsx +330 -79
- package/src/components/index.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUsage.ts +94 -0
- package/src/index.ts +7 -1
- package/src/mock/__tests__/mock-backend.test.ts +128 -0
- package/src/mock/backend.ts +25 -0
- package/src/mock/index.ts +7 -0
- package/src/mock/mock-backend.ts +103 -0
- package/src/types/index.ts +44 -1
- package/src/utils/api.ts +16 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock billing backend (parallel to @startsimpli/auth's createMockAuthBackend):
|
|
3
|
+
* a backend-agnostic BillingBackend impl seeded with demo accounts. Used by
|
|
4
|
+
* apps without a live Stripe/Apple/Google integration (e.g. the MCR demo).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { createMockBillingBackend } from '../mock-backend';
|
|
8
|
+
import type { SubscriptionInfo, PaymentMethod, Invoice, PayoutRequest } from '../../types';
|
|
9
|
+
|
|
10
|
+
const offer = {
|
|
11
|
+
id: 'pro',
|
|
12
|
+
name: 'MCR Pro',
|
|
13
|
+
slug: 'pro',
|
|
14
|
+
unitPrice: '10.00',
|
|
15
|
+
currency: 'USD',
|
|
16
|
+
pricingModel: 'flat' as const,
|
|
17
|
+
intervalUnit: 'month' as const,
|
|
18
|
+
intervalCount: 1,
|
|
19
|
+
intervalDisplay: 'monthly',
|
|
20
|
+
isRecurring: true,
|
|
21
|
+
isFree: false,
|
|
22
|
+
features: [],
|
|
23
|
+
isActive: true,
|
|
24
|
+
isFeatured: true,
|
|
25
|
+
trialDays: 0,
|
|
26
|
+
sortOrder: 1,
|
|
27
|
+
ctaText: 'Subscribe',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sub: SubscriptionInfo = {
|
|
31
|
+
id: 'sub_1',
|
|
32
|
+
offer,
|
|
33
|
+
status: 'active',
|
|
34
|
+
currentPeriodStart: '2026-05-01T00:00:00Z',
|
|
35
|
+
currentPeriodEnd: '2026-06-01T00:00:00Z',
|
|
36
|
+
cancelAtPeriodEnd: false,
|
|
37
|
+
trialEnd: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const pm: PaymentMethod = { brand: 'visa', last4: '4242' };
|
|
41
|
+
|
|
42
|
+
const invoice: Invoice = {
|
|
43
|
+
id: 'inv_1',
|
|
44
|
+
date: '2026-05-01T00:00:00Z',
|
|
45
|
+
amount: '10.00',
|
|
46
|
+
currency: 'USD',
|
|
47
|
+
status: 'paid',
|
|
48
|
+
description: 'MCR Pro — May 2026',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const payout: PayoutRequest = {
|
|
52
|
+
id: 'po_1',
|
|
53
|
+
requestedAt: '2026-04-15T00:00:00Z',
|
|
54
|
+
paidAt: '2026-04-18T00:00:00Z',
|
|
55
|
+
amount: '5000.00',
|
|
56
|
+
currency: 'USD',
|
|
57
|
+
status: 'paid',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const seedAccount = {
|
|
61
|
+
id: 'amr',
|
|
62
|
+
subscription: sub,
|
|
63
|
+
paymentMethod: pm,
|
|
64
|
+
invoices: [invoice],
|
|
65
|
+
payouts: [payout],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('createMockBillingBackend', () => {
|
|
69
|
+
it('returns the seeded subscription for a known account', async () => {
|
|
70
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
71
|
+
const s = await b.getSubscription('amr');
|
|
72
|
+
expect(s?.status).toBe('active');
|
|
73
|
+
expect(s?.offer.unitPrice).toBe('10.00');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns null for an unknown account', async () => {
|
|
77
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
78
|
+
expect(await b.getSubscription('ghost')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns the seeded payment method', async () => {
|
|
82
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
83
|
+
expect(await b.getPaymentMethod('amr')).toEqual(pm);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns the seeded invoices', async () => {
|
|
87
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
88
|
+
const invoices = await b.listInvoices('amr');
|
|
89
|
+
expect(invoices).toHaveLength(1);
|
|
90
|
+
expect(invoices[0].status).toBe('paid');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns the seeded payouts', async () => {
|
|
94
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
95
|
+
expect((await b.listPayouts('amr'))).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('requestPayout appends a new "requested" payout and listPayouts shows it', async () => {
|
|
99
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
100
|
+
const before = (await b.listPayouts('amr')).length;
|
|
101
|
+
const created = await b.requestPayout('amr', '1234.56', 'USD');
|
|
102
|
+
expect(created.status).toBe('requested');
|
|
103
|
+
expect(created.amount).toBe('1234.56');
|
|
104
|
+
expect(created.paidAt).toBeNull();
|
|
105
|
+
const after = await b.listPayouts('amr');
|
|
106
|
+
expect(after).toHaveLength(before + 1);
|
|
107
|
+
expect(after.find((p) => p.id === created.id)).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('requestPayout for an unknown account throws', async () => {
|
|
111
|
+
const b = createMockBillingBackend({ accounts: [seedAccount] });
|
|
112
|
+
await expect(b.requestPayout('ghost', '10', 'USD')).rejects.toThrow(/no account/i);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('lists empty arrays for accounts with no seeded data', async () => {
|
|
116
|
+
const b = createMockBillingBackend({ accounts: [{ id: 'fresh', subscription: null }] });
|
|
117
|
+
expect(await b.getSubscription('fresh')).toBeNull();
|
|
118
|
+
expect(await b.getPaymentMethod('fresh')).toBeNull();
|
|
119
|
+
expect(await b.listInvoices('fresh')).toEqual([]);
|
|
120
|
+
expect(await b.listPayouts('fresh')).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('upsertAccount adds an account at runtime', async () => {
|
|
124
|
+
const b = createMockBillingBackend({ accounts: [] });
|
|
125
|
+
b.upsertAccount({ id: 'late', subscription: sub });
|
|
126
|
+
expect((await b.getSubscription('late'))?.status).toBe('active');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-agnostic billing contract.
|
|
3
|
+
*
|
|
4
|
+
* The Stripe/Apple/Google adapters that ship with this package are web-bound;
|
|
5
|
+
* a BillingBackend lets apps (incl. backendless demos, React Native apps,
|
|
6
|
+
* Storybooks) drive Plan/Subscription/Invoice/Payout state without those.
|
|
7
|
+
* Mirrors the AuthBackend pattern in @startsimpli/auth.
|
|
8
|
+
*
|
|
9
|
+
* The method set is exactly what a "billing surface" needs — nothing here is
|
|
10
|
+
* Stripe-specific.
|
|
11
|
+
*/
|
|
12
|
+
import type { Invoice, PaymentMethod, PayoutRequest, SubscriptionInfo } from '../types';
|
|
13
|
+
|
|
14
|
+
export interface BillingBackend {
|
|
15
|
+
/** Current subscription for the account, or null if none. */
|
|
16
|
+
getSubscription(accountId: string): Promise<SubscriptionInfo | null>;
|
|
17
|
+
/** Payment method on file, or null. */
|
|
18
|
+
getPaymentMethod(accountId: string): Promise<PaymentMethod | null>;
|
|
19
|
+
/** Invoices in reverse chronological order. */
|
|
20
|
+
listInvoices(accountId: string): Promise<Invoice[]>;
|
|
21
|
+
/** Past payout requests (most recent first). */
|
|
22
|
+
listPayouts(accountId: string): Promise<PayoutRequest[]>;
|
|
23
|
+
/** Open a new payout. Implementations may queue, settle async, etc. */
|
|
24
|
+
requestPayout(accountId: string, amount: string, currency: string): Promise<PayoutRequest>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory BillingBackend for backendless apps (demos, offline-first,
|
|
3
|
+
* Storybook). Implements the full BillingBackend contract plus mock-only
|
|
4
|
+
* helpers (upsertAccount, addInvoice). No network, no Stripe.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
Invoice,
|
|
8
|
+
PaymentMethod,
|
|
9
|
+
PayoutRequest,
|
|
10
|
+
SubscriptionInfo,
|
|
11
|
+
} from '../types';
|
|
12
|
+
import type { BillingBackend } from './backend';
|
|
13
|
+
|
|
14
|
+
export interface MockBillingAccount {
|
|
15
|
+
id: string;
|
|
16
|
+
subscription: SubscriptionInfo | null;
|
|
17
|
+
paymentMethod?: PaymentMethod;
|
|
18
|
+
invoices?: Invoice[];
|
|
19
|
+
payouts?: PayoutRequest[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MockBillingBackendOptions {
|
|
23
|
+
accounts: MockBillingAccount[];
|
|
24
|
+
/** Injectable clock (tests). Default Date.now. */
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MockBillingBackend extends BillingBackend {
|
|
29
|
+
/** Add or replace an account at runtime. */
|
|
30
|
+
upsertAccount(account: MockBillingAccount): void;
|
|
31
|
+
/** Append an invoice to an account's history. */
|
|
32
|
+
addInvoice(accountId: string, invoice: Invoice): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let _seq = 0;
|
|
36
|
+
function genId(prefix: string): string {
|
|
37
|
+
_seq += 1;
|
|
38
|
+
return `${prefix}_${Date.now().toString(36)}_${_seq}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMockBillingBackend(
|
|
42
|
+
options: MockBillingBackendOptions
|
|
43
|
+
): MockBillingBackend {
|
|
44
|
+
const now = options.now ?? (() => Date.now());
|
|
45
|
+
|
|
46
|
+
interface Record_ {
|
|
47
|
+
subscription: SubscriptionInfo | null;
|
|
48
|
+
paymentMethod: PaymentMethod | null;
|
|
49
|
+
invoices: Invoice[];
|
|
50
|
+
payouts: PayoutRequest[];
|
|
51
|
+
}
|
|
52
|
+
const accounts = new Map<string, Record_>();
|
|
53
|
+
const seed = (a: MockBillingAccount) => {
|
|
54
|
+
accounts.set(a.id, {
|
|
55
|
+
subscription: a.subscription,
|
|
56
|
+
paymentMethod: a.paymentMethod ?? null,
|
|
57
|
+
invoices: [...(a.invoices ?? [])],
|
|
58
|
+
payouts: [...(a.payouts ?? [])],
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
options.accounts.forEach(seed);
|
|
62
|
+
|
|
63
|
+
function require_(id: string): Record_ {
|
|
64
|
+
const a = accounts.get(id);
|
|
65
|
+
if (!a) throw new Error(`No account: ${id}`);
|
|
66
|
+
return a;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async getSubscription(id) {
|
|
71
|
+
return accounts.get(id)?.subscription ?? null;
|
|
72
|
+
},
|
|
73
|
+
async getPaymentMethod(id) {
|
|
74
|
+
return accounts.get(id)?.paymentMethod ?? null;
|
|
75
|
+
},
|
|
76
|
+
async listInvoices(id) {
|
|
77
|
+
return accounts.get(id)?.invoices ?? [];
|
|
78
|
+
},
|
|
79
|
+
async listPayouts(id) {
|
|
80
|
+
return accounts.get(id)?.payouts ?? [];
|
|
81
|
+
},
|
|
82
|
+
async requestPayout(id, amount, currency) {
|
|
83
|
+
const account = require_(id);
|
|
84
|
+
const payout: PayoutRequest = {
|
|
85
|
+
id: genId('po'),
|
|
86
|
+
requestedAt: new Date(now()).toISOString(),
|
|
87
|
+
paidAt: null,
|
|
88
|
+
amount,
|
|
89
|
+
currency,
|
|
90
|
+
status: 'requested',
|
|
91
|
+
};
|
|
92
|
+
account.payouts = [payout, ...account.payouts];
|
|
93
|
+
return payout;
|
|
94
|
+
},
|
|
95
|
+
upsertAccount(account) {
|
|
96
|
+
seed(account);
|
|
97
|
+
},
|
|
98
|
+
addInvoice(id, invoice) {
|
|
99
|
+
const account = require_(id);
|
|
100
|
+
account.invoices = [invoice, ...account.invoices];
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface OfferFeature {
|
|
|
34
34
|
tooltip?: string | null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Per-product usage payload from GET /api/v1/billing/usage/<product>/.
|
|
38
|
+
* Keys match OfferFeature.key; limit -1 == unlimited; null limit == hide bar.
|
|
39
|
+
* Empty {} is a valid response (no adapter / no sub / errors) — UI must
|
|
40
|
+
* tolerate it. startsim-h14. */
|
|
41
|
+
export type UsageMap = Record<string, { used: number; limit: number | null }>;
|
|
42
|
+
|
|
37
43
|
export interface BillingProduct {
|
|
38
44
|
id: string;
|
|
39
45
|
slug: string;
|
|
@@ -59,11 +65,17 @@ export interface PortalResult {
|
|
|
59
65
|
export interface SubscriptionInfo {
|
|
60
66
|
id: string;
|
|
61
67
|
offer: ProductOffer;
|
|
62
|
-
status: "active" | "trialing" | "past_due" | "cancelled" | "incomplete" | "incomplete_expired" | "unpaid";
|
|
68
|
+
status: "active" | "trialing" | "past_due" | "cancelled" | "incomplete" | "incomplete_expired" | "unpaid" | "trial_expired";
|
|
63
69
|
currentPeriodStart: string | null;
|
|
64
70
|
currentPeriodEnd: string | null;
|
|
65
71
|
cancelAtPeriodEnd: boolean;
|
|
66
72
|
trialEnd: string | null;
|
|
73
|
+
/** True when the viewing user is the OWNER of the team the subscription
|
|
74
|
+
* belongs to. Drives the UI's read-only billing view for non-owner
|
|
75
|
+
* team members ('Owner manages billing'). startsim-wo9. */
|
|
76
|
+
viewerIsOwner?: boolean;
|
|
77
|
+
/** Email of the team owner so non-owners can be told who to ask. */
|
|
78
|
+
ownerEmail?: string | null;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
export interface PaymentMethod {
|
|
@@ -87,6 +99,37 @@ export interface CachedSubscriptionState {
|
|
|
87
99
|
paymentMethod: PaymentMethod | null;
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
export type InvoiceStatus = 'paid' | 'open' | 'past_due' | 'void';
|
|
103
|
+
|
|
104
|
+
export interface Invoice {
|
|
105
|
+
id: string;
|
|
106
|
+
/** ISO timestamp of when the invoice was issued. */
|
|
107
|
+
date: string;
|
|
108
|
+
/** Decimal as a string (mirrors DRF's Money serialization). */
|
|
109
|
+
amount: string;
|
|
110
|
+
currency: string;
|
|
111
|
+
status: InvoiceStatus;
|
|
112
|
+
description: string;
|
|
113
|
+
/** Optional link to a hosted receipt / PDF. */
|
|
114
|
+
receiptUrl?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type PayoutStatus = 'requested' | 'processing' | 'paid' | 'failed';
|
|
118
|
+
|
|
119
|
+
/** A request to disburse funds to the account holder (e.g. an artist's
|
|
120
|
+
* collected royalties). Generic enough for any payout flow. */
|
|
121
|
+
export interface PayoutRequest {
|
|
122
|
+
id: string;
|
|
123
|
+
/** ISO timestamp of when the payout was requested. */
|
|
124
|
+
requestedAt: string;
|
|
125
|
+
/** ISO timestamp of when the payout cleared, or null while pending. */
|
|
126
|
+
paidAt: string | null;
|
|
127
|
+
/** Decimal as a string. */
|
|
128
|
+
amount: string;
|
|
129
|
+
currency: string;
|
|
130
|
+
status: PayoutStatus;
|
|
131
|
+
}
|
|
132
|
+
|
|
90
133
|
export interface BillingConfig {
|
|
91
134
|
/**
|
|
92
135
|
* Base path for billing API calls.
|
package/src/utils/api.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
FreeSubscriptionResult,
|
|
11
11
|
PortalResult,
|
|
12
12
|
SubscriptionInfo,
|
|
13
|
+
UsageMap,
|
|
13
14
|
} from "../types";
|
|
14
15
|
|
|
15
16
|
export class BillingApiClient {
|
|
@@ -118,6 +119,21 @@ export class BillingApiClient {
|
|
|
118
119
|
return resp.json();
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** Per-product usage map for the current user/team — {feature_key:
|
|
123
|
+
* {used, limit}}. Graceful: 200 {} when no adapter / no sub / errors.
|
|
124
|
+
* startsim-h14 — feeds CurrentPlanCard progress bars.
|
|
125
|
+
*/
|
|
126
|
+
async getUsage(slug: string): Promise<UsageMap> {
|
|
127
|
+
const url = `${this.config.apiBaseUrl}/billing/usage/${slug}/`;
|
|
128
|
+
const resp = await this.fetcher(url, { headers: this.headers });
|
|
129
|
+
if (!resp.ok) {
|
|
130
|
+
// Treat any non-2xx as "no data" — usage failures must not block the
|
|
131
|
+
// page. UI hides the bars when the map is empty.
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
return (await resp.json()) as UsageMap;
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
/** Get current user's subscription. */
|
|
122
138
|
async getSubscription(): Promise<SubscriptionInfo | null> {
|
|
123
139
|
const url = `${this.config.apiBaseUrl}/billing/subscription/current/`;
|