| 1 | // Explicit sandbox-only integration checks against a locally running billing |
| 2 | // Worker and isolated D1. Never accepts a live Stripe key or a remote Worker. |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; |
| 5 | import { resolve } from "node:path"; |
| 6 | import { spawnSync } from "node:child_process"; |
| 7 | import { createHmac } from "node:crypto"; |
| 8 | |
| 9 | const dir = resolve("target/billing-audit"); |
| 10 | mkdirSync(dir, { recursive: true }); |
| 11 | const key = readFileSync(".secrets/stripe-test-key.txt", "utf8").trim(); |
| 12 | assert.ok(/^(sk|rk)_test_/.test(key), 'Expected a test API key'); |
| 13 | const version = readFileSync("services/billing/src/stripe.rs", "utf8").match(/STRIPE_VERSION: &str = "([^"]+)"/)[1]; |
| 14 | const endpoint = "http://127.0.0.1:8797"; |
| 15 | const secret = "whsec_local_billing_audit_only"; |
| 16 | const fixturePath = resolve(dir, "fixture.json"); |
| 17 | const phase = process.argv[2]; |
| 18 | assert.ok(['seed', 'run', 'lifecycle', 'enterprise', 'protocol', 'complimentary', 'cleanup'].includes(phase), 'Use seed, run, lifecycle, enterprise, protocol, complimentary, or cleanup'); |
| 19 | // A local Worker is not enough isolation: production replays all events in |
| 20 | // its Stripe account. The test and live keys below were verified to share |
| 21 | // this account during the audit. Never seed or run fixtures there again. |
| 22 | const productionStripeAccount = 'acct_1UMzU3CgDtq6trU5'; |
| 23 | const auditAccount = await stripe('/account'); |
| 24 | assert.match(auditAccount.id, /^acct_/); |
| 25 | if (phase !== 'cleanup') { |
| 26 | assert.notEqual(auditAccount.id, productionStripeAccount, |
| 27 | 'Use a separate Stripe Sandbox: this account is connected to production event replay. No test objects were created.'); |
| 28 | } |
| 29 | async function stripe(path, fields, method = fields ? "POST" : "GET", apiVersion = version) { |
| 30 | const response = await fetch(`https://api.stripe.com/v1${path}`, { |
| 31 | method, headers: { Authorization: `Bearer ${key}`, "Stripe-Version": apiVersion, ...(fields ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) }, |
| 32 | body: fields ? new URLSearchParams(fields) : undefined, |
| 33 | }); |
| 34 | const result = await response.json(); |
| 35 | if (!response.ok) throw Error(`Stripe ${method} ${path}: ${response.status} ${result.error?.code ?? result.error?.type ?? "error"}`); |
| 36 | assert.notEqual(result.livemode, true, "Sandbox returned a live object"); |
| 37 | return result; |
| 38 | } |
| 39 | async function rpc(method, args) { |
| 40 | const response = await fetch(`${endpoint}/rpc/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(args) }); |
| 41 | if (!response.ok) throw Error(`Local billing ${method}: HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`); |
| 42 | return response.json(); |
| 43 | } |
| 44 | function localSQL(sql) { |
| 45 | const file = resolve(dir, "fixture.sql"); writeFileSync(file, sql); |
| 46 | const result = spawnSync(process.execPath, ["node_modules/wrangler/bin/wrangler.js", "d1", "execute", "billing-audit", "--local", "--config", "target/billing-audit/wrangler.json", "--persist-to", "target/billing-audit/state", "--file", file, "--json"], { encoding: "utf8" }); |
| 47 | if (result.status !== 0) throw Error(`Local fixture SQL failed: ${result.stderr.slice(0, 500)}`); |
| 48 | } |
| 49 | if (phase === 'protocol') { |
| 50 | assert.equal((await rpc('status', {})).live, false); |
| 51 | const event = { id: `evt_audit_mode_${Date.now()}`, type: 'audit.mode_check', livemode: true, data: { object: {} } }; |
| 52 | const deliver = () => { |
| 53 | const payload = JSON.stringify(event), time = Math.floor(Date.now() / 1000); |
| 54 | const signature = `t=${time},v1=${createHmac('sha256', secret).update(`${time}.${payload}`).digest('hex')}`; |
| 55 | return rpc('stripe_webhook', { payload, signature }); |
| 56 | }; |
| 57 | await assert.rejects(deliver(), /HTTP 500/); |
| 58 | event.livemode = false; |
| 59 | assert.deepEqual(await deliver(), { ok: true, value: true }); |
| 60 | assert.deepEqual(await deliver(), { ok: true, value: false }); |
| 61 | console.log('PASS wrong-mode delivery remains unclaimed and can be processed once after mode correction'); |
| 62 | } else if (phase === "seed") { |
| 63 | const workspace = `audit-${Date.now()}`; |
| 64 | const customer = await stripe("/customers", { name: "Disposable g1t billing audit", "metadata[g1t_audit]": workspace, payment_method: "pm_card_visa", "invoice_settings[default_payment_method]": "pm_card_visa", "address[country]": "US", "address[state]": "CO", "address[postal_code]": "80202", "address[line1]": "123 Test Street", "address[city]": "Denver" }); |
| 65 | assert.match(customer.id, /^cus_[a-zA-Z0-9]+$/); |
| 66 | writeFileSync(fixturePath, JSON.stringify({ workspace, customer: customer.id, stripeAccount: auditAccount.id })); |
| 67 | localSQL(`INSERT INTO accounts (workspace,balance_micros,customer_id,created_at) VALUES ('${workspace}',0,'${customer.id}','${new Date().toISOString()}');`); |
| 68 | console.log(JSON.stringify({ seeded: workspace, mode: "sandbox", database: "local" })); |
| 69 | } else if (phase === "run") { |
| 70 | const fixture = JSON.parse(readFileSync(fixturePath, "utf8")); |
| 71 | assert.equal(fixture.stripeAccount, auditAccount.id, 'Fixture belongs to a different Stripe account; seed an isolated fixture'); |
| 72 | const { workspace, customer } = fixture; |
| 73 | const actor = { id: "usr_audit", username: "audit-owner", verified: true, workspaces: [{ slug: workspace, role: "owner" }] }; |
| 74 | const checks = []; |
| 75 | const passed = name => { checks.push(name); console.log(`PASS ${name}`); }; |
| 76 | assert.equal((await rpc("status", {})).live, false); |
| 77 | const started = await rpc("subscribe", { workspace, actor, feature: "plan", returnUrl: `${endpoint}/return` }); |
| 78 | assert.equal(started.ok, true, JSON.stringify(started)); |
| 79 | const features = await rpc("features", { workspace, viewer: actor }); |
| 80 | assert.ok(features.ok && features.value.some(f => f.plan.feature === "plan" && f.on), JSON.stringify(features)); |
| 81 | passed("saved-card subscription activates through the real billing Worker"); |
| 82 | const subscriptions = await stripe(`/subscriptions?customer=${customer}&status=all`); |
| 83 | assert.equal(subscriptions.data.length, 1); |
| 84 | fixture.subscription = subscriptions.data[0].id; |
| 85 | writeFileSync(fixturePath, JSON.stringify(fixture)); |
| 86 | const again = await rpc("subscribe", { workspace, actor, feature: "plan", returnUrl: `${endpoint}/return` }); |
| 87 | assert.equal(again.ok, false); |
| 88 | assert.equal((await stripe(`/subscriptions?customer=${customer}&status=all`)).data.length, 1); |
| 89 | passed("repeat subscribe cannot create a second active plan"); |
| 90 | for (const resume of [false, true]) { |
| 91 | const changed = await rpc("cancel_subscription", { workspace, actor, feature: "plan", resume }); |
| 92 | assert.equal(changed.ok, true, JSON.stringify(changed)); |
| 93 | assert.equal((await stripe(`/subscriptions/${fixture.subscription}`)).cancel_at_period_end, !resume); |
| 94 | } |
| 95 | passed("period-end cancellation and resumption reach Stripe"); |
| 96 | const charge = { workspace, feature: "deployments", costMicros: 15_000_000, description: "Sandbox audit usage", repo: null, reference: `audit/${workspace}` }; |
| 97 | const posts = await Promise.all([rpc("charge_feature", charge), rpc("charge_feature", charge)]); |
| 98 | assert.equal(posts.filter(p => p.ok && p.value).length, 1); |
| 99 | const account = await rpc("account", { workspace, viewer: actor }); |
| 100 | assert.equal(account.value.balanceMicros, -8_000_000, JSON.stringify(account)); |
| 101 | passed("concurrent usage delivery consumes included credit and posts excess once"); |
| 102 | const closed = await rpc("close_workspace", { workspace, actor, dryRun: false }); |
| 103 | assert.equal(closed.ok, true, JSON.stringify(closed)); |
| 104 | const invoices = await rpc("invoices", { workspace, viewer: actor }); |
| 105 | assert.ok(invoices.ok && invoices.value.some(i => i.status === "paid"), JSON.stringify(invoices)); |
| 106 | assert.equal((await rpc("account", { workspace, viewer: actor })).value.balanceMicros, 0); |
| 107 | const usageInvoice = (await stripe(`/invoices?customer=${customer}&limit=20`)).data.find(i => i.metadata?.g1t_workspace === workspace); |
| 108 | assert.ok(usageInvoice?.charge); |
| 109 | passed("closing collects exactly the excess usage on a paid Stripe invoice"); |
| 110 | await stripe("/refunds", { charge: usageInvoice.charge, amount: "400" }); |
| 111 | const chargeObject = await stripe(`/charges/${usageInvoice.charge}`); |
| 112 | const event = { id: `evt_local_audit_${workspace}`, type: "charge.refunded", livemode: false, data: { object: chargeObject } }; |
| 113 | const payload = JSON.stringify(event), time = Math.floor(Date.now() / 1000); |
| 114 | const signature = `t=${time},v1=${createHmac("sha256", secret).update(`${time}.${payload}`).digest("hex")}`; |
| 115 | assert.equal((await rpc("stripe_webhook", { payload, signature })).ok, true); |
| 116 | assert.equal((await rpc("stripe_webhook", { payload, signature })).ok, true); |
| 117 | assert.equal((await rpc("account", { workspace, viewer: actor })).value.balanceMicros, -4_000_000); |
| 118 | passed("real sandbox refund and locally signed duplicate delivery reverse credit once"); |
| 119 | const bad = await rpc("stripe_webhook", { payload, signature: "t=1,v1=00" }); |
| 120 | assert.equal(bad.ok, false); |
| 121 | passed("invalid webhook signatures are rejected"); |
| 122 | writeFileSync(resolve(dir, "sandbox-results.json"), JSON.stringify({ checkedAt: new Date().toISOString(), mode: "sandbox", worker: "local", checks, limitations: ["No public webhook delivery", "No browser Checkout or 3DS", "No renewal test clock", "No production deployment"] }, null, 2)); |
| 123 | } else if (phase === "lifecycle") { |
| 124 | assert.equal((await rpc('status', {})).live, false); |
| 125 | const workspace = `audit-cycle-${Date.now()}`; |
| 126 | const actor = { id: 'usr_audit', username: 'audit-owner', verified: true, workspaces: [{ slug: workspace, role: 'owner' }] }; |
| 127 | const checks = []; |
| 128 | const passed = message => { checks.push(message); console.log(`PASS ${message}`); }; |
| 129 | const clock = await stripe('/test_helpers/test_clocks', { frozen_time: String(Math.floor(Date.now() / 1000)), name: workspace }); |
| 130 | const evidence = { workspace, clock: clock.id, stripeAccount: auditAccount.id, checks }; |
| 131 | const save = () => writeFileSync(resolve(dir, 'lifecycle-results.json'), JSON.stringify({ ...evidence, checkedAt: new Date().toISOString() }, null, 2)); |
| 132 | save(); |
| 133 | const customer = await stripe('/customers', { name: workspace, test_clock: clock.id, payment_method: 'pm_card_visa', 'invoice_settings[default_payment_method]': 'pm_card_visa', 'metadata[workspace]': workspace, 'address[country]': 'US', 'address[state]': 'CO', 'address[postal_code]': '80202', 'address[line1]': '123 Test Street', 'address[city]': 'Denver' }); |
| 134 | evidence.customer = customer.id; save(); |
| 135 | localSQL(`INSERT INTO accounts (workspace,balance_micros,customer_id,created_at) VALUES ('${workspace}',0,'${customer.id}','${new Date().toISOString()}');`); |
| 136 | const started = await rpc('subscribe', { workspace, actor, feature: 'plan', returnUrl: `${endpoint}/return` }); |
| 137 | assert.equal(started.ok, true, JSON.stringify(started)); |
| 138 | const subscription = (await stripe(`/subscriptions?customer=${customer.id}&status=all`)).data[0]; |
| 139 | assert.equal(subscription.status, 'active'); |
| 140 | evidence.subscription = subscription.id; save(); |
| 141 | const end = sub => sub.current_period_end ?? sub.items.data[0].current_period_end; |
| 142 | const advance = async frozenTime => { |
| 143 | await stripe(`/test_helpers/test_clocks/${clock.id}/advance`, { frozen_time: String(frozenTime) }); |
| 144 | for (let attempt = 0; attempt < 30; attempt++) { |
| 145 | const current = await stripe(`/test_helpers/test_clocks/${clock.id}`); |
| 146 | if (current.status === 'ready') return; |
| 147 | await new Promise(resolve => setTimeout(resolve, 1000)); |
| 148 | } |
| 149 | throw Error('Stripe clock did not become ready within 30 polls'); |
| 150 | }; |
| 151 | // Exercise the actual subscription handler and signed delivery protocol. |
| 152 | // These locally signed events deliberately fetch Stripe's current object; |
| 153 | // public webhook transport is a separate test, not claimed here. |
| 154 | const deliver = async (type, object, suffix) => { |
| 155 | // Read real objects in the production destination's schema while the |
| 156 | // Worker keeps using its independently pinned request API version. |
| 157 | const webhookVersion = '2026-08-26.dahlia'; |
| 158 | const resource = type.startsWith('invoice.') ? 'invoices' : 'subscriptions'; |
| 159 | object = await stripe(`/${resource}/${object.id}`, undefined, 'GET', webhookVersion); |
| 160 | if (resource === 'invoices') { |
| 161 | assert.equal(object.subscription, undefined); |
| 162 | assert.equal(object.parent?.subscription_details?.subscription, subscription.id); |
| 163 | } |
| 164 | const payload = JSON.stringify({ id: `evt_audit_${workspace}_${suffix}`, api_version: webhookVersion, type, livemode: false, data: { object } }); |
| 165 | const time = Math.floor(Date.now() / 1000); |
| 166 | const signature = `t=${time},v1=${createHmac('sha256', secret).update(`${time}.${payload}`).digest('hex')}`; |
| 167 | const result = await rpc('stripe_webhook', { payload, signature }); |
| 168 | assert.equal(result.ok, true, JSON.stringify(result)); |
| 169 | }; |
| 170 | const plan = async () => (await rpc('features', { workspace, viewer: actor })).value.find(f => f.plan.feature === 'plan'); |
| 171 | try { |
| 172 | const waiver = await rpc('admin_set_allowances', { id: `ws_${workspace}`, allowances: { plan: true }, note: 'Audit: must not silently waive an active Stripe plan', by: 'audit' }); |
| 173 | assert.equal(waiver.ok, false); |
| 174 | assert.match(waiver.error.message, /subscription/i); |
| 175 | assert.equal((await stripe(`/subscriptions/${subscription.id}`)).status, 'active'); |
| 176 | passed('staff fee waiver refuses a still-billable Stripe subscription'); |
| 177 | await advance(end(subscription) + 7200); |
| 178 | let renewed = await stripe(`/subscriptions/${subscription.id}`); |
| 179 | let renewalInvoice = await stripe(`/invoices/${renewed.latest_invoice}`); |
| 180 | // Stripe can leave the renewal draft until the next simulation advance. |
| 181 | if (renewalInvoice.status === 'draft') { |
| 182 | await stripe(`/invoices/${renewalInvoice.id}/finalize`, {}); |
| 183 | renewalInvoice = await stripe(`/invoices/${renewalInvoice.id}`); |
| 184 | } |
| 185 | if (renewalInvoice.status === 'open') renewalInvoice = await stripe(`/invoices/${renewalInvoice.id}/pay`, {}); |
| 186 | assert.equal(renewalInvoice.status, 'paid'); |
| 187 | assert.notEqual(renewalInvoice.id, subscription.latest_invoice); |
| 188 | await deliver('invoice.paid', renewalInvoice, 'renewed'); |
| 189 | await deliver('invoice.paid', renewalInvoice, 'renewed'); |
| 190 | assert.equal((await plan()).on, true); |
| 191 | passed('test-clock renewal collects and duplicate delivery retains paid access'); save(); |
| 192 | // Attach succeeds, subsequent charge fails: Stripe's documented test PM. |
| 193 | const declined = await stripe('/payment_methods/pm_card_chargeCustomerFail/attach', { customer: customer.id }); |
| 194 | await stripe(`/subscriptions/${subscription.id}`, { default_payment_method: declined.id }); |
| 195 | renewed = await stripe(`/subscriptions/${subscription.id}`); |
| 196 | await advance(end(renewed) + 7200); |
| 197 | let failedSub = await stripe(`/subscriptions/${subscription.id}`); |
| 198 | let failedInvoice = await stripe(`/invoices/${failedSub.latest_invoice}`); |
| 199 | if (failedInvoice.status === 'draft') { |
| 200 | await stripe(`/invoices/${failedInvoice.id}/finalize`, {}); |
| 201 | failedInvoice = await stripe(`/invoices/${failedInvoice.id}`); |
| 202 | } |
| 203 | if (!failedInvoice.attempted) { |
| 204 | await assert.rejects(stripe(`/invoices/${failedInvoice.id}/pay`, {}), /Stripe/); |
| 205 | } |
| 206 | failedSub = await stripe(`/subscriptions/${subscription.id}`); |
| 207 | assert.equal(failedSub.status, 'past_due'); |
| 208 | await deliver('invoice.payment_failed', failedInvoice, 'declined'); |
| 209 | assert.equal((await plan()).on, false); |
| 210 | passed('declined renewal removes paid access after reconciliation'); save(); |
| 211 | await stripe(`/subscriptions/${subscription.id}`, { default_payment_method: customer.invoice_settings.default_payment_method }); |
| 212 | const recovered = await stripe(`/invoices/${failedInvoice.id}/pay`, {}); |
| 213 | assert.equal(recovered.status, 'paid'); |
| 214 | await deliver('invoice.paid', recovered, 'recovered'); |
| 215 | await deliver('invoice.payment_failed', failedInvoice, 'late-decline'); |
| 216 | assert.equal((await plan()).on, true); |
| 217 | passed('payment recovery restores access and stale decline cannot undo it'); save(); |
| 218 | const cancel = await rpc('cancel_subscription', { workspace, actor, feature: 'plan', resume: false }); |
| 219 | assert.equal(cancel.ok, true, JSON.stringify(cancel)); |
| 220 | assert.equal((await plan()).on, true); |
| 221 | const beforeEnd = await stripe(`/subscriptions/${subscription.id}`); |
| 222 | await advance(end(beforeEnd) + 60); |
| 223 | const ended = await stripe(`/subscriptions/${subscription.id}`); |
| 224 | assert.equal(ended.status, 'canceled'); |
| 225 | await deliver('customer.subscription.deleted', ended, 'ended'); |
| 226 | assert.equal((await plan()).on, false); |
| 227 | passed('period-end cancellation retains access until expiry and then removes it'); |
| 228 | evidence.webhookObjectVersion = '2026-08-26.dahlia'; |
| 229 | evidence.transport = 'Locally signed RPC delivery; not public Stripe delivery'; |
| 230 | evidence.complete = true; save(); |
| 231 | } finally { |
| 232 | await stripe(`/test_helpers/test_clocks/${clock.id}`, undefined, 'DELETE'); |
| 233 | evidence.cleaned = true; save(); |
| 234 | } |
| 235 | } else if (phase === 'enterprise') { |
| 236 | const workspace = `audit-enterprise-${Date.now()}`; |
| 237 | const enterprise = `ent_${workspace}`; |
| 238 | const customer = await stripe('/customers', { name: workspace, email: 'billing-audit@example.com', 'address[country]': 'US', 'address[state]': 'CO', 'address[postal_code]': '80202', 'address[line1]': '123 Test Street', 'address[city]': 'Denver' }); |
| 239 | const now = new Date().toISOString(); |
| 240 | localSQL(`INSERT INTO billing_accounts (id,kind,name,customer_id,created_by,created_at) VALUES ('${enterprise}','enterprise','Audit enterprise','${customer.id}','audit','${now}'); |
| 241 | INSERT INTO account_members VALUES ('${workspace}','${enterprise}','audit','${now}'); |
| 242 | INSERT INTO accounts (workspace,balance_micros,created_at) VALUES ('${workspace}',-10000001,'${now}');`); |
| 243 | const checks = []; |
| 244 | try { |
| 245 | // Wait for both requests before cleanup, even when Stripe returns its |
| 246 | // transient idempotency_key_in_use response to one concurrent caller. |
| 247 | const outcomes = await Promise.allSettled([rpc('admin_invoice_enterprise',{id:enterprise,by:'audit'}),rpc('admin_invoice_enterprise',{id:enterprise,by:'audit'})]); |
| 248 | const requests = outcomes.filter(r=>r.status==='fulfilled').map(r=>r.value); |
| 249 | for (const result of outcomes) assert.equal(result.status,'fulfilled', result.reason?.message); |
| 250 | assert.ok(requests.some(r => r.ok),JSON.stringify(requests)); |
| 251 | const invoices = (await stripe(`/invoices?customer=${customer.id}`)).data; |
| 252 | assert.equal(invoices.length,1); |
| 253 | assert.equal(invoices[0].subtotal,1001); |
| 254 | for (const result of requests.filter(r => r.ok)) assert.equal(result.value.lines[0].amountMicros,10010000); |
| 255 | checks.push('concurrent enterprise requests create one invoice with exact cent-rounded line accounting'); |
| 256 | console.log(`PASS ${checks.at(-1)}`); |
| 257 | const again = await rpc('admin_invoice_enterprise',{id:enterprise,by:'audit'}); |
| 258 | assert.equal(again.ok,false); |
| 259 | assert.equal((await stripe(`/invoices?customer=${customer.id}`)).data.length,1); |
| 260 | checks.push('already-invoiced debt cannot be invoiced twice'); |
| 261 | console.log(`PASS ${checks.at(-1)}`); |
| 262 | localSQL(`UPDATE accounts SET balance_micros=balance_micros-5000000 WHERE workspace='${workspace}';`); |
| 263 | const more = await rpc('admin_invoice_enterprise',{id:enterprise,by:'audit'}); |
| 264 | assert.equal(more.ok,true,JSON.stringify(more)); |
| 265 | const all = (await stripe(`/invoices?customer=${customer.id}`)).data; |
| 266 | assert.equal(all.length,2); |
| 267 | assert.equal(all.reduce((sum,i)=>sum+i.subtotal,0),1501); |
| 268 | checks.push('subsequent usage invoices only debt not covered by the first invoice'); |
| 269 | console.log(`PASS ${checks.at(-1)}`); |
| 270 | writeFileSync(resolve(dir,'enterprise-results.json'),JSON.stringify({checkedAt:new Date().toISOString(),stripeAccount:auditAccount.id,workspace,checks},null,2)); |
| 271 | } finally { await stripe(`/customers/${customer.id}`,undefined,'DELETE'); } |
| 272 | } else if (phase === 'complimentary') { |
| 273 | // Staff enrollment, terms changes, admission, conversion (paid, 3DS, |
| 274 | // declined, no card, repeated) and revocation, through the real Worker |
| 275 | // against real sandbox subscriptions. Webhooks are not delivered: each |
| 276 | // step's synchronous path is what is exercised. |
| 277 | assert.equal((await rpc('status', {})).live, false); |
| 278 | const stamp = Date.now(); |
| 279 | const checks = []; |
| 280 | const made = []; |
| 281 | const passed = message => { checks.push(message); console.log(`PASS ${message}`); }; |
| 282 | const actorOf = workspace => ({ id: 'usr_audit', username: 'audit-owner', verified: true, workspaces: [{ slug: workspace, role: 'owner' }] }); |
| 283 | const enroll = (workspace, allowance, fee = 0) => |
| 284 | rpc('admin_set_complimentary', { workspace, allowance_micros: allowance, unlimited: false, fee_cents: fee, note: 'sandbox audit', by: 'audit' }); |
| 285 | const standing = workspace => rpc('admin_complimentary', { workspace }); |
| 286 | const reserve = (workspace, estimate) => |
| 287 | rpc('reserve', { workspace, repo: { namespace: workspace, name: 'app' }, public: false, kind: 'workflow', estimateMicros: estimate }); |
| 288 | const card = async (customer, method) => { |
| 289 | const pm = await stripe(`/payment_methods/${method}/attach`, { customer }); |
| 290 | await stripe(`/customers/${customer}`, { 'invoice_settings[default_payment_method]': pm.id }); |
| 291 | }; |
| 292 | const convert = workspace => rpc('convert_to_paid', { actor: actorOf(workspace), workspace, returnUrl: `${endpoint}/return` }); |
| 293 | try { |
| 294 | // Enrollment: a real customer and subscription, then visible. |
| 295 | const a = `cmp-a-${stamp}`; |
| 296 | const enrolled = await enroll(a, 100_000_000); |
| 297 | assert.equal(enrolled.ok, true, JSON.stringify(enrolled)); |
| 298 | made.push(enrolled.value.customerId); |
| 299 | assert.equal(enrolled.value.status, 'active', JSON.stringify(enrolled.value)); |
| 300 | const sub = await stripe(`/subscriptions/${enrolled.value.subscriptionId}`); |
| 301 | assert.equal(sub.status, 'active'); |
| 302 | assert.equal(sub.metadata.g1t_grant, enrolled.value.grantId); |
| 303 | assert.equal(sub.metadata.g1t_allowance_micros, '100000000'); |
| 304 | assert.equal(sub.items.data[0].price.unit_amount, 0); |
| 305 | const ent = await rpc('entitlements', { workspace: a }); |
| 306 | assert.equal(ent.plan, 'complimentary'); |
| 307 | assert.equal(ent.complimentary.allowanceMicros, 100_000_000); |
| 308 | assert.equal(ent.complimentary.remainingMicros, 100_000_000); |
| 309 | assert.equal(ent.hasPlan, true); |
| 310 | passed('staff enrollment creates a real $0 subscription with the grant in its metadata, then the workspace sees it'); |
| 311 | const again = await enroll(a, 100_000_000); |
| 312 | assert.equal(again.value.subscriptionId, enrolled.value.subscriptionId); |
| 313 | assert.equal((await stripe(`/subscriptions?customer=${enrolled.value.customerId}&status=all`)).data.length, 1); |
| 314 | passed('changing terms keeps one subscription'); |
| 315 | // Admission: the allowance bounds new work, card or not; no on demand. |
| 316 | await card(enrolled.value.customerId, 'pm_card_visa'); |
| 317 | const big = await reserve(a, 200_000_000); |
| 318 | assert.equal(big.ok, false, JSON.stringify(big)); |
| 319 | assert.match(big.error.message, /complimentary allowance/); |
| 320 | const small = await reserve(a, 10_000_000); |
| 321 | assert.equal(small.ok, true, JSON.stringify(small)); |
| 322 | const held = await rpc('entitlements', { workspace: a }); |
| 323 | assert.ok(held.complimentary.remainingMicros < 100_000_000 && held.complimentary.heldMicros > 0, JSON.stringify(held.complimentary)); |
| 324 | passed('admission counts running work against the allowance and refuses past it even with a saved card'); |
| 325 | // A custom fee is its own price on the same subscription. |
| 326 | const fee = await enroll(a, 50_000_000, 2_500); |
| 327 | assert.equal(fee.ok, true, JSON.stringify(fee)); |
| 328 | const feeSub = await stripe(`/subscriptions/${enrolled.value.subscriptionId}`); |
| 329 | assert.equal(feeSub.items.data[0].price.unit_amount, 2_500); |
| 330 | assert.equal(feeSub.metadata.g1t_fee_cents, '2500'); |
| 331 | assert.equal(feeSub.collection_method, 'send_invoice'); |
| 332 | assert.equal((await standing(a)).periods.length, 3); |
| 333 | passed('a custom monthly fee changes the subscription price apart from the allowance, each term kept as a period'); |
| 334 | assert.equal((await enroll(a, 50_000_000, 0)).ok, true); |
| 335 | // Conversion with a card that pays: the same subscription becomes the plan. |
| 336 | const converted = await convert(a); |
| 337 | assert.equal(converted.ok, true, JSON.stringify(converted)); |
| 338 | assert.equal(converted.value.status, 'converted', JSON.stringify(converted.value)); |
| 339 | const paid = await stripe(`/subscriptions/${enrolled.value.subscriptionId}`); |
| 340 | assert.equal(paid.items.data[0].price.unit_amount, 2_100); |
| 341 | assert.equal((await stripe(`/subscriptions?customer=${enrolled.value.customerId}&status=all`)).data.length, 1); |
| 342 | assert.equal((await rpc('entitlements', { workspace: a })).plan, 'paid'); |
| 343 | const ended = await standing(a); |
| 344 | assert.equal(ended.status, 'ended'); |
| 345 | assert.equal(ended.endedReason, 'converted'); |
| 346 | assert.ok(ended.moneyReceivedMicros >= 21_000_000, JSON.stringify(ended)); |
| 347 | passed('conversion with a paying card turns the same subscription into the plan and records its payment as money received'); |
| 348 | |
| 349 | // 3DS: nothing changes until the bank is answered; asking again is the same invoice. |
| 350 | const b = `cmp-b-${stamp}`; |
| 351 | const bEnrolled = await enroll(b, 100_000_000); |
| 352 | made.push(bEnrolled.value.customerId); |
| 353 | await card(bEnrolled.value.customerId, 'pm_card_authenticationRequired'); |
| 354 | const needs = await convert(b); |
| 355 | assert.equal(needs.value?.status, 'needs_action', JSON.stringify(needs)); |
| 356 | assert.match(needs.value.url, /^https:\/\/invoice\.stripe\.com\//); |
| 357 | const invoicesBefore = (await stripe(`/invoices?subscription=${bEnrolled.value.subscriptionId}`)).data.length; |
| 358 | const twice = await convert(b); |
| 359 | assert.equal(twice.value?.status, 'needs_action', JSON.stringify(twice)); |
| 360 | // Hosted invoice links are re-signed on each read: the invoice count below is the check. |
| 361 | assert.equal((await stripe(`/invoices?subscription=${bEnrolled.value.subscriptionId}`)).data.length, invoicesBefore); |
| 362 | assert.equal((await rpc('entitlements', { workspace: b })).plan, 'complimentary'); |
| 363 | assert.equal((await stripe(`/subscriptions/${bEnrolled.value.subscriptionId}`)).items.data[0].price.unit_amount, 0); |
| 364 | assert.equal((await standing(b)).status, 'active'); |
| 365 | passed('3DS leaves the enrollment as it was, and asking again returns the same invoice rather than a new one'); |
| 366 | |
| 367 | // Declined: the enrollment stands, one subscription. |
| 368 | const c = `cmp-c-${stamp}`; |
| 369 | const cEnrolled = await enroll(c, 100_000_000); |
| 370 | made.push(cEnrolled.value.customerId); |
| 371 | await card(cEnrolled.value.customerId, 'pm_card_chargeCustomerFail'); |
| 372 | const declined = await convert(c); |
| 373 | assert.equal(declined.value?.status, 'declined', JSON.stringify(declined)); |
| 374 | assert.equal((await rpc('entitlements', { workspace: c })).plan, 'complimentary'); |
| 375 | assert.equal((await stripe(`/subscriptions?customer=${cEnrolled.value.customerId}&status=all`)).data.length, 1); |
| 376 | passed('a declined card leaves the enrollment and its one subscription unchanged'); |
| 377 | |
| 378 | // No card at all: a card check page, nothing charged. |
| 379 | const d = `cmp-d-${stamp}`; |
| 380 | const dEnrolled = await enroll(d, 100_000_000); |
| 381 | made.push(dEnrolled.value.customerId); |
| 382 | const needsCard = await convert(d); |
| 383 | assert.equal(needsCard.value?.status, 'needs_card', JSON.stringify(needsCard)); |
| 384 | assert.match(needsCard.value.url, /^https:\/\/checkout\.stripe\.com\//); |
| 385 | passed('without a saved card, conversion sends the owner to a card check first'); |
| 386 | const member = { id: 'usr_member', username: 'member', verified: true, workspaces: [{ slug: d, role: 'member' }] }; |
| 387 | assert.equal((await rpc('convert_to_paid', { actor: member, workspace: d, returnUrl: `${endpoint}/return` })).ok, false); |
| 388 | passed('a member who does not manage billing cannot convert'); |
| 389 | |
| 390 | // Charged past the allowance (employees, friends): refused without a |
| 391 | // card, then admitted under the workspace's limits once it has one. |
| 392 | const e = `cmp-e-${stamp}`; |
| 393 | const eEnrolled = await rpc('admin_set_complimentary', { workspace: e, allowance_micros: 5_000_000, unlimited: false, fee_cents: 0, charge_past_allowance: true, note: 'sandbox audit', by: 'audit' }); |
| 394 | assert.equal(eEnrolled.ok, true, JSON.stringify(eEnrolled)); |
| 395 | made.push(eEnrolled.value.customerId); |
| 396 | assert.equal(eEnrolled.value.chargePastAllowance, true); |
| 397 | assert.equal((await stripe(`/subscriptions/${eEnrolled.value.subscriptionId}`)).metadata.g1t_past_allowance, 'charge'); |
| 398 | const noCard = await reserve(e, 20_000_000); |
| 399 | assert.equal(noCard.ok, false, JSON.stringify(noCard)); |
| 400 | assert.match(noCard.error.message, /Add a card/); |
| 401 | const eEnt = await rpc('entitlements', { workspace: e }); |
| 402 | assert.equal(eEnt.complimentary.chargePastAllowance, true); |
| 403 | assert.equal(eEnt.complimentary.hasCard, false); |
| 404 | passed('charged past the allowance, without a card: work past it is refused and the owner is told to add one'); |
| 405 | await card(eEnrolled.value.customerId, 'pm_card_visa'); |
| 406 | localSQL(`UPDATE accounts SET card_last4 = '4242' WHERE workspace = '${e}';`); |
| 407 | const withCard = await reserve(e, 20_000_000); |
| 408 | assert.equal(withCard.ok, true, JSON.stringify(withCard)); |
| 409 | const eAfter = await rpc('entitlements', { workspace: e }); |
| 410 | assert.equal(eAfter.hasPlan, true); |
| 411 | assert.ok(eAfter.ceilingMicros > 0, JSON.stringify(eAfter)); |
| 412 | passed('charged past the allowance, with a card: work past it is admitted under the workspace\'s own limits'); |
| 413 | |
| 414 | // Revocation: back to Free now, subscription canceled. |
| 415 | const revoked = await rpc('admin_end_complimentary', { workspace: d, note: 'sandbox audit', by: 'audit' }); |
| 416 | assert.equal(revoked.ok, true, JSON.stringify(revoked)); |
| 417 | assert.equal(revoked.value.status, 'ended'); |
| 418 | assert.equal((await stripe(`/subscriptions/${dEnrolled.value.subscriptionId}`)).status, 'canceled'); |
| 419 | assert.equal((await rpc('entitlements', { workspace: d })).plan, 'free'); |
| 420 | passed('ending an enrollment cancels its subscription and returns the workspace to Free'); |
| 421 | writeFileSync(resolve(dir, 'complimentary-results.json'), JSON.stringify({ checkedAt: new Date().toISOString(), stripeAccount: auditAccount.id, mode: 'sandbox', worker: 'local', checks, |
| 422 | limitations: ['Webhooks not delivered publicly', '3DS not completed in a browser here', 'No test clock renewal of an enrollment'] }, null, 2)); |
| 423 | } finally { |
| 424 | for (const customer of made.filter(Boolean)) { |
| 425 | try { await stripe(`/customers/${customer}`, undefined, 'DELETE'); } catch (error) { console.error(`cleanup ${customer}: ${error.message}`); } |
| 426 | } |
| 427 | } |
| 428 | } else if (phase === "cleanup") { |
| 429 | const { customer } = JSON.parse(readFileSync(fixturePath, "utf8")); |
| 430 | await stripe(`/customers/${customer}`, undefined, "DELETE"); |
| 431 | console.log("Disposable sandbox customer removed; live account untouched."); |
| 432 | } else throw Error("Use seed, run or cleanup"); |