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.
Files changed (32) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -2
  4. package/action.yml +23 -0
  5. package/bin/gatefit.js +32 -0
  6. package/package.json +56 -4
  7. package/scripts/action-run.sh +18 -0
  8. package/src/access-adapter.ts +90 -0
  9. package/src/access-rules.ts +123 -0
  10. package/src/adapters/hardened.ts +22 -0
  11. package/src/adapters/naive.ts +25 -0
  12. package/src/adapters/sqlite.ts +83 -0
  13. package/src/chaos.ts +169 -0
  14. package/src/cli.ts +166 -0
  15. package/src/clock-driver.ts +52 -0
  16. package/src/fixture-driver.ts +84 -0
  17. package/src/license.ts +160 -0
  18. package/src/live-scenarios.ts +191 -0
  19. package/src/packs.ts +27 -0
  20. package/src/paths.ts +31 -0
  21. package/src/stripe-clock.ts +153 -0
  22. package/tests/entitlements/goldens/cancel_at_period_end_keeps_access_until_end.yaml +36 -0
  23. package/tests/entitlements/goldens/cancel_revokes_access.yaml +18 -0
  24. package/tests/entitlements/goldens/dispute_revokes_or_flags.yaml +29 -0
  25. package/tests/entitlements/goldens/duplicate_webhook_idempotent.yaml +33 -0
  26. package/tests/entitlements/goldens/paid_grants_access.yaml +11 -0
  27. package/tests/entitlements/goldens/past_due_denies_until_recovery.yaml +26 -0
  28. package/tests/entitlements/goldens/refund_revokes_access.yaml +18 -0
  29. package/tests/entitlements/goldens/seat_qty_vs_db_cap.yaml +29 -0
  30. package/tests/entitlements/goldens/trial_to_paid_grants.yaml +19 -0
  31. package/tests/entitlements/goldens/unpaid_after_retries_revokes.yaml +40 -0
  32. 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,11 @@
1
+ id: paid_grants_access
2
+ subject: cus_s1
3
+ steps:
4
+ - event:
5
+ id: evt_s1_invoice_paid
6
+ type: invoice.paid
7
+ customerId: cus_s1
8
+ plan: pro
9
+ expect:
10
+ locked: false
11
+ plan: pro
@@ -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