gatefit 0.0.0-stage → 0.5.0
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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/action.yml +23 -0
- package/bin/gatefit.js +32 -0
- package/package.json +56 -4
- package/scripts/action-run.sh +18 -0
- package/src/access-adapter.ts +90 -0
- package/src/access-rules.ts +123 -0
- package/src/adapters/hardened.ts +22 -0
- package/src/adapters/naive.ts +25 -0
- package/src/adapters/sqlite.ts +83 -0
- package/src/chaos.ts +169 -0
- package/src/cli.ts +166 -0
- package/src/clock-driver.ts +52 -0
- package/src/fixture-driver.ts +84 -0
- package/src/license.ts +160 -0
- package/src/live-scenarios.ts +191 -0
- package/src/packs.ts +27 -0
- package/src/paths.ts +31 -0
- package/src/stripe-clock.ts +153 -0
- package/tests/entitlements/goldens/cancel_at_period_end_keeps_access_until_end.yaml +36 -0
- package/tests/entitlements/goldens/cancel_revokes_access.yaml +18 -0
- package/tests/entitlements/goldens/dispute_revokes_or_flags.yaml +29 -0
- package/tests/entitlements/goldens/duplicate_webhook_idempotent.yaml +33 -0
- package/tests/entitlements/goldens/paid_grants_access.yaml +11 -0
- package/tests/entitlements/goldens/past_due_denies_until_recovery.yaml +26 -0
- package/tests/entitlements/goldens/refund_revokes_access.yaml +18 -0
- package/tests/entitlements/goldens/seat_qty_vs_db_cap.yaml +29 -0
- package/tests/entitlements/goldens/trial_to_paid_grants.yaml +19 -0
- package/tests/entitlements/goldens/unpaid_after_retries_revokes.yaml +40 -0
- package/tests/entitlements/goldens/upgrade_plus_cape_race.yaml +46 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { AccessAdapter, AccessSnapshot, BillingEvent } from "./access-adapter.ts";
|
|
2
|
+
import { compareAccess } from "./access-adapter.ts";
|
|
3
|
+
import { advanceUntilReady, withTestClock, type ClockWaitOpts } from "./clock-driver.ts";
|
|
4
|
+
import {
|
|
5
|
+
MAX_CUSTOMERS_PER_CLOCK,
|
|
6
|
+
MAX_SUBSCRIPTIONS_PER_CLOCK,
|
|
7
|
+
type StripeClockClient,
|
|
8
|
+
type StripeInvoice,
|
|
9
|
+
type StripeSubscription,
|
|
10
|
+
} from "./stripe-clock.ts";
|
|
11
|
+
|
|
12
|
+
export const LIVE_SCENARIO_IDS = [
|
|
13
|
+
"paid_grants_access",
|
|
14
|
+
"cancel_revokes_access",
|
|
15
|
+
"trial_to_paid_grants",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type LiveScenarioId = (typeof LIVE_SCENARIO_IDS)[number];
|
|
19
|
+
|
|
20
|
+
export type LiveScenarioResult = {
|
|
21
|
+
id: LiveScenarioId;
|
|
22
|
+
ok: boolean;
|
|
23
|
+
mismatches: string[];
|
|
24
|
+
customerId?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function billingEventsFromStripe(input: {
|
|
28
|
+
customerId: string;
|
|
29
|
+
plan: string;
|
|
30
|
+
subscription: StripeSubscription;
|
|
31
|
+
invoices: StripeInvoice[];
|
|
32
|
+
stripeHttpStatus?: number;
|
|
33
|
+
now?: number;
|
|
34
|
+
}): BillingEvent[] {
|
|
35
|
+
void input.stripeHttpStatus;
|
|
36
|
+
const events: BillingEvent[] = [];
|
|
37
|
+
const quantity = input.subscription.items?.data?.[0]?.quantity;
|
|
38
|
+
if (input.subscription.status === "trialing") {
|
|
39
|
+
events.push({
|
|
40
|
+
id: `${input.subscription.id}:trialing`,
|
|
41
|
+
type: "customer.subscription.updated",
|
|
42
|
+
customerId: input.customerId,
|
|
43
|
+
status: "trialing",
|
|
44
|
+
plan: input.plan,
|
|
45
|
+
});
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
for (const invoice of input.invoices) {
|
|
49
|
+
if (invoice.paid === true || invoice.status === "paid") {
|
|
50
|
+
events.push({
|
|
51
|
+
id: invoice.id,
|
|
52
|
+
type: "invoice.paid",
|
|
53
|
+
customerId: input.customerId,
|
|
54
|
+
plan: input.plan,
|
|
55
|
+
paid: true,
|
|
56
|
+
...(quantity !== undefined ? { seats: quantity } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (input.subscription.status === "canceled" || input.subscription.status === "incomplete_expired") {
|
|
61
|
+
events.push({
|
|
62
|
+
id: `${input.subscription.id}:canceled`,
|
|
63
|
+
type: "customer.subscription.deleted",
|
|
64
|
+
customerId: input.customerId,
|
|
65
|
+
canceled: true,
|
|
66
|
+
});
|
|
67
|
+
return events;
|
|
68
|
+
}
|
|
69
|
+
if (input.subscription.cancel_at_period_end === true) {
|
|
70
|
+
events.push({
|
|
71
|
+
id: `${input.subscription.id}:cape`,
|
|
72
|
+
type: "customer.subscription.updated",
|
|
73
|
+
customerId: input.customerId,
|
|
74
|
+
plan: input.plan,
|
|
75
|
+
cancel_at_period_end: true,
|
|
76
|
+
period_end: input.subscription.current_period_end,
|
|
77
|
+
now: input.now,
|
|
78
|
+
...(quantity !== undefined ? { seats: quantity } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return events;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function expectedAccess(_id: LiveScenarioId, phase: "trial" | "paid" | "canceled"): AccessSnapshot {
|
|
85
|
+
if (phase === "paid") return { locked: false, plan: "pro" };
|
|
86
|
+
return { locked: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runLiveLane(
|
|
90
|
+
adapter: AccessAdapter,
|
|
91
|
+
client: StripeClockClient,
|
|
92
|
+
opts: {
|
|
93
|
+
scenarios?: LiveScenarioId[];
|
|
94
|
+
now?: () => number;
|
|
95
|
+
wait?: ClockWaitOpts;
|
|
96
|
+
} = {},
|
|
97
|
+
): Promise<{ ok: boolean; results: LiveScenarioResult[] }> {
|
|
98
|
+
const ids = opts.scenarios ?? [...LIVE_SCENARIO_IDS];
|
|
99
|
+
const results: LiveScenarioResult[] = [];
|
|
100
|
+
for (const id of ids) {
|
|
101
|
+
results.push(await runLiveScenario(adapter, client, id, { now: opts.now, wait: opts.wait }));
|
|
102
|
+
}
|
|
103
|
+
return { ok: results.every((row) => row.ok), results };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runLiveScenario(
|
|
107
|
+
adapter: AccessAdapter,
|
|
108
|
+
client: StripeClockClient,
|
|
109
|
+
id: LiveScenarioId,
|
|
110
|
+
opts: { now?: () => number; wait?: ClockWaitOpts } = {},
|
|
111
|
+
): Promise<LiveScenarioResult> {
|
|
112
|
+
if (!LIVE_SCENARIO_IDS.includes(id)) {
|
|
113
|
+
throw new Error(`unknown live scenario: ${id}`);
|
|
114
|
+
}
|
|
115
|
+
const frozenTime = opts.now?.() ?? Math.floor(Date.now() / 1000);
|
|
116
|
+
return withTestClock(client, { frozenTime, name: `gatefit-${id}` }, async (clock) => {
|
|
117
|
+
let customers = 0;
|
|
118
|
+
let subscriptions = 0;
|
|
119
|
+
const customer = await client.createCustomer({ testClock: clock.id });
|
|
120
|
+
customers += 1;
|
|
121
|
+
if (customers > MAX_CUSTOMERS_PER_CLOCK) {
|
|
122
|
+
throw new Error("too many customers on this clock");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (id === "trial_to_paid_grants") {
|
|
126
|
+
const sub = await client.createSubscription({ customer: customer.id, trialPeriodDays: 14 });
|
|
127
|
+
subscriptions += 1;
|
|
128
|
+
assertSubCap(subscriptions);
|
|
129
|
+
await applyAndCheck(adapter, client, customer.id, sub, expectedAccess(id, "trial"), {
|
|
130
|
+
now: clock.frozen_time,
|
|
131
|
+
});
|
|
132
|
+
const ready = await advanceUntilReady(client, clock.id, frozenTime + 15 * 24 * 60 * 60, opts.wait);
|
|
133
|
+
const after = await client.retrieveSubscription(sub.id);
|
|
134
|
+
return applyAndCheck(adapter, client, customer.id, after, expectedAccess(id, "paid"), {
|
|
135
|
+
id,
|
|
136
|
+
now: ready.frozen_time,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sub = await client.createSubscription({ customer: customer.id });
|
|
141
|
+
subscriptions += 1;
|
|
142
|
+
assertSubCap(subscriptions);
|
|
143
|
+
if (id === "paid_grants_access") {
|
|
144
|
+
return applyAndCheck(adapter, client, customer.id, sub, expectedAccess(id, "paid"), {
|
|
145
|
+
id,
|
|
146
|
+
now: clock.frozen_time,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
await applyAndCheck(adapter, client, customer.id, sub, expectedAccess(id, "paid"), {
|
|
150
|
+
now: clock.frozen_time,
|
|
151
|
+
});
|
|
152
|
+
const canceled = await client.cancelSubscription(sub.id);
|
|
153
|
+
return applyAndCheck(adapter, client, customer.id, canceled, expectedAccess(id, "canceled"), {
|
|
154
|
+
id,
|
|
155
|
+
now: clock.frozen_time,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertSubCap(subscriptions: number): void {
|
|
161
|
+
if (subscriptions > MAX_SUBSCRIPTIONS_PER_CLOCK) {
|
|
162
|
+
throw new Error("too many subscriptions on this clock");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function applyAndCheck(
|
|
167
|
+
adapter: AccessAdapter,
|
|
168
|
+
client: StripeClockClient,
|
|
169
|
+
customerId: string,
|
|
170
|
+
subscription: StripeSubscription,
|
|
171
|
+
expect: AccessSnapshot,
|
|
172
|
+
meta: { id?: LiveScenarioId; now?: number } = {},
|
|
173
|
+
): Promise<LiveScenarioResult> {
|
|
174
|
+
if (adapter.reset) await adapter.reset();
|
|
175
|
+
const invoices = await client.listInvoices(customerId);
|
|
176
|
+
const events = billingEventsFromStripe({
|
|
177
|
+
customerId,
|
|
178
|
+
plan: "pro",
|
|
179
|
+
subscription,
|
|
180
|
+
invoices,
|
|
181
|
+
now: meta.now,
|
|
182
|
+
});
|
|
183
|
+
for (const event of events) await adapter.apply(event);
|
|
184
|
+
const actual = await adapter.snapshot(customerId);
|
|
185
|
+
const cmp = compareAccess(actual, expect);
|
|
186
|
+
if (!meta.id) {
|
|
187
|
+
if (!cmp.ok) throw new Error(cmp.mismatches.join("; "));
|
|
188
|
+
return { id: "paid_grants_access", ok: true, mismatches: [], customerId };
|
|
189
|
+
}
|
|
190
|
+
return { id: meta.id, ok: cmp.ok, mismatches: cmp.mismatches, customerId };
|
|
191
|
+
}
|
package/src/packs.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pro pack registry. Chaos mode registers here and runs only after the license gate.
|
|
3
|
+
* The stub has no runner and is not a user-facing feature.
|
|
4
|
+
*/
|
|
5
|
+
export type ProPack = {
|
|
6
|
+
id: string;
|
|
7
|
+
run?: (ctx: { adapterPath: string; seed: string }) => Promise<number>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const proPacks = new Map<string, ProPack>();
|
|
11
|
+
|
|
12
|
+
export function registerProPack(pack: ProPack): void {
|
|
13
|
+
if (!pack.id || proPacks.has(pack.id)) {
|
|
14
|
+
throw new Error(`pro pack id is missing or already registered: ${pack.id}`);
|
|
15
|
+
}
|
|
16
|
+
proPacks.set(pack.id, pack);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getProPack(id: string): ProPack | undefined {
|
|
20
|
+
return proPacks.get(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isProPack(id: string): boolean {
|
|
24
|
+
return proPacks.has(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerProPack({ id: "pro-stub" });
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function packageRoot(): string {
|
|
6
|
+
return process.env.GATEFIT_PACKAGE_ROOT ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function userCwd(): string {
|
|
10
|
+
return process.env.INIT_CWD ?? process.cwd();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function goldenDir(): string {
|
|
14
|
+
return path.join(packageRoot(), "tests/entitlements/goldens");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readPackageVersion(): string {
|
|
18
|
+
const raw = fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8");
|
|
19
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
20
|
+
if (!pkg.version) throw new Error("package.json missing version");
|
|
21
|
+
return pkg.version;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveUserOrPackage(spec: string): string {
|
|
25
|
+
if (path.isAbsolute(spec) && fs.existsSync(spec)) return spec;
|
|
26
|
+
const fromUser = path.resolve(userCwd(), spec);
|
|
27
|
+
if (fs.existsSync(fromUser)) return fromUser;
|
|
28
|
+
const fromPkg = path.resolve(packageRoot(), spec);
|
|
29
|
+
if (fs.existsSync(fromPkg)) return fromPkg;
|
|
30
|
+
throw new Error(`path not found: ${spec}`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { assertNoLiveStripeSecret } from "./access-adapter.ts";
|
|
2
|
+
|
|
3
|
+
export const STRIPE_API_VERSION = "2025-10-29.clover";
|
|
4
|
+
export const TEST_PAYMENT_METHOD = "pm_card_visa";
|
|
5
|
+
export const MONTH_SECONDS = 30 * 24 * 60 * 60;
|
|
6
|
+
export const MAX_ADVANCE_INTERVALS = 2;
|
|
7
|
+
export const MAX_CUSTOMERS_PER_CLOCK = 3;
|
|
8
|
+
export const MAX_SUBSCRIPTIONS_PER_CLOCK = 3;
|
|
9
|
+
|
|
10
|
+
export type TestClock = {
|
|
11
|
+
id: string;
|
|
12
|
+
status: string;
|
|
13
|
+
frozen_time: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type StripeCustomer = { id: string };
|
|
17
|
+
export type StripeSubscription = {
|
|
18
|
+
id: string;
|
|
19
|
+
status: string;
|
|
20
|
+
customer: string;
|
|
21
|
+
cancel_at_period_end?: boolean;
|
|
22
|
+
current_period_end?: number;
|
|
23
|
+
items?: { data?: Array<{ quantity?: number }> };
|
|
24
|
+
};
|
|
25
|
+
export type StripeInvoice = { id: string; status: string; paid: boolean; customer: string };
|
|
26
|
+
|
|
27
|
+
export type StripeClockClient = {
|
|
28
|
+
createClock(input: { frozenTime: number; name?: string }): Promise<TestClock>;
|
|
29
|
+
retrieveClock(id: string): Promise<TestClock>;
|
|
30
|
+
advanceClock(id: string, frozenTime: number): Promise<TestClock>;
|
|
31
|
+
deleteClock(id: string): Promise<{ id: string; deleted: boolean }>;
|
|
32
|
+
createCustomer(input: { testClock: string; paymentMethod?: string }): Promise<StripeCustomer>;
|
|
33
|
+
createSubscription(input: { customer: string; trialPeriodDays?: number }): Promise<StripeSubscription>;
|
|
34
|
+
retrieveSubscription(id: string): Promise<StripeSubscription>;
|
|
35
|
+
cancelSubscription(id: string): Promise<StripeSubscription>;
|
|
36
|
+
listInvoices(customerId: string): Promise<StripeInvoice[]>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function requireLiveClockEnabled(env: NodeJS.ProcessEnv = process.env): void {
|
|
40
|
+
if (env.GATEFIT_LIVE_CLOCK !== "1") {
|
|
41
|
+
throw new Error("live lane is opt-in: set GATEFIT_LIVE_CLOCK=1");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function requireTestModeSecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
46
|
+
assertNoLiveStripeSecret(JSON.stringify(env));
|
|
47
|
+
const secret = env.STRIPE_SECRET_KEY ?? env.STRIPE_API_KEY ?? "";
|
|
48
|
+
assertNoLiveStripeSecret(secret);
|
|
49
|
+
if (!secret) {
|
|
50
|
+
throw new Error("live lane requires STRIPE_SECRET_KEY or STRIPE_API_KEY (sk_test_ only)");
|
|
51
|
+
}
|
|
52
|
+
if (!secret.startsWith("sk_test_")) {
|
|
53
|
+
throw new Error("refuse non-test Stripe secret: live lane requires sk_test_");
|
|
54
|
+
}
|
|
55
|
+
return secret;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function assertAdvanceWithinTwoIntervals(
|
|
59
|
+
from: number,
|
|
60
|
+
to: number,
|
|
61
|
+
intervalSeconds: number = MONTH_SECONDS,
|
|
62
|
+
): void {
|
|
63
|
+
if (to <= from) {
|
|
64
|
+
throw new Error("advance frozen_time must be after current frozen_time");
|
|
65
|
+
}
|
|
66
|
+
if (to > from + MAX_ADVANCE_INTERVALS * intervalSeconds) {
|
|
67
|
+
throw new Error("advance exceeds two billing intervals");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createStripeClockClient(opts: {
|
|
72
|
+
secret: string;
|
|
73
|
+
fetchImpl?: typeof fetch;
|
|
74
|
+
apiVersion?: string;
|
|
75
|
+
baseUrl?: string;
|
|
76
|
+
}): StripeClockClient {
|
|
77
|
+
requireTestModeSecret({ STRIPE_SECRET_KEY: opts.secret });
|
|
78
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
79
|
+
const apiVersion = opts.apiVersion ?? STRIPE_API_VERSION;
|
|
80
|
+
const baseUrl = (opts.baseUrl ?? "https://api.stripe.com").replace(/\/$/, "");
|
|
81
|
+
|
|
82
|
+
async function request<T>(method: string, path: string, form?: Record<string, string>): Promise<T> {
|
|
83
|
+
const headers: Record<string, string> = {
|
|
84
|
+
Authorization: `Bearer ${opts.secret}`,
|
|
85
|
+
"Stripe-Version": apiVersion,
|
|
86
|
+
};
|
|
87
|
+
const init: RequestInit = { method, headers };
|
|
88
|
+
if (form) {
|
|
89
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
90
|
+
init.body = new URLSearchParams(form).toString();
|
|
91
|
+
}
|
|
92
|
+
const res = await fetchImpl(`${baseUrl}${path}`, init);
|
|
93
|
+
const json = (await res.json()) as T & { error?: { message?: string } };
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const message = json.error?.message ?? `stripe ${method} ${path} failed`;
|
|
96
|
+
throw new Error(message);
|
|
97
|
+
}
|
|
98
|
+
return json;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
createClock(input) {
|
|
103
|
+
const form: Record<string, string> = { frozen_time: String(input.frozenTime) };
|
|
104
|
+
if (input.name) form.name = input.name;
|
|
105
|
+
return request<TestClock>("POST", "/v1/test_helpers/test_clocks", form);
|
|
106
|
+
},
|
|
107
|
+
retrieveClock(id) {
|
|
108
|
+
return request<TestClock>("GET", `/v1/test_helpers/test_clocks/${id}`);
|
|
109
|
+
},
|
|
110
|
+
advanceClock(id, frozenTime) {
|
|
111
|
+
return request<TestClock>("POST", `/v1/test_helpers/test_clocks/${id}/advance`, {
|
|
112
|
+
frozen_time: String(frozenTime),
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
deleteClock(id) {
|
|
116
|
+
return request<{ id: string; deleted: boolean }>("DELETE", `/v1/test_helpers/test_clocks/${id}`);
|
|
117
|
+
},
|
|
118
|
+
createCustomer(input) {
|
|
119
|
+
const form: Record<string, string> = {
|
|
120
|
+
test_clock: input.testClock,
|
|
121
|
+
payment_method: input.paymentMethod ?? TEST_PAYMENT_METHOD,
|
|
122
|
+
"invoice_settings[default_payment_method]": input.paymentMethod ?? TEST_PAYMENT_METHOD,
|
|
123
|
+
};
|
|
124
|
+
return request<StripeCustomer>("POST", "/v1/customers", form);
|
|
125
|
+
},
|
|
126
|
+
createSubscription(input) {
|
|
127
|
+
const form: Record<string, string> = {
|
|
128
|
+
customer: input.customer,
|
|
129
|
+
"items[0][price_data][currency]": "usd",
|
|
130
|
+
"items[0][price_data][product_data][name]": "gatefit-pro",
|
|
131
|
+
"items[0][price_data][recurring][interval]": "month",
|
|
132
|
+
"items[0][price_data][unit_amount]": "2000",
|
|
133
|
+
};
|
|
134
|
+
if (input.trialPeriodDays !== undefined) {
|
|
135
|
+
form.trial_period_days = String(input.trialPeriodDays);
|
|
136
|
+
}
|
|
137
|
+
return request<StripeSubscription>("POST", "/v1/subscriptions", form);
|
|
138
|
+
},
|
|
139
|
+
retrieveSubscription(id) {
|
|
140
|
+
return request<StripeSubscription>("GET", `/v1/subscriptions/${id}`);
|
|
141
|
+
},
|
|
142
|
+
cancelSubscription(id) {
|
|
143
|
+
return request<StripeSubscription>("DELETE", `/v1/subscriptions/${id}`);
|
|
144
|
+
},
|
|
145
|
+
async listInvoices(customerId) {
|
|
146
|
+
const payload = await request<{ data: StripeInvoice[] }>(
|
|
147
|
+
"GET",
|
|
148
|
+
`/v1/invoices?customer=${encodeURIComponent(customerId)}`,
|
|
149
|
+
);
|
|
150
|
+
return payload.data ?? [];
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
id: cancel_at_period_end_keeps_access_until_end
|
|
2
|
+
subject: cus_h6_cape
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_h6_cape_paid
|
|
6
|
+
type: invoice.paid
|
|
7
|
+
customerId: cus_h6_cape
|
|
8
|
+
plan: pro
|
|
9
|
+
seats: 1
|
|
10
|
+
expect:
|
|
11
|
+
locked: false
|
|
12
|
+
plan: pro
|
|
13
|
+
seats: 1
|
|
14
|
+
- event:
|
|
15
|
+
id: evt_h6_cape_set
|
|
16
|
+
type: customer.subscription.updated
|
|
17
|
+
customerId: cus_h6_cape
|
|
18
|
+
cancel_at_period_end: true
|
|
19
|
+
period_end: 1577923200
|
|
20
|
+
now: 1577836800
|
|
21
|
+
plan: pro
|
|
22
|
+
seats: 1
|
|
23
|
+
expect:
|
|
24
|
+
locked: false
|
|
25
|
+
plan: pro
|
|
26
|
+
seats: 1
|
|
27
|
+
- event:
|
|
28
|
+
id: evt_h6_cape_period_end
|
|
29
|
+
type: customer.subscription.updated
|
|
30
|
+
customerId: cus_h6_cape
|
|
31
|
+
cancel_at_period_end: true
|
|
32
|
+
period_end: 1577923200
|
|
33
|
+
now: 1577923200
|
|
34
|
+
plan: pro
|
|
35
|
+
expect:
|
|
36
|
+
locked: true
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
id: cancel_revokes_access
|
|
2
|
+
subject: cus_s2
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_s2_invoice_paid
|
|
6
|
+
type: invoice.paid
|
|
7
|
+
customerId: cus_s2
|
|
8
|
+
plan: pro
|
|
9
|
+
expect:
|
|
10
|
+
locked: false
|
|
11
|
+
plan: pro
|
|
12
|
+
- event:
|
|
13
|
+
id: evt_s2_subscription_deleted
|
|
14
|
+
type: customer.subscription.deleted
|
|
15
|
+
customerId: cus_s2
|
|
16
|
+
canceled: true
|
|
17
|
+
expect:
|
|
18
|
+
locked: true
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Assumed policy: charge.dispute.created revokes access (locked: true)
|
|
2
|
+
# and flags the row by clearing features. Plan/seats stay. Fixture-only;
|
|
3
|
+
# dispute + Stripe Test Clocks flake (auto-cancel delay).
|
|
4
|
+
id: dispute_revokes_or_flags
|
|
5
|
+
subject: cus_h7_dispute
|
|
6
|
+
steps:
|
|
7
|
+
- event:
|
|
8
|
+
id: evt_h7_dispute_paid
|
|
9
|
+
type: invoice.paid
|
|
10
|
+
customerId: cus_h7_dispute
|
|
11
|
+
plan: pro
|
|
12
|
+
seats: 1
|
|
13
|
+
features:
|
|
14
|
+
- team
|
|
15
|
+
expect:
|
|
16
|
+
locked: false
|
|
17
|
+
plan: pro
|
|
18
|
+
seats: 1
|
|
19
|
+
features:
|
|
20
|
+
- team
|
|
21
|
+
- event:
|
|
22
|
+
id: evt_h7_dispute_created
|
|
23
|
+
type: charge.dispute.created
|
|
24
|
+
customerId: cus_h7_dispute
|
|
25
|
+
expect:
|
|
26
|
+
locked: true
|
|
27
|
+
plan: pro
|
|
28
|
+
seats: 1
|
|
29
|
+
features: []
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
id: duplicate_webhook_idempotent
|
|
2
|
+
subject: cus_s6
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_s6_invoice_paid
|
|
6
|
+
type: invoice.paid
|
|
7
|
+
customerId: cus_s6
|
|
8
|
+
plan: pro
|
|
9
|
+
expect:
|
|
10
|
+
locked: false
|
|
11
|
+
plan: pro
|
|
12
|
+
- event:
|
|
13
|
+
id: evt_s6_invoice_paid
|
|
14
|
+
type: invoice.paid
|
|
15
|
+
customerId: cus_s6
|
|
16
|
+
plan: pro
|
|
17
|
+
expect:
|
|
18
|
+
locked: false
|
|
19
|
+
plan: pro
|
|
20
|
+
- event:
|
|
21
|
+
id: evt_s6_subscription_deleted
|
|
22
|
+
type: customer.subscription.deleted
|
|
23
|
+
customerId: cus_s6
|
|
24
|
+
canceled: true
|
|
25
|
+
expect:
|
|
26
|
+
locked: true
|
|
27
|
+
- event:
|
|
28
|
+
id: evt_s6_invoice_paid
|
|
29
|
+
type: invoice.paid
|
|
30
|
+
customerId: cus_s6
|
|
31
|
+
plan: pro
|
|
32
|
+
expect:
|
|
33
|
+
locked: true
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
id: past_due_denies_until_recovery
|
|
2
|
+
subject: cus_s5
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_s5_invoice_paid
|
|
6
|
+
type: invoice.paid
|
|
7
|
+
customerId: cus_s5
|
|
8
|
+
plan: pro
|
|
9
|
+
expect:
|
|
10
|
+
locked: false
|
|
11
|
+
plan: pro
|
|
12
|
+
- event:
|
|
13
|
+
id: evt_s5_payment_failed
|
|
14
|
+
type: invoice.payment_failed
|
|
15
|
+
customerId: cus_s5
|
|
16
|
+
status: past_due
|
|
17
|
+
expect:
|
|
18
|
+
locked: true
|
|
19
|
+
- event:
|
|
20
|
+
id: evt_s5_recovered
|
|
21
|
+
type: invoice.paid
|
|
22
|
+
customerId: cus_s5
|
|
23
|
+
plan: pro
|
|
24
|
+
expect:
|
|
25
|
+
locked: false
|
|
26
|
+
plan: pro
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
id: refund_revokes_access
|
|
2
|
+
subject: cus_s3
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_s3_invoice_paid
|
|
6
|
+
type: invoice.paid
|
|
7
|
+
customerId: cus_s3
|
|
8
|
+
plan: pro
|
|
9
|
+
expect:
|
|
10
|
+
locked: false
|
|
11
|
+
plan: pro
|
|
12
|
+
- event:
|
|
13
|
+
id: evt_s3_charge_refunded
|
|
14
|
+
type: charge.refunded
|
|
15
|
+
customerId: cus_s3
|
|
16
|
+
refunded: true
|
|
17
|
+
expect:
|
|
18
|
+
locked: true
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
id: seat_qty_vs_db_cap
|
|
2
|
+
subject: cus_h6_seats
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_h6_seats_paid_10
|
|
6
|
+
type: invoice.paid
|
|
7
|
+
customerId: cus_h6_seats
|
|
8
|
+
plan: pro
|
|
9
|
+
seats: 10
|
|
10
|
+
features:
|
|
11
|
+
- team
|
|
12
|
+
expect:
|
|
13
|
+
locked: false
|
|
14
|
+
plan: pro
|
|
15
|
+
seats: 10
|
|
16
|
+
features:
|
|
17
|
+
- team
|
|
18
|
+
- event:
|
|
19
|
+
id: evt_h6_seats_qty_3
|
|
20
|
+
type: customer.subscription.updated
|
|
21
|
+
customerId: cus_h6_seats
|
|
22
|
+
plan: pro
|
|
23
|
+
seats: 3
|
|
24
|
+
expect:
|
|
25
|
+
locked: false
|
|
26
|
+
plan: pro
|
|
27
|
+
seats: 3
|
|
28
|
+
features:
|
|
29
|
+
- team
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
id: trial_to_paid_grants
|
|
2
|
+
subject: cus_s4
|
|
3
|
+
steps:
|
|
4
|
+
- event:
|
|
5
|
+
id: evt_s4_trialing
|
|
6
|
+
type: customer.subscription.updated
|
|
7
|
+
customerId: cus_s4
|
|
8
|
+
status: trialing
|
|
9
|
+
plan: pro
|
|
10
|
+
expect:
|
|
11
|
+
locked: true
|
|
12
|
+
- event:
|
|
13
|
+
id: evt_s4_invoice_paid
|
|
14
|
+
type: invoice.paid
|
|
15
|
+
customerId: cus_s4
|
|
16
|
+
plan: pro
|
|
17
|
+
expect:
|
|
18
|
+
locked: false
|
|
19
|
+
plan: pro
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Assumed policy: past_due denies, then unpaid after retries exhaust
|
|
2
|
+
# revokes (locked: true). Immediate cancel stays locked. Fixture-only.
|
|
3
|
+
id: unpaid_after_retries_revokes
|
|
4
|
+
subject: cus_h7_unpaid
|
|
5
|
+
steps:
|
|
6
|
+
- event:
|
|
7
|
+
id: evt_h7_unpaid_paid
|
|
8
|
+
type: invoice.paid
|
|
9
|
+
customerId: cus_h7_unpaid
|
|
10
|
+
plan: pro
|
|
11
|
+
seats: 1
|
|
12
|
+
expect:
|
|
13
|
+
locked: false
|
|
14
|
+
plan: pro
|
|
15
|
+
seats: 1
|
|
16
|
+
- event:
|
|
17
|
+
id: evt_h7_unpaid_past_due
|
|
18
|
+
type: invoice.payment_failed
|
|
19
|
+
customerId: cus_h7_unpaid
|
|
20
|
+
status: past_due
|
|
21
|
+
expect:
|
|
22
|
+
locked: true
|
|
23
|
+
- event:
|
|
24
|
+
id: evt_h7_unpaid_exhaust
|
|
25
|
+
type: customer.subscription.updated
|
|
26
|
+
customerId: cus_h7_unpaid
|
|
27
|
+
status: unpaid
|
|
28
|
+
plan: pro
|
|
29
|
+
seats: 1
|
|
30
|
+
expect:
|
|
31
|
+
locked: true
|
|
32
|
+
plan: pro
|
|
33
|
+
seats: 1
|
|
34
|
+
- event:
|
|
35
|
+
id: evt_h7_unpaid_canceled
|
|
36
|
+
type: customer.subscription.deleted
|
|
37
|
+
customerId: cus_h7_unpaid
|
|
38
|
+
canceled: true
|
|
39
|
+
expect:
|
|
40
|
+
locked: true
|