@bugmole/cli 0.4.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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,466 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { Env } from "../core.js";
4
+ import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
5
+ import { recordUsage } from "./ledger.js";
6
+ import { changeKind, closeUsagePeriods, chaseOverdueInvoices, syncWorkspaceBilling, CANCEL_REASON, SCHEDULED_REASON, UPGRADE_REASON } from "./subscriptions.js";
7
+ import { freePlan } from "./entitlements.js";
8
+ import { proRataCents, toCents } from "./provider.js";
9
+ import { canSpend } from "./ledger.js";
10
+
11
+ const DAY = 86_400_000;
12
+ const PLAN_IDS = { PAYPAL_PLAN_TEAM_MONTH: "P-TEAM-M", PAYPAL_PLAN_TEAM_YEAR: "P-TEAM-Y", PAYPAL_PLAN_BUSINESS_MONTH: "P-BIZ-M", PAYPAL_PLAN_BUSINESS_YEAR: "P-BIZ-Y" };
13
+ const PRICES: Record<string, string> = { "P-TEAM-M": "249.00", "P-TEAM-Y": "2388.00", "P-BIZ-M": "899.00", "P-BIZ-Y": "8628.00" };
14
+
15
+ // --- A scripted PayPal ---------------------------------------------------------------------------
16
+
17
+ type Sub = Record<string, any>;
18
+
19
+ function fakePayPal() {
20
+ const state = {
21
+ subscriptions: new Map<string, Sub>(),
22
+ sales: new Map<string, Array<{ id: string; status: string; time: string; amount: string }>>(),
23
+ invoices: new Map<string, { status: string; number: string; body: any }>(),
24
+ verification: "SUCCESS" as string,
25
+ verifyDown: false,
26
+ refundFails: false,
27
+ calls: [] as Array<{ method: string; path: string; body: any }>,
28
+ next: 1,
29
+ };
30
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
31
+ const request = new Request(input, init);
32
+ const url = new URL(request.url);
33
+ const path = url.pathname;
34
+ const raw = request.method === "GET" || path === "/v1/oauth2/token" ? "" : await request.text();
35
+ const body = raw ? JSON.parse(raw) : null;
36
+ state.calls.push({ method: request.method, path, body });
37
+ if (path === "/v1/oauth2/token") return Response.json({ access_token: "token" });
38
+ if (path === "/v1/notifications/verify-webhook-signature") {
39
+ if (state.verifyDown) return new Response("down", { status: 500 });
40
+ return Response.json({ verification_status: state.verification });
41
+ }
42
+ if (path === "/v1/billing/subscriptions" && request.method === "POST") {
43
+ const id = `I-${state.next++}`;
44
+ state.subscriptions.set(id, { id, status: "APPROVAL_PENDING", custom_id: body.custom_id, plan_id: body.plan_id, start_time: body.start_time ?? new Date().toISOString() });
45
+ return Response.json({ id, links: [{ rel: "approve", href: `https://paypal.test/approve/${id}` }] }, { status: 201 });
46
+ }
47
+ let match = path.match(/^\/v1\/billing\/subscriptions\/([^/]+)\/transactions$/);
48
+ if (match) return Response.json({ transactions: (state.sales.get(match[1]) ?? []).map((sale) => ({ ...sale, amount_with_breakdown: { gross_amount: { value: sale.amount, currency_code: "USD" } } })) });
49
+ match = path.match(/^\/v1\/billing\/subscriptions\/([^/]+)\/cancel$/);
50
+ if (match) {
51
+ const sub = state.subscriptions.get(match[1]);
52
+ if (sub) Object.assign(sub, { status: "CANCELLED", status_change_note: body.reason, status_update_time: new Date().toISOString() });
53
+ return new Response(null, { status: 204 });
54
+ }
55
+ match = path.match(/^\/v1\/billing\/subscriptions\/([^/]+)$/);
56
+ if (match) {
57
+ const sub = state.subscriptions.get(match[1]);
58
+ return sub ? Response.json(sub) : Response.json({ name: "RESOURCE_NOT_FOUND" }, { status: 404 });
59
+ }
60
+ match = path.match(/^\/v1\/payments\/sale\/([^/]+)\/refund$/);
61
+ if (match) {
62
+ if (state.refundFails) return Response.json({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "TRANSACTION_REFUSED" }] }, { status: 422 });
63
+ return Response.json({ id: `REFUND-${match[1]}`, state: "completed" }, { status: 201 });
64
+ }
65
+ if (path === "/v2/invoicing/invoices" && request.method === "POST") {
66
+ const id = `INV2-${state.next++}`;
67
+ state.invoices.set(id, { status: "DRAFT", number: body.detail.invoice_number, body });
68
+ return Response.json({ id }, { status: 201 });
69
+ }
70
+ match = path.match(/^\/v2\/invoicing\/invoices\/([^/]+)\/send$/);
71
+ if (match) {
72
+ state.invoices.get(match[1])!.status = "SENT";
73
+ return Response.json({ href: `https://paypal.test/invoice/${match[1]}` });
74
+ }
75
+ match = path.match(/^\/v2\/invoicing\/invoices\/([^/]+)$/);
76
+ if (match) {
77
+ const invoice = state.invoices.get(match[1]);
78
+ return invoice ? Response.json({ id: match[1], status: invoice.status }) : new Response("", { status: 404 });
79
+ }
80
+ return new Response("not found", { status: 404 });
81
+ }) as unknown as typeof fetch;
82
+
83
+ /** The subscriber approves; PayPal activates and (unless it starts later) takes the first payment. */
84
+ const approve = (id: string, { at = Date.now(), pay = true, customId }: { at?: number; pay?: boolean; customId?: string } = {}) => {
85
+ const sub = state.subscriptions.get(id)!;
86
+ // A subscription that starts right away starts when it's approved; a scheduled one keeps its start.
87
+ const scheduled = Date.parse(sub.start_time) > Date.now() + 1000;
88
+ const start = scheduled ? Date.parse(sub.start_time) : at;
89
+ sub.start_time = new Date(start).toISOString();
90
+ const yearly = String(sub.plan_id).endsWith("-Y");
91
+ const next = new Date(start);
92
+ if (yearly) next.setUTCFullYear(next.getUTCFullYear() + 1); else next.setUTCMonth(next.getUTCMonth() + 1);
93
+ Object.assign(sub, { status: "ACTIVE", ...(customId ? { custom_id: customId } : {}), billing_info: { next_billing_time: next.toISOString() } });
94
+ if (pay && start <= Date.now() + 1000) paySale(id, new Date(at).toISOString());
95
+ };
96
+ const paySale = (id: string, time: string, amount = PRICES[state.subscriptions.get(id)!.plan_id]) => {
97
+ const list = state.sales.get(id) ?? [];
98
+ const saleId = `SALE-${id}-${list.length + 1}`;
99
+ list.push({ id: saleId, status: "COMPLETED", time, amount });
100
+ state.sales.set(id, list);
101
+ return saleId;
102
+ };
103
+ return { state, doFetch, approve, paySale };
104
+ }
105
+
106
+ function billingEnv(paypal: ReturnType<typeof fakePayPal>, extra: Record<string, unknown> = {}) {
107
+ return createTestEnv({
108
+ FEATURE_BILLING: "on",
109
+ FEATURE_ENTITLEMENTS: "on",
110
+ PAYPAL_CLIENT_ID: "client",
111
+ PAYPAL_CLIENT_SECRET: "secret",
112
+ PAYPAL_WEBHOOK_ID: "WH-1",
113
+ PAYPAL_FETCH: paypal.doFetch,
114
+ DASHBOARD_URL: "https://dash.test",
115
+ ...PLAN_IDS,
116
+ ...extra,
117
+ } as Partial<Env>);
118
+ }
119
+
120
+ const ownerHeaders = { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" };
121
+
122
+ async function paidWorkspace(paypal: ReturnType<typeof fakePayPal>, plan = "team", interval = "month") {
123
+ const setup = billingEnv(paypal);
124
+ const project = await seedProject(setup.env);
125
+ // Remember the owner's email for receipts.
126
+ await call(setup.env, "GET", "/api/workspaces", { as: project.owner, headers: ownerHeaders });
127
+ const started = await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/billing/checkout`, { as: project.owner, body: { plan, interval } });
128
+ assert.equal(started.status, 201, JSON.stringify(started.json));
129
+ const subscriptionId = started.json.approveUrl.split("/").pop();
130
+ return { ...setup, ...project, checkoutId: started.json.checkoutId as string, subscriptionId: subscriptionId as string };
131
+ }
132
+
133
+ function webhook(env: Env, type: string, resource: Record<string, unknown>, id = `WH-${crypto.randomUUID()}`) {
134
+ return call(env, "POST", "/api/billing/webhooks/paypal", {
135
+ body: { id, event_type: type, resource },
136
+ headers: {
137
+ "paypal-transmission-id": "t", "paypal-transmission-time": "now", "paypal-cert-url": "https://paypal.test/cert",
138
+ "paypal-auth-algo": "SHA256withRSA", "paypal-transmission-sig": "sig",
139
+ },
140
+ });
141
+ }
142
+
143
+ // --- Pure helpers --------------------------------------------------------------------------------
144
+
145
+ test("money and plan-change arithmetic", () => {
146
+ assert.equal(toCents("249.00"), 24900);
147
+ assert.equal(toCents("0.5"), 50);
148
+ assert.equal(toCents("12"), 1200);
149
+ assert.ok(Number.isNaN(toCents("1e3")));
150
+ const start = "2026-09-01T00:00:00.000Z";
151
+ const end = "2026-10-01T00:00:00.000Z";
152
+ assert.equal(proRataCents(24900, start, end, new Date("2026-09-11T00:00:00.000Z")), 16600);
153
+ assert.equal(proRataCents(24900, start, end, new Date("2026-10-02T00:00:00.000Z")), 0);
154
+ const team = { ...freePlan("w"), plan: "team" as const, interval: "month" as const };
155
+ assert.equal(changeKind(freePlan("w"), "team", "month"), "new");
156
+ assert.equal(changeKind(team, "team", "month"), "same");
157
+ assert.equal(changeKind(team, "business", "month"), "upgrade");
158
+ assert.equal(changeKind(team, "team", "year"), "upgrade");
159
+ assert.equal(changeKind({ ...team, plan: "business" }, "team", "year"), "downgrade");
160
+ assert.equal(changeKind({ ...team, interval: "year" }, "team", "month"), "downgrade");
161
+ });
162
+
163
+ // --- Checkout ------------------------------------------------------------------------------------
164
+
165
+ test("a checkout activates the plan only once PayPal says it's paid, and records the payment once", async () => {
166
+ const paypal = fakePayPal();
167
+ const { env, workspaceId, owner, checkoutId, subscriptionId, email } = await paidWorkspace(paypal);
168
+ const created = paypal.state.subscriptions.get(subscriptionId)!;
169
+ assert.equal(created.custom_id, checkoutId);
170
+ assert.equal(created.plan_id, "P-TEAM-M");
171
+ const complete = () => call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/${checkoutId}/complete`, { as: owner });
172
+
173
+ assert.equal((await complete()).json.code, "payment_not_completed", "not approved yet");
174
+ paypal.approve(subscriptionId, { pay: false });
175
+ assert.equal((await complete()).json.code, "payment_pending", "approved, but PayPal hasn't posted the payment");
176
+ paypal.paySale(subscriptionId, new Date().toISOString());
177
+ const done = await complete();
178
+ assert.equal(done.status, 200, JSON.stringify(done.json));
179
+ assert.equal(done.json.plan.id, "team");
180
+ assert.equal((await complete()).status, 200, "repeatable");
181
+
182
+ const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
183
+ assert.equal(billing.json.subscription.plan, "team");
184
+ assert.equal(billing.json.subscription.renews, true);
185
+ assert.equal(billing.json.invoices.length, 1);
186
+ assert.equal(billing.json.invoices[0].totalCents, 24900);
187
+ const usage = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
188
+ assert.equal(usage.json.limits.cloudMinutes, 3000);
189
+ assert.equal(email.sent.filter((message) => /Receipt/.test(message.subject)).length, 1);
190
+ assert.equal(email.sent[0].to[0].email, "owner@acme.test");
191
+ });
192
+
193
+ test("someone else's subscription can't pay for a checkout, and only billing managers can buy", async () => {
194
+ const paypal = fakePayPal();
195
+ const { env, workspaceId, owner, checkoutId, subscriptionId } = await paidWorkspace(paypal);
196
+ paypal.approve(subscriptionId, { customId: "chk_someone_else" });
197
+ const attempt = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/${checkoutId}/complete`, { as: owner });
198
+ assert.equal(attempt.status, 402);
199
+ assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/chk_nope/complete`, { as: owner })).status, 404);
200
+
201
+ const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: owner, body: { email: "dev@acme.test", role: "member" } });
202
+ const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
203
+ await call(env, "POST", `/api/invitations/${token}/accept`, { as: "user_dev", headers: { "x-aegis-email": "dev@acme.test", "x-aegis-email-verified": "true" } });
204
+ const member = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout`, { as: "user_dev", body: { plan: "team", interval: "month" } });
205
+ assert.equal(member.status, 403);
206
+ const view = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: "user_dev" });
207
+ assert.equal(view.json.canManage, false);
208
+ assert.deepEqual(view.json.invoices, [], "members don't see invoices");
209
+ });
210
+
211
+ test("checkout is closed until billing is switched on and PayPal is configured", async () => {
212
+ const paypal = fakePayPal();
213
+ const off = billingEnv(paypal, { FEATURE_BILLING: "off" });
214
+ const project = await seedProject(off.env);
215
+ const blocked = await call(off.env, "POST", `/api/workspaces/${project.workspaceId}/billing/checkout`, { as: project.owner, body: { plan: "team", interval: "month" } });
216
+ assert.equal(blocked.status, 503);
217
+ const unconfigured = billingEnv(paypal, { PAYPAL_PLAN_BUSINESS_YEAR: "" });
218
+ const other = await seedProject(unconfigured.env);
219
+ const offers = await call(unconfigured.env, "GET", `/api/workspaces/${other.workspaceId}/billing`, { as: other.owner });
220
+ const businessYear = offers.json.offers.find((offer: { plan: string; interval: string }) => offer.plan === "business" && offer.interval === "year");
221
+ assert.equal(businessYear.available, false);
222
+ assert.equal(businessYear.priceCents, 862800);
223
+ const refused = await call(unconfigured.env, "POST", `/api/workspaces/${other.workspaceId}/billing/checkout`, { as: other.owner, body: { plan: "business", interval: "year" } });
224
+ assert.equal(refused.json.code, "billing_unavailable");
225
+ });
226
+
227
+ // --- Webhooks ------------------------------------------------------------------------------------
228
+
229
+ test("a webhook finishes a checkout whose return page never came back, once", async () => {
230
+ const paypal = fakePayPal();
231
+ const { env, workspaceId, owner, subscriptionId } = await paidWorkspace(paypal);
232
+ paypal.approve(subscriptionId);
233
+ const sale = paypal.state.sales.get(subscriptionId)![0].id;
234
+ const first = await webhook(env, "PAYMENT.SALE.COMPLETED", { id: sale, billing_agreement_id: subscriptionId }, "WH-A");
235
+ assert.equal(first.status, 200);
236
+ const again = await webhook(env, "PAYMENT.SALE.COMPLETED", { id: sale, billing_agreement_id: subscriptionId }, "WH-A");
237
+ assert.equal(again.json.duplicate, true);
238
+ const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
239
+ assert.equal(billing.json.subscription.plan, "team");
240
+ assert.equal(billing.json.invoices.length, 1);
241
+
242
+ paypal.state.verification = "FAILURE";
243
+ assert.equal((await webhook(env, "BILLING.SUBSCRIPTION.CANCELLED", { id: subscriptionId })).status, 400);
244
+ paypal.state.verifyDown = true;
245
+ assert.equal((await webhook(env, "BILLING.SUBSCRIPTION.CANCELLED", { id: subscriptionId })).status, 503, "PayPal retries");
246
+ assert.equal((await call(env, "POST", "/api/billing/webhooks/stripe", { body: {} })).status, 404);
247
+ });
248
+
249
+ // --- Upgrades and downgrades ---------------------------------------------------------------------
250
+
251
+ async function activeTeam(paypal: ReturnType<typeof fakePayPal>, paidDaysAgo = 10) {
252
+ const setup = await paidWorkspace(paypal);
253
+ paypal.approve(setup.subscriptionId, { at: Date.now() - paidDaysAgo * DAY });
254
+ const done = await call(setup.env, "POST", `/api/workspaces/${setup.workspaceId}/billing/checkout/${setup.checkoutId}/complete`, { as: setup.owner });
255
+ assert.equal(done.status, 200, JSON.stringify(done.json));
256
+ return setup;
257
+ }
258
+
259
+ test("an upgrade starts now, bills the old plan's usage, cancels the old subscription and refunds its unused part", async () => {
260
+ const paypal = fakePayPal();
261
+ const { env, db, workspaceId, owner, subscriptionId: teamSub, email } = await activeTeam(paypal);
262
+ const team = JSON.parse((db.db.prepare("SELECT last_payment_json AS payment FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).payment);
263
+ team.start = team.periodStart;
264
+ team.end = team.periodEnd;
265
+ await recordUsage(env, { workspaceId, kind: "browser_minute", quantity: 3_100, source: "cloud_run", idempotencyKey: "big-run" });
266
+
267
+ const started = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout`, { as: owner, body: { plan: "business", interval: "month" } });
268
+ assert.equal(started.json.effective, "now");
269
+ const bizSub = started.json.approveUrl.split("/").pop();
270
+ paypal.approve(bizSub);
271
+ const done = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/${started.json.checkoutId}/complete`, { as: owner });
272
+ assert.equal(done.json.plan.id, "business");
273
+
274
+ assert.equal(paypal.state.subscriptions.get(teamSub)!.status, "CANCELLED");
275
+ assert.equal(paypal.state.subscriptions.get(teamSub)!.status_change_note, UPGRADE_REASON);
276
+ const refund = paypal.state.calls.find((c) => c.path.endsWith("/refund"))!;
277
+ const expected = Math.floor((24900 * (Date.parse(team.end) - Date.now())) / (Date.parse(team.end) - Date.parse(team.start)));
278
+ assert.ok(Math.abs(Math.round(Number(refund.body.amount.total) * 100) - expected) <= 1, `refund ${refund.body.amount.total} ≈ ${expected / 100}`);
279
+ assert.ok(email.sent.some((message) => /Refund/.test(message.subject)));
280
+
281
+ const invoice = [...paypal.state.invoices.values()][0];
282
+ assert.ok(invoice, "the Team period's extra minutes were invoiced");
283
+ assert.equal(invoice.body.items[0].quantity, "100");
284
+ assert.equal(invoice.body.items[0].unit_amount.value, "0.04");
285
+ assert.equal(invoice.status, "SENT");
286
+
287
+ // The daily retry finds nothing left to do.
288
+ const refunds = paypal.state.calls.filter((c) => c.path.endsWith("/refund")).length;
289
+ await syncWorkspaceBilling(env, workspaceId);
290
+ assert.equal(paypal.state.calls.filter((c) => c.path.endsWith("/refund")).length, refunds);
291
+ });
292
+
293
+ test("a refused refund is recorded for a person to handle; the upgrade still goes through", async () => {
294
+ const paypal = fakePayPal();
295
+ const { env, db, workspaceId, owner } = await activeTeam(paypal);
296
+ paypal.state.refundFails = true;
297
+ const started = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout`, { as: owner, body: { plan: "team", interval: "year" } });
298
+ paypal.approve(started.json.approveUrl.split("/").pop());
299
+ const done = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/${started.json.checkoutId}/complete`, { as: owner });
300
+ assert.equal(done.json.plan.interval, "year");
301
+ const checkout = db.db.prepare("SELECT refund_json AS refund, settled_at AS settled FROM billing_checkouts WHERE id = ?").get(started.json.checkoutId) as any;
302
+ assert.equal(JSON.parse(checkout.refund).status, "failed");
303
+ assert.ok(checkout.settled);
304
+ });
305
+
306
+ test("a downgrade waits for the end of the paid period, then the new plan takes over", async () => {
307
+ const paypal = fakePayPal();
308
+ const { env, db, workspaceId, owner, subscriptionId: bizSub } = await (async () => {
309
+ const setup = await paidWorkspace(paypal, "business", "month");
310
+ paypal.approve(setup.subscriptionId, { at: Date.now() - 20 * DAY });
311
+ await call(setup.env, "POST", `/api/workspaces/${setup.workspaceId}/billing/checkout/${setup.checkoutId}/complete`, { as: setup.owner });
312
+ return setup;
313
+ })();
314
+ const periodEnd = (db.db.prepare("SELECT current_period_end AS end FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).end;
315
+
316
+ const started = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout`, { as: owner, body: { plan: "team", interval: "month" } });
317
+ assert.equal(started.json.effective, "period_end");
318
+ const teamSub = started.json.approveUrl.split("/").pop();
319
+ assert.equal(paypal.state.subscriptions.get(teamSub)!.start_time, new Date(Date.parse(periodEnd)).toISOString());
320
+ paypal.approve(teamSub);
321
+ const done = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/${started.json.checkoutId}/complete`, { as: owner });
322
+ assert.equal(done.json.status, "scheduled");
323
+ assert.equal(done.json.plan.id, "business", "still Business until the period ends");
324
+ assert.equal(paypal.state.subscriptions.get(bizSub)!.status_change_note, SCHEDULED_REASON);
325
+ const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
326
+ assert.deepEqual(billing.json.subscription.changesTo, { plan: "team", interval: "month" });
327
+
328
+ // The period ends and PayPal takes Team's first payment.
329
+ const after = new Date(Date.parse(periodEnd) + 60_000);
330
+ paypal.paySale(teamSub, after.toISOString());
331
+ await syncWorkspaceBilling(env, workspaceId, after);
332
+ const plan = db.db.prepare("SELECT plan, provider_subscription_id AS sub, status FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any;
333
+ assert.deepEqual({ ...plan }, { plan: "team", sub: teamSub, status: "active" });
334
+ });
335
+
336
+ // --- Cancellation, failed payments, read-only projects -------------------------------------------
337
+
338
+ test("a cancelled plan runs to the end of its period, then extra projects become read-only", async () => {
339
+ const paypal = fakePayPal();
340
+ const { env, db, workspaceId, owner, subscriptionId, projectId } = await activeTeam(paypal);
341
+ for (const id of ["second", "third"]) {
342
+ const created = await call(env, "POST", "/api/projects/register", { as: owner, body: { id, name: id, workspaceId, environment: "staging", storageProvider: "r2" } });
343
+ assert.equal(created.status, 201);
344
+ }
345
+ const cancelled = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/cancel`, { as: owner });
346
+ assert.equal(cancelled.status, 200);
347
+ assert.equal(paypal.state.subscriptions.get(subscriptionId)!.status_change_note, CANCEL_REASON);
348
+ // PayPal's own cancellation event for it changes nothing.
349
+ await webhook(env, "BILLING.SUBSCRIPTION.CANCELLED", { id: subscriptionId });
350
+ let billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
351
+ assert.equal(billing.json.subscription.plan, "team");
352
+ assert.equal(billing.json.subscription.renews, false);
353
+
354
+ const end = (db.db.prepare("SELECT current_period_end AS end FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).end;
355
+ await syncWorkspaceBilling(env, workspaceId, new Date(Date.parse(end) + 1000));
356
+ billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
357
+ assert.equal(billing.json.subscription, null);
358
+ const projects = await call(env, "GET", "/api/projects", { as: owner });
359
+ const locked = projects.json.projects.filter((project: { lockedAt: string | null }) => project.lockedAt).map((project: { id: string }) => project.id).sort();
360
+ assert.deepEqual(locked, ["second", "third"], "the oldest project stays writable");
361
+ await seedPlan(env, projectId, owner);
362
+ assert.equal((await call(env, "POST", "/api/projects/third/runs", { as: owner, body: { planId: "checkout", idempotencyKey: "x" } })).status, 403);
363
+ assert.equal((await call(env, "GET", "/api/projects/third/runs", { as: owner })).status, 200, "still readable");
364
+ assert.equal((await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "y" } })).status, 201);
365
+ });
366
+
367
+ test("a failed renewal gives a week of grace, then pauses the cloud, then moves to Free", async () => {
368
+ const paypal = fakePayPal();
369
+ const { env, db, workspaceId, subscriptionId, email } = await activeTeam(paypal);
370
+ paypal.state.subscriptions.get(subscriptionId)!.status = "SUSPENDED";
371
+ const now = Date.now();
372
+ await syncWorkspaceBilling(env, workspaceId, new Date(now));
373
+ const row = db.db.prepare("SELECT status, grace_until AS grace FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any;
374
+ assert.equal(row.status, "past_due");
375
+ assert.ok(email.sent.some((message) => /didn't go through/.test(message.subject)));
376
+ assert.equal((await canSpend(env, workspaceId, "browser_minute", 1, new Date(now + DAY))).allowed, true, "grace");
377
+ const paused = await canSpend(env, workspaceId, "browser_minute", 1, new Date(now + 8 * DAY));
378
+ assert.equal(!paused.allowed && paused.reason, "suspended");
379
+
380
+ await syncWorkspaceBilling(env, workspaceId, new Date(now + 22 * DAY));
381
+ const after = db.db.prepare("SELECT status FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any;
382
+ assert.equal(after.status, "canceled");
383
+ assert.equal(paypal.state.subscriptions.get(subscriptionId)!.status, "CANCELLED");
384
+ });
385
+
386
+ test("paying the failed renewal restores the plan", async () => {
387
+ const paypal = fakePayPal();
388
+ const { env, db, workspaceId, subscriptionId } = await activeTeam(paypal, 31);
389
+ const sub = paypal.state.subscriptions.get(subscriptionId)!;
390
+ sub.status = "SUSPENDED";
391
+ await syncWorkspaceBilling(env, workspaceId);
392
+ sub.status = "ACTIVE";
393
+ const next = new Date(Date.parse(sub.billing_info.next_billing_time));
394
+ next.setUTCMonth(next.getUTCMonth() + 1);
395
+ sub.billing_info.next_billing_time = next.toISOString();
396
+ paypal.paySale(subscriptionId, new Date().toISOString());
397
+ await syncWorkspaceBilling(env, workspaceId);
398
+ const row = db.db.prepare("SELECT status, current_period_end AS end FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any;
399
+ assert.equal(row.status, "active");
400
+ assert.equal(row.end, next.toISOString());
401
+ assert.equal((db.db.prepare("SELECT COUNT(*) AS c FROM invoices WHERE workspace_id = ? AND kind = 'subscription'").get(workspaceId) as any).c, 2);
402
+ });
403
+
404
+ // --- Usage invoices ------------------------------------------------------------------------------
405
+
406
+ test("each month's extra usage is invoiced once through PayPal, and an unpaid invoice pauses the cloud", async () => {
407
+ const paypal = fakePayPal();
408
+ const { env, db, workspaceId, owner } = await activeTeam(paypal, 40);
409
+ const anchor = (db.db.prepare("SELECT current_period_start AS start FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).start;
410
+ // Usage in the first (now closed) month.
411
+ const firstMonth = new Date(Date.parse(anchor) + DAY);
412
+ await recordUsage(env, { workspaceId, kind: "browser_minute", quantity: 3_250, source: "cloud_run", idempotencyKey: "m1", now: firstMonth });
413
+ await recordUsage(env, { workspaceId, kind: "ai_credit", quantity: 1_600, source: "ai", idempotencyKey: "a1", now: firstMonth });
414
+
415
+ assert.equal(await closeUsagePeriods(env), 1);
416
+ assert.equal(await closeUsagePeriods(env), 1, "found again, not sent again");
417
+ assert.equal(paypal.state.invoices.size, 1);
418
+ const sent = [...paypal.state.invoices.values()][0];
419
+ assert.deepEqual(sent.body.items.map((item: any) => [item.quantity, item.unit_amount.value]), [["250", "0.04"], ["100", "0.01"]]);
420
+ assert.equal(sent.body.primary_recipients[0].billing_info.email_address, "owner@acme.test");
421
+
422
+ const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
423
+ const usageInvoice = billing.json.invoices.find((invoice: { kind: string }) => invoice.kind === "usage");
424
+ assert.equal(usageInvoice.totalCents, 1100);
425
+ assert.equal(usageInvoice.status, "open");
426
+ assert.match(usageInvoice.payUrl, /paypal\.test\/invoice/);
427
+
428
+ // Unpaid three weeks later: the cloud pauses.
429
+ await chaseOverdueInvoices(env, new Date(Date.now() + 23 * DAY));
430
+ assert.equal((db.db.prepare("SELECT status FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).status, "past_due");
431
+
432
+ // Paid: PayPal tells us, and the pause lifts.
433
+ const [invoiceId] = [...paypal.state.invoices.keys()];
434
+ paypal.state.invoices.get(invoiceId)!.status = "PAID";
435
+ db.db.prepare("UPDATE invoices SET due_at = ? WHERE id = ?").run(new Date(Date.now() + DAY).toISOString(), usageInvoice.id);
436
+ await webhook(env, "INVOICING.INVOICE.PAID", { invoice: { id: invoiceId } });
437
+ const paid = await call(env, "GET", `/api/workspaces/${workspaceId}/invoices/${usageInvoice.id}`, { as: owner });
438
+ assert.equal(paid.json.invoice.status, "paid");
439
+ assert.equal(paid.json.seller.name, "The AI Inc.");
440
+ assert.equal(paid.json.invoice.lines.length, 2);
441
+ assert.equal((db.db.prepare("SELECT status FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).status, "active");
442
+ });
443
+
444
+ test("a month within the allowance isn't invoiced", async () => {
445
+ const paypal = fakePayPal();
446
+ const { env, workspaceId } = await activeTeam(paypal, 40);
447
+ assert.equal(await closeUsagePeriods(env), 0);
448
+ assert.equal(paypal.state.invoices.size, 0);
449
+ const listed = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: "user_owner" });
450
+ assert.equal(listed.json.invoices.filter((invoice: { kind: string }) => invoice.kind === "usage").length, 0);
451
+ });
452
+
453
+ test("the billing contact is saved for receipts and validated", async () => {
454
+ const paypal = fakePayPal();
455
+ const { env, workspaceId, owner } = await activeTeam(paypal);
456
+ assert.equal((await call(env, "PUT", `/api/workspaces/${workspaceId}/billing/contact`, { as: owner, body: { email: "not-an-email" } })).status, 422);
457
+ const saved = await call(env, "PUT", `/api/workspaces/${workspaceId}/billing/contact`, {
458
+ as: owner, body: { name: "Acme Ltd", email: "ap@acme.test", address: "1 Main St\nSpringfield", taxId: "VAT123" },
459
+ });
460
+ assert.equal(saved.status, 200);
461
+ const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
462
+ assert.deepEqual(billing.json.contact, { name: "Acme Ltd", email: "ap@acme.test", address: "1 Main St\nSpringfield", taxId: "VAT123" });
463
+ const receipt = await call(env, "GET", `/api/workspaces/${workspaceId}/invoices/${billing.json.invoices[0].id}`, { as: owner });
464
+ assert.equal(receipt.json.invoice.billingName, "Acme Ltd");
465
+ assert.equal(receipt.json.invoice.kind, "subscription");
466
+ });
@@ -0,0 +1,114 @@
1
+ // The payment provider behind paid plans. PayPal is the first; another
2
+ // (WorldFirst Global Checkout, Stripe) implements this same interface and
3
+ // nothing else in billing changes. Bugmole keeps its own usage ledger,
4
+ // invoices and spending limits; a provider only moves money.
5
+ import type { BillingInterval } from "../../billing/plan-catalog.js";
6
+ import type { Env } from "../core.js";
7
+
8
+ export type PaidPlan = "team" | "business";
9
+
10
+ export type ProviderSubscription = {
11
+ id: string;
12
+ /** pending: not approved yet. active: paid up. past_due: a renewal failed. ended: cancelled or expired. */
13
+ status: "pending" | "active" | "past_due" | "ended";
14
+ /** Our reference stamped at creation (the checkout id). Trusted only because we set it. */
15
+ reference: string | null;
16
+ plan: { plan: PaidPlan; interval: BillingInterval } | null;
17
+ startedAt: string | null;
18
+ /** End of the paid period: the next charge. */
19
+ paidThrough: string | null;
20
+ /** Why it ended, as the provider recorded it (our own cancellation reason, if it was us). */
21
+ endNote: string | null;
22
+ endedAt: string | null;
23
+ };
24
+
25
+ export type ProviderPayment = { reference: string; amountCents: number; currency: string; paidAt: string };
26
+
27
+ export type UsageChargeLine = { name: string; description?: string; quantity: number; unitCents: number };
28
+
29
+ export type WebhookEvent = {
30
+ id: string;
31
+ type: string;
32
+ /** The subscription the event is about, when it's about one. */
33
+ subscriptionId?: string;
34
+ /** The usage charge (invoice) the event is about, when it's about one. */
35
+ usageChargeId?: string;
36
+ };
37
+
38
+ export interface BillingProvider {
39
+ readonly id: string;
40
+ /** Credentials and a price for this plan and interval exist. */
41
+ canSell(env: Env, plan: PaidPlan, interval: BillingInterval): boolean;
42
+ createSubscription(env: Env, params: {
43
+ reference: string;
44
+ plan: PaidPlan;
45
+ interval: BillingInterval;
46
+ email?: string;
47
+ returnUrl: string;
48
+ cancelUrl: string;
49
+ requestId: string;
50
+ /** Starts billing later (a downgrade that takes effect when the current period ends). */
51
+ startAt?: string;
52
+ }): Promise<{ subscriptionId: string; approveUrl: string }>;
53
+ getSubscription(env: Env, id: string): Promise<ProviderSubscription | null>;
54
+ /** The payment for the current period (made on or after `since`), once the provider has posted it. */
55
+ latestPayment(env: Env, subscriptionId: string, since: Date, until: Date): Promise<ProviderPayment | null>;
56
+ cancelSubscription(env: Env, id: string, reason: string): Promise<void>;
57
+ refund(env: Env, payment: ProviderPayment, amountCents: number, requestId: string, description: string): Promise<{ reference: string }>;
58
+ /** Bills usage after the month; the payer receives the provider's pay link. */
59
+ chargeUsage(env: Env, params: {
60
+ invoiceNumber: string;
61
+ email: string;
62
+ name?: string;
63
+ note: string;
64
+ lines: UsageChargeLine[];
65
+ requestId: string;
66
+ }): Promise<{ reference: string; url: string | null }>;
67
+ usageChargeStatus(env: Env, reference: string): Promise<"open" | "paid" | "void" | null>;
68
+ /** Null when the request isn't a genuine event; throws when the check itself is unavailable. */
69
+ verifyWebhook(env: Env, request: Request, rawBody: string): Promise<WebhookEvent | null>;
70
+ }
71
+
72
+ export class BillingUnavailableError extends Error {}
73
+
74
+ const providers = new Map<string, BillingProvider>();
75
+
76
+ export function registerBillingProvider(provider: BillingProvider): void {
77
+ providers.set(provider.id, provider);
78
+ }
79
+
80
+ export function billingProvider(id: string): BillingProvider {
81
+ const provider = providers.get(id);
82
+ if (!provider) throw new BillingUnavailableError(`No billing provider "${id}"`);
83
+ return provider;
84
+ }
85
+
86
+ /** The provider new checkouts use. */
87
+ export function defaultBillingProvider(env: Env): BillingProvider {
88
+ return billingProvider((env as Env & { BILLING_PROVIDER?: string }).BILLING_PROVIDER || "paypal");
89
+ }
90
+
91
+ /** Whole cents from a decimal amount string, without float drift. */
92
+ export function toCents(amount: string): number {
93
+ const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(amount.trim());
94
+ if (!match) return NaN;
95
+ const cents = Number(match[2]) * 100 + Number(((match[3] ?? "") + "00").slice(0, 2));
96
+ return match[1] ? -cents : cents;
97
+ }
98
+
99
+ export function fromCents(cents: number): string {
100
+ return (cents / 100).toFixed(2);
101
+ }
102
+
103
+ /**
104
+ * The unused part of a paid period, rounded down to the cent, in integer
105
+ * arithmetic (ported from realistiz-dd's proRataRefund).
106
+ */
107
+ export function proRataCents(paidCents: number, periodStart: string, periodEnd: string, now: Date): number {
108
+ const start = Date.parse(periodStart);
109
+ const end = Date.parse(periodEnd);
110
+ const total = end - start;
111
+ if (!(total > 0)) return 0;
112
+ const remaining = Math.min(Math.max(end - now.getTime(), 0), total);
113
+ return Math.floor((paidCents * remaining) / total);
114
+ }
@@ -0,0 +1,66 @@
1
+ // Plan and usage for the dashboard's Billing page.
2
+ import { PLANS } from "../../billing/plan-catalog.js";
3
+ import { memberOfWorkspace, requestJson, response } from "../core.js";
4
+ import { actorOf, recordAudit } from "../org/audit.js";
5
+ import { type AuthedRouteContext, matchPath, registerRoutes } from "../routes/index.js";
6
+ import { cloudUsageSuspended } from "./entitlements.js";
7
+ import { usageSummary } from "./ledger.js";
8
+
9
+ const MAX_SPENDING_LIMIT_CENTS = 10_000_000;
10
+
11
+ async function getUsage({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
12
+ if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
13
+ const { entitlements, summary } = await usageSummary(env, workspaceId);
14
+ const { plan, ...limits } = entitlements;
15
+ const projects = await env.DB.prepare("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ? AND deleted_at IS NULL")
16
+ .bind(workspaceId).first<{ count: number }>();
17
+ return response({
18
+ plan: {
19
+ id: plan.plan,
20
+ name: PLANS[plan.plan].name,
21
+ interval: plan.interval,
22
+ status: plan.status,
23
+ periodStart: plan.periodStart,
24
+ periodEnd: plan.periodEnd,
25
+ pendingPlan: plan.pendingPlan,
26
+ graceUntil: plan.graceUntil,
27
+ cloudSuspended: cloudUsageSuspended(plan),
28
+ managedByProvider: Boolean(plan.provider),
29
+ },
30
+ limits,
31
+ usage: summary,
32
+ projects: projects?.count ?? 0,
33
+ });
34
+ }
35
+
36
+ async function setSpendingLimit({ env, principal, request }: AuthedRouteContext, workspaceId: string): Promise<Response> {
37
+ if (!(await memberOfWorkspace(env, principal, workspaceId, "billing.manage"))) {
38
+ return response({ error: "Only owners and billing managers can change the spending limit" }, 403);
39
+ }
40
+ const body = await requestJson(request);
41
+ const raw = body?.limitCents;
42
+ const limitCents = raw === null ? null : Number(raw);
43
+ if (limitCents !== null && (!Number.isInteger(limitCents) || limitCents < 0 || limitCents > MAX_SPENDING_LIMIT_CENTS)) {
44
+ return response({ error: "limitCents must be a whole number of cents, or null for no limit" }, 422);
45
+ }
46
+ const current = await env.DB.prepare("SELECT plan, spending_limit_cents AS limitCents FROM workspace_plans WHERE workspace_id = ?")
47
+ .bind(workspaceId).first<{ plan: string; limitCents: number | null }>();
48
+ if (!current || current.plan === "free") {
49
+ return response({ error: "The Free plan has no additional usage to limit", code: "plan_feature" }, 409);
50
+ }
51
+ await env.DB.prepare("UPDATE workspace_plans SET spending_limit_cents = ?, updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
52
+ .bind(limitCents, workspaceId).run();
53
+ await recordAudit(env, actorOf(principal), {
54
+ workspaceId, action: "billing.spending_limit_changed", data: { from: current.limitCents, to: limitCents },
55
+ }, request);
56
+ return response({ spendingLimitCents: limitCents });
57
+ }
58
+
59
+ registerRoutes(async (context) => {
60
+ const { request, url } = context;
61
+ let params = matchPath(url.pathname, "/api/workspaces/:workspaceId/usage");
62
+ if (params && request.method === "GET") return getUsage(context, params.workspaceId);
63
+ params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/spending-limit");
64
+ if (params && request.method === "PUT") return setSpendingLimit(context, params.workspaceId);
65
+ return null;
66
+ });