@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.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
// Paid plans: checkout, upgrades and downgrades, renewals, failed payments,
|
|
2
|
+
// cancellation, and billing usage above the allowance after each month.
|
|
3
|
+
//
|
|
4
|
+
// Rules carried over from realistiz-dd's billing:
|
|
5
|
+
// - The provider is the source of truth. A return page or webhook only says
|
|
6
|
+
// which subscription to look at; its status, owner (the checkout id we
|
|
7
|
+
// stamp as its reference) and payment are read back from the provider.
|
|
8
|
+
// - A payment is recorded once: invoices are unique per provider reference,
|
|
9
|
+
// so the return page, a webhook and the daily sync can race safely.
|
|
10
|
+
// - Upgrades are immediate. The replaced subscription is cancelled at the
|
|
11
|
+
// provider first (so it can't charge again), then its unused part is
|
|
12
|
+
// refunded pro-rata. Every step is recorded as it completes and retried by
|
|
13
|
+
// the daily job if the worker dies half-way.
|
|
14
|
+
// Bugmole-specific rules:
|
|
15
|
+
// - Downgrades and cancellations take effect when the paid period ends.
|
|
16
|
+
// - A failed payment gives 7 days of grace, then pauses cloud usage (local
|
|
17
|
+
// and CI runs never stop); 14 days after that the workspace moves to Free.
|
|
18
|
+
// - Nothing is deleted on a downgrade: projects over the new limit become
|
|
19
|
+
// read-only until the workspace upgrades again.
|
|
20
|
+
import { PLANS, entitlementsFor, formatUsd, type BillingInterval, type Entitlements, type PlanId, type UsageKind } from "../../billing/plan-catalog.js";
|
|
21
|
+
import type { Env, Principal } from "../core.js";
|
|
22
|
+
import { newId } from "../core.js";
|
|
23
|
+
import { emailSender, escapeHtml } from "../email/sender.js";
|
|
24
|
+
import { planChangedHooks } from "../hooks.js";
|
|
25
|
+
import { actorOf, recordAudit } from "../org/audit.js";
|
|
26
|
+
import { getWorkspacePlan, type WorkspacePlan } from "./entitlements.js";
|
|
27
|
+
import { periodAnchor, summarize, usagePeriod, usedInPeriod } from "./ledger.js";
|
|
28
|
+
import {
|
|
29
|
+
BillingUnavailableError,
|
|
30
|
+
billingProvider,
|
|
31
|
+
defaultBillingProvider,
|
|
32
|
+
proRataCents,
|
|
33
|
+
type BillingProvider,
|
|
34
|
+
type PaidPlan,
|
|
35
|
+
type ProviderPayment,
|
|
36
|
+
type ProviderSubscription,
|
|
37
|
+
type UsageChargeLine,
|
|
38
|
+
} from "./provider.js";
|
|
39
|
+
import { billingRecipients } from "./thresholds.js";
|
|
40
|
+
import { PayPalBusinessError } from "./paypal/client.js";
|
|
41
|
+
import "./paypal/provider.js";
|
|
42
|
+
|
|
43
|
+
const DAY = 86_400_000;
|
|
44
|
+
export const GRACE_DAYS = 7;
|
|
45
|
+
/** After grace runs out, how long an unpaid workspace keeps its plan before moving to Free. */
|
|
46
|
+
export const LAPSE_DAYS = 14;
|
|
47
|
+
const PAYMENT_LOOKBACK_MS = 40 * DAY;
|
|
48
|
+
|
|
49
|
+
/** Reasons we give the provider when we end a subscription ourselves; a webhook echoing one is not the customer leaving. */
|
|
50
|
+
export const CANCEL_REASON = "Cancelled on Bugmole";
|
|
51
|
+
export const UPGRADE_REASON = "Replaced by an upgrade on Bugmole";
|
|
52
|
+
export const SCHEDULED_REASON = "Replaced by a plan change at period end";
|
|
53
|
+
const OUR_REASONS = new Set([CANCEL_REASON, UPGRADE_REASON, SCHEDULED_REASON]);
|
|
54
|
+
|
|
55
|
+
export class BillingError extends Error {
|
|
56
|
+
constructor(readonly status: number, readonly code: string, message: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Plans and prices ------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const TIER: Record<PaidPlan, number> = { team: 1, business: 2 };
|
|
64
|
+
|
|
65
|
+
export function isPaidPlan(value: unknown): value is PaidPlan {
|
|
66
|
+
return value === "team" || value === "business";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What one period of a plan costs, in cents. */
|
|
70
|
+
export function periodPriceCents(plan: PaidPlan, interval: BillingInterval): number {
|
|
71
|
+
const def = PLANS[plan];
|
|
72
|
+
return interval === "year" ? (def.priceYearlyMonthlyCents ?? 0) * 12 : def.priceMonthlyCents ?? 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** "upgrade" starts now; "downgrade" starts when the current period ends. */
|
|
76
|
+
export function changeKind(from: WorkspacePlan, plan: PaidPlan, interval: BillingInterval): "new" | "same" | "upgrade" | "downgrade" {
|
|
77
|
+
if (!isPaidPlan(from.plan) || !from.interval) return "new";
|
|
78
|
+
if (from.plan === plan && from.interval === interval) return "same";
|
|
79
|
+
if (TIER[plan] !== TIER[from.plan]) return TIER[plan] > TIER[from.plan] ? "upgrade" : "downgrade";
|
|
80
|
+
return interval === "year" ? "upgrade" : "downgrade";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Stored state ----------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export type StoredPayment = ProviderPayment & { periodStart: string; periodEnd: string };
|
|
86
|
+
|
|
87
|
+
type CheckoutRow = {
|
|
88
|
+
id: string;
|
|
89
|
+
workspace_id: string;
|
|
90
|
+
plan: PaidPlan;
|
|
91
|
+
billing_interval: BillingInterval;
|
|
92
|
+
provider: string;
|
|
93
|
+
provider_subscription_id: string | null;
|
|
94
|
+
status: "pending" | "scheduled" | "completed" | "abandoned";
|
|
95
|
+
replaces_subscription_id: string | null;
|
|
96
|
+
replaced_payment_json: string | null;
|
|
97
|
+
replaced_cancelled_at: string | null;
|
|
98
|
+
refund_json: string | null;
|
|
99
|
+
settled_at: string | null;
|
|
100
|
+
created_by: string;
|
|
101
|
+
created_at: string;
|
|
102
|
+
completed_at: string | null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type PlanRow = {
|
|
106
|
+
workspace_id: string;
|
|
107
|
+
plan: string;
|
|
108
|
+
billing_interval: string | null;
|
|
109
|
+
status: string;
|
|
110
|
+
current_period_start: string | null;
|
|
111
|
+
current_period_end: string | null;
|
|
112
|
+
provider: string | null;
|
|
113
|
+
provider_subscription_id: string | null;
|
|
114
|
+
pending_plan: string | null;
|
|
115
|
+
grace_until: string | null;
|
|
116
|
+
last_payment_json: string | null;
|
|
117
|
+
cancel_at_period_end: number;
|
|
118
|
+
billing_email: string | null;
|
|
119
|
+
billing_name: string | null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function parse<T>(value: string | null): T | null {
|
|
123
|
+
if (!value) return null;
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(value) as T;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function planRow(env: Env, workspaceId: string): Promise<PlanRow | null> {
|
|
132
|
+
return env.DB.prepare("SELECT * FROM workspace_plans WHERE workspace_id = ?").bind(workspaceId).first<PlanRow>();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function checkoutById(env: Env, id: string): Promise<CheckoutRow | null> {
|
|
136
|
+
return env.DB.prepare("SELECT * FROM billing_checkouts WHERE id = ?").bind(id).first<CheckoutRow>();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sqlIso(value: string): string {
|
|
140
|
+
return /[zZ]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(" ", "T")}Z`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function notifyPlanChange(env: Env, workspaceId: string, from: string, to: string): Promise<void> {
|
|
144
|
+
if (from === to) return;
|
|
145
|
+
for (const hook of planChangedHooks) {
|
|
146
|
+
try {
|
|
147
|
+
await hook(env, { workspaceId, from, to });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error("[billing] plan change hook failed", error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function sendBillingEmail(env: Env, workspaceId: string, subject: string, lines: string[], tag: string): Promise<void> {
|
|
155
|
+
const row = await planRow(env, workspaceId);
|
|
156
|
+
const recipients = await billingRecipients(env, workspaceId);
|
|
157
|
+
if (row?.billing_email && !recipients.some((r) => r.email === row.billing_email)) recipients.push({ email: row.billing_email });
|
|
158
|
+
if (!recipients.length) return;
|
|
159
|
+
const link = `${(env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "")}/billing`;
|
|
160
|
+
try {
|
|
161
|
+
await emailSender(env).send({
|
|
162
|
+
to: recipients,
|
|
163
|
+
subject,
|
|
164
|
+
text: `${lines.join("\n\n")}\n\nBilling: ${link}`,
|
|
165
|
+
html: `${lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")}<p><a href="${escapeHtml(link)}">Open billing</a></p>`,
|
|
166
|
+
tag,
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error(`[billing] couldn't email ${workspaceId}: ${subject}`, error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Projects over the limit -----------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/** Locks the newest projects beyond the plan's limit and unlocks what fits again. */
|
|
176
|
+
export async function applyProjectLimit(env: Env, workspaceId: string, entitlements: Entitlements): Promise<{ locked: number }> {
|
|
177
|
+
const rows = await env.DB.prepare(`
|
|
178
|
+
SELECT id, locked_at AS lockedAt FROM projects
|
|
179
|
+
WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at ASC, rowid ASC
|
|
180
|
+
`).bind(workspaceId).all<{ id: string; lockedAt: string | null }>();
|
|
181
|
+
const limit = entitlements.projects ?? Number.POSITIVE_INFINITY;
|
|
182
|
+
const now = new Date().toISOString();
|
|
183
|
+
let locked = 0;
|
|
184
|
+
for (const [index, project] of rows.results.entries()) {
|
|
185
|
+
const shouldLock = index >= limit;
|
|
186
|
+
if (shouldLock) locked += 1;
|
|
187
|
+
if (shouldLock && !project.lockedAt) {
|
|
188
|
+
await env.DB.prepare("UPDATE projects SET locked_at = ? WHERE id = ?").bind(now, project.id).run();
|
|
189
|
+
} else if (!shouldLock && project.lockedAt) {
|
|
190
|
+
await env.DB.prepare("UPDATE projects SET locked_at = NULL WHERE id = ?").bind(project.id).run();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { locked };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Invoices --------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
async function nextInvoiceNumber(env: Env, workspaceId: string, at: Date): Promise<string> {
|
|
199
|
+
const prefix = `VQ-${at.getUTCFullYear()}${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
200
|
+
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM invoices WHERE number LIKE ?").bind(`${prefix}-%`).first<{ count: number }>();
|
|
201
|
+
// The random tail keeps two workers numbering at once from colliding.
|
|
202
|
+
return `${prefix}-${String(Number(row?.count ?? 0) + 1).padStart(4, "0")}-${newId("n").slice(-4).toUpperCase()}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Records a plan payment the provider already took. Returns false when it was already recorded. */
|
|
206
|
+
async function recordSubscriptionPayment(env: Env, workspaceId: string, provider: string, plan: PaidPlan, interval: BillingInterval, payment: StoredPayment): Promise<boolean> {
|
|
207
|
+
const at = new Date(payment.paidAt);
|
|
208
|
+
const lines = [{ name: `${PLANS[plan].name} plan (${interval === "year" ? "yearly" : "monthly"})`, quantity: 1, unitCents: payment.amountCents }];
|
|
209
|
+
const result = await env.DB.prepare(`
|
|
210
|
+
INSERT INTO invoices (id, workspace_id, number, kind, status, plan, period_start, period_end, currency, total_cents, lines_json, provider, provider_ref, paid_at)
|
|
211
|
+
VALUES (?, ?, ?, 'subscription', 'paid', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
212
|
+
ON CONFLICT(provider, provider_ref) DO NOTHING
|
|
213
|
+
`).bind(
|
|
214
|
+
newId("inv"), workspaceId, await nextInvoiceNumber(env, workspaceId, at), plan,
|
|
215
|
+
payment.periodStart, payment.periodEnd, payment.currency, payment.amountCents, JSON.stringify(lines),
|
|
216
|
+
provider, payment.reference, payment.paidAt,
|
|
217
|
+
).run();
|
|
218
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const USAGE_NAMES: Record<UsageKind, string> = {
|
|
222
|
+
browser_minute: "Cloud minutes beyond the allowance",
|
|
223
|
+
device_minute: "Real device minutes",
|
|
224
|
+
ai_credit: "AI credits beyond the allowance",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export function usageLines(summary: ReturnType<typeof summarize>): UsageChargeLine[] {
|
|
228
|
+
return (Object.values(summary.lines))
|
|
229
|
+
.filter((line) => line.overageCents > 0 && line.unitCents !== null)
|
|
230
|
+
.map((line) => ({ name: USAGE_NAMES[line.kind], quantity: line.overageQuantity, unitCents: line.unitCents! }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Bills one closed usage period. Safe to repeat: a period is billed once
|
|
235
|
+
* (unique per workspace and period), and a charge the provider couldn't take
|
|
236
|
+
* yet is retried on the next run.
|
|
237
|
+
*/
|
|
238
|
+
export async function billUsagePeriod(
|
|
239
|
+
env: Env,
|
|
240
|
+
workspaceId: string,
|
|
241
|
+
period: { start: string; end: string },
|
|
242
|
+
entitlements: Entitlements,
|
|
243
|
+
spendingLimitCents: number | null,
|
|
244
|
+
now = new Date(),
|
|
245
|
+
): Promise<{ invoiceId: string; totalCents: number } | null> {
|
|
246
|
+
const existing = await env.DB.prepare("SELECT id, status, provider_ref AS ref, total_cents AS total FROM invoices WHERE workspace_id = ? AND kind = 'usage' AND period_start = ?")
|
|
247
|
+
.bind(workspaceId, period.start).first<{ id: string; status: string; ref: string | null; total: number }>();
|
|
248
|
+
if (existing && (existing.ref || existing.status === "none")) return existing.status === "none" ? null : { invoiceId: existing.id, totalCents: existing.total };
|
|
249
|
+
|
|
250
|
+
const summary = summarize(entitlements, await usedInPeriod(env, workspaceId, period.start), period, spendingLimitCents);
|
|
251
|
+
const lines = usageLines(summary);
|
|
252
|
+
const total = lines.reduce((sum, line) => sum + line.quantity * line.unitCents, 0);
|
|
253
|
+
let invoiceId = existing?.id;
|
|
254
|
+
if (!invoiceId) {
|
|
255
|
+
invoiceId = newId("inv");
|
|
256
|
+
const inserted = await env.DB.prepare(`
|
|
257
|
+
INSERT INTO invoices (id, workspace_id, number, kind, status, period_start, period_end, currency, total_cents, lines_json)
|
|
258
|
+
VALUES (?, ?, ?, 'usage', ?, ?, ?, 'USD', ?, ?)
|
|
259
|
+
ON CONFLICT(workspace_id, kind, period_start) DO NOTHING
|
|
260
|
+
`).bind(invoiceId, workspaceId, await nextInvoiceNumber(env, workspaceId, now), total > 0 ? "open" : "none", period.start, period.end, total, JSON.stringify(lines)).run();
|
|
261
|
+
if ((inserted.meta?.changes ?? 0) === 0) return null; // another run got here first
|
|
262
|
+
}
|
|
263
|
+
if (total <= 0) return null;
|
|
264
|
+
|
|
265
|
+
const row = await planRow(env, workspaceId);
|
|
266
|
+
const provider = billingProvider(row?.provider || defaultBillingProvider(env).id);
|
|
267
|
+
const email = row?.billing_email ?? (await billingRecipients(env, workspaceId))[0]?.email;
|
|
268
|
+
if (!email) {
|
|
269
|
+
console.error(`[billing] usage invoice ${invoiceId} for ${workspaceId} has nobody to send it to`);
|
|
270
|
+
return { invoiceId, totalCents: total };
|
|
271
|
+
}
|
|
272
|
+
const number = (await env.DB.prepare("SELECT number FROM invoices WHERE id = ?").bind(invoiceId).first<{ number: string }>())!.number;
|
|
273
|
+
try {
|
|
274
|
+
const charge = await provider.chargeUsage(env, {
|
|
275
|
+
invoiceNumber: number,
|
|
276
|
+
email,
|
|
277
|
+
name: row?.billing_name ?? undefined,
|
|
278
|
+
note: `Usage beyond your plan's allowance, ${period.start.slice(0, 10)} to ${period.end.slice(0, 10)}.`,
|
|
279
|
+
lines,
|
|
280
|
+
requestId: `usage-${invoiceId}`,
|
|
281
|
+
});
|
|
282
|
+
await env.DB.prepare("UPDATE invoices SET provider = ?, provider_ref = ?, provider_url = ?, due_at = ? WHERE id = ?")
|
|
283
|
+
.bind(provider.id, charge.reference, charge.url, new Date(now.getTime() + 15 * DAY).toISOString(), invoiceId).run();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.error(`[billing] couldn't send usage invoice ${number} for ${workspaceId}; the daily run retries`, error);
|
|
286
|
+
}
|
|
287
|
+
return { invoiceId, totalCents: total };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- Checkout --------------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function dashboardUrl(env: Env): string {
|
|
293
|
+
return (env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export type CheckoutStart = { checkoutId: string; approveUrl: string; effective: "now" | "period_end" };
|
|
297
|
+
|
|
298
|
+
export async function startCheckout(
|
|
299
|
+
env: Env,
|
|
300
|
+
principal: Principal,
|
|
301
|
+
workspaceId: string,
|
|
302
|
+
plan: PaidPlan,
|
|
303
|
+
interval: BillingInterval,
|
|
304
|
+
request?: Request,
|
|
305
|
+
): Promise<CheckoutStart> {
|
|
306
|
+
const provider = defaultBillingProvider(env);
|
|
307
|
+
if (!provider.canSell(env, plan, interval)) {
|
|
308
|
+
throw new BillingError(503, "billing_unavailable", "Online checkout isn't available right now. Contact us to upgrade.");
|
|
309
|
+
}
|
|
310
|
+
const current = await getWorkspacePlan(env, workspaceId);
|
|
311
|
+
const row = await planRow(env, workspaceId);
|
|
312
|
+
const kind = changeKind(current, plan, interval);
|
|
313
|
+
if (kind === "same" && !row?.cancel_at_period_end) {
|
|
314
|
+
throw new BillingError(409, "already_subscribed", `This workspace is already on ${PLANS[plan].name}.`);
|
|
315
|
+
}
|
|
316
|
+
const replacing = kind === "upgrade" || kind === "downgrade" || kind === "same" ? row?.provider_subscription_id ?? null : null;
|
|
317
|
+
const scheduled = kind === "downgrade" || kind === "same";
|
|
318
|
+
const startAt = scheduled && current.periodEnd ? new Date(Math.max(Date.parse(current.periodEnd), Date.now() + DAY)).toISOString() : undefined;
|
|
319
|
+
const checkoutId = newId("chk");
|
|
320
|
+
const email = principal.email ?? row?.billing_email ?? undefined;
|
|
321
|
+
const created = await provider.createSubscription(env, {
|
|
322
|
+
reference: checkoutId,
|
|
323
|
+
plan,
|
|
324
|
+
interval,
|
|
325
|
+
email,
|
|
326
|
+
returnUrl: `${dashboardUrl(env)}/billing/return?${new URLSearchParams({ workspace: workspaceId, checkout: checkoutId })}`,
|
|
327
|
+
cancelUrl: `${dashboardUrl(env)}/billing?${new URLSearchParams({ workspace: workspaceId, checkout: "cancelled" })}`,
|
|
328
|
+
requestId: checkoutId,
|
|
329
|
+
...(startAt ? { startAt } : {}),
|
|
330
|
+
});
|
|
331
|
+
await env.DB.prepare(`
|
|
332
|
+
INSERT INTO billing_checkouts (id, workspace_id, plan, billing_interval, provider, provider_subscription_id, replaces_subscription_id, replaced_payment_json, created_by)
|
|
333
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
334
|
+
`).bind(
|
|
335
|
+
checkoutId, workspaceId, plan, interval, provider.id, created.subscriptionId,
|
|
336
|
+
replacing, replacing ? row?.last_payment_json ?? null : null, principal.aegisId,
|
|
337
|
+
).run();
|
|
338
|
+
await recordAudit(env, actorOf(principal), {
|
|
339
|
+
workspaceId, action: "billing.checkout_started", targetType: "checkout", targetId: checkoutId, data: { plan, interval, kind },
|
|
340
|
+
}, request);
|
|
341
|
+
return { checkoutId, approveUrl: created.approveUrl, effective: scheduled ? "period_end" : "now" };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Everything that decides the outcome is read from the provider; the browser only names the checkout. */
|
|
345
|
+
export async function completeCheckout(env: Env, workspaceId: string, checkoutId: string, now = new Date()): Promise<{ status: "active" | "scheduled"; plan: WorkspacePlan }> {
|
|
346
|
+
const checkout = await checkoutById(env, checkoutId);
|
|
347
|
+
if (!checkout || checkout.workspace_id !== workspaceId || !checkout.provider_subscription_id) {
|
|
348
|
+
throw new BillingError(404, "checkout_not_found", "That checkout wasn't found.");
|
|
349
|
+
}
|
|
350
|
+
if (checkout.status === "completed") return { status: "active", plan: await getWorkspacePlan(env, workspaceId) };
|
|
351
|
+
if (checkout.status === "scheduled") return { status: "scheduled", plan: await getWorkspacePlan(env, workspaceId) };
|
|
352
|
+
const provider = billingProvider(checkout.provider);
|
|
353
|
+
const remote = await provider.getSubscription(env, checkout.provider_subscription_id);
|
|
354
|
+
if (!remote || remote.reference !== checkout.id) throw new BillingError(402, "payment_not_completed", "The payment wasn't completed.");
|
|
355
|
+
if (remote.status !== "active") throw new BillingError(402, "payment_not_completed", "The payment wasn't completed.");
|
|
356
|
+
if (!remote.plan || remote.plan.plan !== checkout.plan || remote.plan.interval !== checkout.billing_interval) {
|
|
357
|
+
console.error(`[billing] checkout ${checkout.id} subscription ${remote.id} is on an unexpected plan`, remote.plan);
|
|
358
|
+
throw new BillingError(402, "payment_not_completed", "The payment wasn't completed.");
|
|
359
|
+
}
|
|
360
|
+
const startsLater = remote.startedAt !== null && Date.parse(remote.startedAt) > now.getTime() + 60_000;
|
|
361
|
+
if (startsLater) return { status: "scheduled", plan: await scheduleChange(env, checkout, remote, now) };
|
|
362
|
+
await activateSubscription(env, checkout, remote, provider, now);
|
|
363
|
+
return { status: "active", plan: await getWorkspacePlan(env, workspaceId) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A downgrade approved now, billed from the end of the current period. */
|
|
367
|
+
async function scheduleChange(env: Env, checkout: CheckoutRow, remote: ProviderSubscription, now: Date): Promise<WorkspacePlan> {
|
|
368
|
+
const provider = billingProvider(checkout.provider);
|
|
369
|
+
if (checkout.replaces_subscription_id && !checkout.replaced_cancelled_at) {
|
|
370
|
+
// The current subscription is paid up to the period end; stop it renewing, since the new one takes over then.
|
|
371
|
+
await provider.cancelSubscription(env, checkout.replaces_subscription_id, SCHEDULED_REASON);
|
|
372
|
+
await env.DB.prepare("UPDATE billing_checkouts SET replaced_cancelled_at = ? WHERE id = ?").bind(now.toISOString(), checkout.id).run();
|
|
373
|
+
}
|
|
374
|
+
// Any earlier scheduled change is replaced by this one.
|
|
375
|
+
const earlier = await env.DB.prepare("SELECT id, provider, provider_subscription_id AS sub FROM billing_checkouts WHERE workspace_id = ? AND status = 'scheduled' AND id != ?")
|
|
376
|
+
.bind(checkout.workspace_id, checkout.id).all<{ id: string; provider: string; sub: string | null }>();
|
|
377
|
+
for (const old of earlier.results) {
|
|
378
|
+
if (old.sub) await billingProvider(old.provider).cancelSubscription(env, old.sub, SCHEDULED_REASON).catch(() => undefined);
|
|
379
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'abandoned' WHERE id = ?").bind(old.id).run();
|
|
380
|
+
}
|
|
381
|
+
await env.DB.batch([
|
|
382
|
+
env.DB.prepare("UPDATE billing_checkouts SET status = 'scheduled', completed_at = ? WHERE id = ?").bind(now.toISOString(), checkout.id),
|
|
383
|
+
env.DB.prepare("UPDATE workspace_plans SET pending_plan = ?, cancel_at_period_end = 1, updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
384
|
+
.bind(checkout.plan, checkout.workspace_id),
|
|
385
|
+
]);
|
|
386
|
+
await sendBillingEmail(env, checkout.workspace_id, `Your plan changes to ${PLANS[checkout.plan].name} at the end of this period`, [
|
|
387
|
+
`Your workspace moves to ${PLANS[checkout.plan].name} (${checkout.billing_interval === "year" ? "yearly" : "monthly"}) on ${remote.startedAt?.slice(0, 10)}.`,
|
|
388
|
+
"Until then, everything in your current plan keeps working.",
|
|
389
|
+
], "billing-scheduled");
|
|
390
|
+
return getWorkspacePlan(env, checkout.workspace_id);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Starts the plan a paid subscription is for: records the payment, sets the period, settles what it replaced. */
|
|
394
|
+
async function activateSubscription(env: Env, checkout: CheckoutRow, remote: ProviderSubscription, provider: BillingProvider, now: Date): Promise<void> {
|
|
395
|
+
const payment = await provider.latestPayment(env, remote.id, new Date(Math.min(Date.parse(sqlIso(checkout.created_at)), now.getTime()) - PAYMENT_LOOKBACK_MS), now);
|
|
396
|
+
if (!payment || !remote.paidThrough) {
|
|
397
|
+
// The provider posts the first payment a few seconds after activation; the webhook finishes the job.
|
|
398
|
+
throw new BillingError(402, "payment_pending", "Your payment is still processing. This page updates when it's done.");
|
|
399
|
+
}
|
|
400
|
+
const expected = periodPriceCents(checkout.plan, checkout.billing_interval);
|
|
401
|
+
if (payment.currency !== "USD" || payment.amountCents < expected) {
|
|
402
|
+
console.error(`[billing] payment ${payment.reference} on ${remote.id} is ${payment.amountCents} ${payment.currency}, expected ${expected} USD`);
|
|
403
|
+
throw new BillingError(402, "unexpected_amount", "The payment amount didn't match the plan. Contact support.");
|
|
404
|
+
}
|
|
405
|
+
const before = await getWorkspacePlan(env, checkout.workspace_id);
|
|
406
|
+
const periodStart = payment.paidAt;
|
|
407
|
+
const stored: StoredPayment = { ...payment, periodStart, periodEnd: remote.paidThrough };
|
|
408
|
+
const anchor = remote.startedAt && Date.parse(remote.startedAt) <= now.getTime() ? remote.startedAt : periodStart;
|
|
409
|
+
|
|
410
|
+
// Bill the old plan's usage so far before the period anchor moves.
|
|
411
|
+
if (isPaidPlan(before.plan) && before.status !== "canceled") {
|
|
412
|
+
const oldPeriod = usagePeriod(periodAnchor(before), now);
|
|
413
|
+
await billUsagePeriod(env, checkout.workspace_id, { start: oldPeriod.start, end: now.toISOString() }, entitlementsFor(before.plan, before.overrides), before.spendingLimitCents, now);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await env.DB.prepare(`
|
|
417
|
+
INSERT INTO workspace_plans (workspace_id, plan, billing_interval, status, current_period_start, current_period_end, provider, provider_subscription_id, last_payment_json, pending_plan, grace_until, cancel_at_period_end, updated_at)
|
|
418
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, NULL, NULL, 0, CURRENT_TIMESTAMP)
|
|
419
|
+
ON CONFLICT(workspace_id) DO UPDATE SET plan = excluded.plan, billing_interval = excluded.billing_interval, status = 'active',
|
|
420
|
+
current_period_start = excluded.current_period_start, current_period_end = excluded.current_period_end,
|
|
421
|
+
provider = excluded.provider, provider_subscription_id = excluded.provider_subscription_id,
|
|
422
|
+
last_payment_json = excluded.last_payment_json, pending_plan = NULL, grace_until = NULL, cancel_at_period_end = 0,
|
|
423
|
+
updated_at = CURRENT_TIMESTAMP
|
|
424
|
+
`).bind(checkout.workspace_id, checkout.plan, checkout.billing_interval, anchor, remote.paidThrough, provider.id, remote.id, JSON.stringify(stored)).run();
|
|
425
|
+
const recorded = await recordSubscriptionPayment(env, checkout.workspace_id, provider.id, checkout.plan, checkout.billing_interval, stored);
|
|
426
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'completed', completed_at = COALESCE(completed_at, ?) WHERE id = ?").bind(now.toISOString(), checkout.id).run();
|
|
427
|
+
await applyProjectLimit(env, checkout.workspace_id, entitlementsFor(checkout.plan));
|
|
428
|
+
await recordAudit(env, { type: "system", id: `billing:${provider.id}` }, {
|
|
429
|
+
workspaceId: checkout.workspace_id, action: "billing.plan_changed", targetType: "workspace", targetId: checkout.workspace_id,
|
|
430
|
+
data: { from: before.plan, to: checkout.plan, interval: checkout.billing_interval, payment: payment.reference },
|
|
431
|
+
});
|
|
432
|
+
await notifyPlanChange(env, checkout.workspace_id, before.plan, checkout.plan);
|
|
433
|
+
if (checkout.replaces_subscription_id) await settleReplaced(env, checkout.id, now);
|
|
434
|
+
if (recorded) {
|
|
435
|
+
await sendBillingEmail(env, checkout.workspace_id, `Receipt: ${PLANS[checkout.plan].name} plan, ${formatUsd(payment.amountCents)}`, [
|
|
436
|
+
`Thanks — your workspace is on ${PLANS[checkout.plan].name} (${checkout.billing_interval === "year" ? "yearly" : "monthly"}).`,
|
|
437
|
+
`Paid ${formatUsd(payment.amountCents)} for ${periodStart.slice(0, 10)} to ${remote.paidThrough.slice(0, 10)}.`,
|
|
438
|
+
], "billing-receipt");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export type Refund = { amountCents: number; status: "none" | "refunded" | "failed"; reference?: string };
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Settles the subscription an upgrade replaced: cancel it at the provider,
|
|
446
|
+
* then refund its unused part as of the upgrade. Each step is recorded when
|
|
447
|
+
* it completes; a provider outage leaves the rest for the daily retry, and a
|
|
448
|
+
* refused refund is final and logged for a person to handle.
|
|
449
|
+
*/
|
|
450
|
+
export async function settleReplaced(env: Env, checkoutId: string, now = new Date()): Promise<void> {
|
|
451
|
+
const checkout = await checkoutById(env, checkoutId);
|
|
452
|
+
if (!checkout || checkout.status !== "completed" || !checkout.replaces_subscription_id || checkout.settled_at) return;
|
|
453
|
+
const provider = billingProvider(checkout.provider);
|
|
454
|
+
if (!checkout.replaced_cancelled_at) {
|
|
455
|
+
try {
|
|
456
|
+
await provider.cancelSubscription(env, checkout.replaces_subscription_id, UPGRADE_REASON);
|
|
457
|
+
await env.DB.prepare("UPDATE billing_checkouts SET replaced_cancelled_at = ? WHERE id = ?").bind(now.toISOString(), checkout.id).run();
|
|
458
|
+
} catch (error) {
|
|
459
|
+
console.error(`[billing] couldn't cancel replaced subscription ${checkout.replaces_subscription_id}; the daily run retries`, error);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const replaced = parse<StoredPayment>(checkout.replaced_payment_json);
|
|
464
|
+
const upgradedAt = new Date(sqlIso(checkout.completed_at ?? now.toISOString()));
|
|
465
|
+
const due = replaced ? proRataCents(replaced.amountCents, replaced.periodStart, replaced.periodEnd, upgradedAt) : 0;
|
|
466
|
+
let refund: Refund = { amountCents: due, status: "none" };
|
|
467
|
+
if (replaced && due >= 1) {
|
|
468
|
+
try {
|
|
469
|
+
const result = await provider.refund(env, replaced, due, `upgrade-refund-${checkout.id}`, "Unused part of the previous Bugmole plan");
|
|
470
|
+
refund = { amountCents: due, status: "refunded", reference: result.reference };
|
|
471
|
+
await env.DB.prepare("UPDATE invoices SET refunded_cents = refunded_cents + ? WHERE provider = ? AND provider_ref = ?")
|
|
472
|
+
.bind(due, provider.id, replaced.reference).run();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (!(error instanceof PayPalBusinessError)) {
|
|
475
|
+
console.error(`[billing] refund for upgrade ${checkout.id} unavailable; the daily run retries`, error);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
refund = { amountCents: due, status: "failed" };
|
|
479
|
+
console.error(`[billing] refund refused for upgrade ${checkout.id}: ${due} cents on ${replaced.reference} — refund manually`, error);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const settled = await env.DB.prepare("UPDATE billing_checkouts SET refund_json = ?, settled_at = ? WHERE id = ? AND settled_at IS NULL")
|
|
483
|
+
.bind(JSON.stringify(refund), now.toISOString(), checkout.id).run();
|
|
484
|
+
if ((settled.meta?.changes ?? 0) > 0 && refund.status === "refunded") {
|
|
485
|
+
await sendBillingEmail(env, checkout.workspace_id, `Refund: ${formatUsd(refund.amountCents)} for your previous plan`, [
|
|
486
|
+
`We refunded ${formatUsd(refund.amountCents)}, the unused part of your previous plan, to your PayPal account.`,
|
|
487
|
+
], "billing-refund");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// --- Cancellation ----------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
/** Stops renewal now; the plan keeps working until the paid period ends. */
|
|
494
|
+
export async function cancelAtPeriodEnd(env: Env, principal: Principal, workspaceId: string, request?: Request): Promise<WorkspacePlan> {
|
|
495
|
+
const row = await planRow(env, workspaceId);
|
|
496
|
+
if (!row || !isPaidPlan(row.plan) || row.status === "canceled") throw new BillingError(409, "not_subscribed", "This workspace isn't on a paid plan.");
|
|
497
|
+
if (row.provider && row.provider_subscription_id) {
|
|
498
|
+
await billingProvider(row.provider).cancelSubscription(env, row.provider_subscription_id, CANCEL_REASON);
|
|
499
|
+
}
|
|
500
|
+
const scheduled = await env.DB.prepare("SELECT id, provider, provider_subscription_id AS sub FROM billing_checkouts WHERE workspace_id = ? AND status = 'scheduled'")
|
|
501
|
+
.bind(workspaceId).all<{ id: string; provider: string; sub: string | null }>();
|
|
502
|
+
for (const change of scheduled.results) {
|
|
503
|
+
if (change.sub) await billingProvider(change.provider).cancelSubscription(env, change.sub, CANCEL_REASON).catch(() => undefined);
|
|
504
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'abandoned' WHERE id = ?").bind(change.id).run();
|
|
505
|
+
}
|
|
506
|
+
await env.DB.prepare("UPDATE workspace_plans SET cancel_at_period_end = 1, pending_plan = 'free', updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
507
|
+
.bind(workspaceId).run();
|
|
508
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "billing.cancelled", targetType: "workspace", targetId: workspaceId }, request);
|
|
509
|
+
await sendBillingEmail(env, workspaceId, "Your Bugmole plan won't renew", [
|
|
510
|
+
`Your ${PLANS[row.plan as PaidPlan].name} plan stays active until ${row.current_period_end?.slice(0, 10) ?? "the end of the period"}, then the workspace moves to Free.`,
|
|
511
|
+
"Nothing is deleted. Projects beyond Free's limit become read-only until you upgrade again.",
|
|
512
|
+
], "billing-cancelled");
|
|
513
|
+
return getWorkspacePlan(env, workspaceId);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// --- Keeping in step with the provider -------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
async function moveToFree(env: Env, workspaceId: string, reason: string): Promise<void> {
|
|
519
|
+
const before = await getWorkspacePlan(env, workspaceId);
|
|
520
|
+
await env.DB.prepare(`
|
|
521
|
+
UPDATE workspace_plans SET status = 'canceled', pending_plan = NULL, cancel_at_period_end = 0,
|
|
522
|
+
provider_subscription_id = NULL, updated_at = CURRENT_TIMESTAMP
|
|
523
|
+
WHERE workspace_id = ?
|
|
524
|
+
`).bind(workspaceId).run();
|
|
525
|
+
const { locked } = await applyProjectLimit(env, workspaceId, entitlementsFor("free"));
|
|
526
|
+
await recordAudit(env, { type: "system", id: "billing" }, {
|
|
527
|
+
workspaceId, action: "billing.moved_to_free", targetType: "workspace", targetId: workspaceId, data: { reason, lockedProjects: locked },
|
|
528
|
+
});
|
|
529
|
+
await notifyPlanChange(env, workspaceId, before.plan, "free");
|
|
530
|
+
await sendBillingEmail(env, workspaceId, "Your workspace is now on Free", [
|
|
531
|
+
reason === "unpaid"
|
|
532
|
+
? "We couldn't collect payment, so your workspace moved to the Free plan."
|
|
533
|
+
: "Your paid plan has ended and your workspace is on the Free plan.",
|
|
534
|
+
locked ? `${locked} project${locked === 1 ? " is" : "s are"} read-only until you upgrade. Nothing was deleted.` : "Nothing was deleted.",
|
|
535
|
+
], "billing-free");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Brings one workspace in line with its provider and the calendar. Called by
|
|
540
|
+
* webhooks and by the daily job. Safe to repeat.
|
|
541
|
+
*/
|
|
542
|
+
export async function syncWorkspaceBilling(env: Env, workspaceId: string, now = new Date()): Promise<void> {
|
|
543
|
+
const row = await planRow(env, workspaceId);
|
|
544
|
+
if (!row || !isPaidPlan(row.plan) || row.status === "canceled") return;
|
|
545
|
+
const provider = row.provider ? billingProvider(row.provider) : null;
|
|
546
|
+
|
|
547
|
+
if (provider && row.provider_subscription_id) {
|
|
548
|
+
const remote = await provider.getSubscription(env, row.provider_subscription_id);
|
|
549
|
+
const checkout = remote?.reference ? await checkoutById(env, remote.reference) : null;
|
|
550
|
+
if (remote && checkout?.workspace_id === workspaceId) {
|
|
551
|
+
if (remote.status === "active") await applyRenewal(env, row, remote, provider, now);
|
|
552
|
+
if (remote.status === "past_due" && row.status === "active") {
|
|
553
|
+
await env.DB.prepare("UPDATE workspace_plans SET status = 'past_due', grace_until = ?, updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
554
|
+
.bind(new Date(now.getTime() + GRACE_DAYS * DAY).toISOString(), workspaceId).run();
|
|
555
|
+
await sendBillingEmail(env, workspaceId, "Your Bugmole payment didn't go through", [
|
|
556
|
+
`PayPal couldn't collect your renewal. Update your payment method in PayPal within ${GRACE_DAYS} days to keep using Bugmole Cloud.`,
|
|
557
|
+
"Runs on your own machines and CI keep working either way.",
|
|
558
|
+
], "billing-payment-failed");
|
|
559
|
+
}
|
|
560
|
+
if (remote.status === "ended" && !(remote.endNote && OUR_REASONS.has(remote.endNote)) && !row.cancel_at_period_end) {
|
|
561
|
+
// Cancelled inside PayPal: honor it at the end of the paid period.
|
|
562
|
+
await env.DB.prepare("UPDATE workspace_plans SET cancel_at_period_end = 1, pending_plan = 'free', updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
563
|
+
.bind(workspaceId).run();
|
|
564
|
+
row.cancel_at_period_end = 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const current = await planRow(env, workspaceId);
|
|
570
|
+
if (!current) return;
|
|
571
|
+
const ended = current.current_period_end !== null && Date.parse(current.current_period_end) <= now.getTime();
|
|
572
|
+
if (ended && current.cancel_at_period_end) {
|
|
573
|
+
const scheduled = await env.DB.prepare("SELECT * FROM billing_checkouts WHERE workspace_id = ? AND status = 'scheduled' ORDER BY completed_at DESC LIMIT 1")
|
|
574
|
+
.bind(workspaceId).first<CheckoutRow>();
|
|
575
|
+
if (scheduled?.provider_subscription_id) {
|
|
576
|
+
const next = await billingProvider(scheduled.provider).getSubscription(env, scheduled.provider_subscription_id);
|
|
577
|
+
if (next?.status === "active") {
|
|
578
|
+
try {
|
|
579
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'pending' WHERE id = ?").bind(scheduled.id).run();
|
|
580
|
+
await activateSubscription(env, { ...scheduled, status: "pending" }, next, billingProvider(scheduled.provider), now);
|
|
581
|
+
return;
|
|
582
|
+
} catch (error) {
|
|
583
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'scheduled' WHERE id = ?").bind(scheduled.id).run();
|
|
584
|
+
// The first payment of the new plan isn't in yet; give it a day before giving up on it.
|
|
585
|
+
if (Date.parse(current.current_period_end!) > now.getTime() - DAY) return;
|
|
586
|
+
console.error(`[billing] scheduled plan for ${workspaceId} never got paid`, error);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
await moveToFree(env, workspaceId, "ended");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (current.status === "past_due" && current.grace_until && Date.parse(current.grace_until) + LAPSE_DAYS * DAY <= now.getTime()) {
|
|
594
|
+
if (current.provider && current.provider_subscription_id) {
|
|
595
|
+
await billingProvider(current.provider).cancelSubscription(env, current.provider_subscription_id, CANCEL_REASON).catch(() => undefined);
|
|
596
|
+
}
|
|
597
|
+
await moveToFree(env, workspaceId, "unpaid");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** A renewal the provider already charged: record it and move the period on. */
|
|
602
|
+
async function applyRenewal(env: Env, row: PlanRow, remote: ProviderSubscription, provider: BillingProvider, now: Date): Promise<void> {
|
|
603
|
+
if (!remote.paidThrough || !isPaidPlan(row.plan) || !row.billing_interval) return;
|
|
604
|
+
const known = row.current_period_end ? Date.parse(row.current_period_end) : 0;
|
|
605
|
+
const recovered = row.status === "past_due";
|
|
606
|
+
if (Date.parse(remote.paidThrough) <= known && !recovered) return;
|
|
607
|
+
const payment = await provider.latestPayment(env, remote.id, new Date(now.getTime() - PAYMENT_LOOKBACK_MS), now);
|
|
608
|
+
const last = parse<StoredPayment>(row.last_payment_json);
|
|
609
|
+
if (!payment || payment.reference === last?.reference) {
|
|
610
|
+
// Only the date moved (or the payment isn't posted yet): keep the later end.
|
|
611
|
+
if (Date.parse(remote.paidThrough) > known) {
|
|
612
|
+
await env.DB.prepare("UPDATE workspace_plans SET current_period_end = ?, updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
613
|
+
.bind(remote.paidThrough, row.workspace_id).run();
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const stored: StoredPayment = { ...payment, periodStart: row.current_period_end ?? payment.paidAt, periodEnd: remote.paidThrough };
|
|
618
|
+
await env.DB.prepare(`
|
|
619
|
+
UPDATE workspace_plans SET current_period_end = ?, last_payment_json = ?, status = 'active', grace_until = NULL, updated_at = CURRENT_TIMESTAMP
|
|
620
|
+
WHERE workspace_id = ?
|
|
621
|
+
`).bind(remote.paidThrough, JSON.stringify(stored), row.workspace_id).run();
|
|
622
|
+
const recorded = await recordSubscriptionPayment(env, row.workspace_id, provider.id, row.plan, row.billing_interval as BillingInterval, stored);
|
|
623
|
+
if (recorded) {
|
|
624
|
+
await sendBillingEmail(env, row.workspace_id, `Receipt: ${PLANS[row.plan].name} plan, ${formatUsd(payment.amountCents)}`, [
|
|
625
|
+
`Your ${PLANS[row.plan].name} plan renewed: ${formatUsd(payment.amountCents)} for ${stored.periodStart.slice(0, 10)} to ${stored.periodEnd.slice(0, 10)}.`,
|
|
626
|
+
], "billing-receipt");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// --- Webhooks --------------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
export async function handleProviderWebhook(env: Env, providerId: string, request: Request): Promise<Response> {
|
|
633
|
+
let provider: BillingProvider;
|
|
634
|
+
try {
|
|
635
|
+
provider = billingProvider(providerId);
|
|
636
|
+
} catch {
|
|
637
|
+
return Response.json({ error: "Unknown provider" }, { status: 404 });
|
|
638
|
+
}
|
|
639
|
+
const rawBody = await request.text();
|
|
640
|
+
let event;
|
|
641
|
+
try {
|
|
642
|
+
event = await provider.verifyWebhook(env, request, rawBody);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
// The provider's verification API is down: let it retry the event.
|
|
645
|
+
console.error("[billing] webhook verification unavailable", error);
|
|
646
|
+
return Response.json({ error: "Verification unavailable" }, { status: 503 });
|
|
647
|
+
}
|
|
648
|
+
if (!event) return Response.json({ error: "Invalid webhook" }, { status: 400 });
|
|
649
|
+
|
|
650
|
+
const first = event.id
|
|
651
|
+
? await env.DB.prepare("INSERT INTO billing_events (provider, event_id, event_type) VALUES (?, ?, ?) ON CONFLICT DO NOTHING")
|
|
652
|
+
.bind(provider.id, event.id, event.type).run()
|
|
653
|
+
: null;
|
|
654
|
+
try {
|
|
655
|
+
if (event.subscriptionId) await onSubscriptionEvent(env, provider, event.subscriptionId);
|
|
656
|
+
if (event.usageChargeId) await refreshUsageCharge(env, provider, event.usageChargeId);
|
|
657
|
+
return Response.json({ ok: true, duplicate: first !== null && (first.meta?.changes ?? 0) === 0 });
|
|
658
|
+
} catch (error) {
|
|
659
|
+
// Forget the event so the provider's retry is processed.
|
|
660
|
+
if (event.id) await env.DB.prepare("DELETE FROM billing_events WHERE provider = ? AND event_id = ?").bind(provider.id, event.id).run();
|
|
661
|
+
console.error(`[billing] webhook ${event.type} failed`, error);
|
|
662
|
+
return Response.json({ error: "Processing failed" }, { status: 500 });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function onSubscriptionEvent(env: Env, provider: BillingProvider, subscriptionId: string): Promise<void> {
|
|
667
|
+
const checkout = await env.DB.prepare("SELECT * FROM billing_checkouts WHERE provider = ? AND provider_subscription_id = ?")
|
|
668
|
+
.bind(provider.id, subscriptionId).first<CheckoutRow>();
|
|
669
|
+
if (!checkout) return;
|
|
670
|
+
if (checkout.status === "pending") {
|
|
671
|
+
try {
|
|
672
|
+
await completeCheckout(env, checkout.workspace_id, checkout.id);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (!(error instanceof BillingError)) throw error;
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const row = await planRow(env, checkout.workspace_id);
|
|
679
|
+
if (row?.provider_subscription_id === subscriptionId) await syncWorkspaceBilling(env, checkout.workspace_id);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function refreshUsageCharge(env: Env, provider: BillingProvider, reference: string): Promise<void> {
|
|
683
|
+
const invoice = await env.DB.prepare("SELECT id, workspace_id AS workspaceId, status FROM invoices WHERE provider = ? AND provider_ref = ?")
|
|
684
|
+
.bind(provider.id, reference).first<{ id: string; workspaceId: string; status: string }>();
|
|
685
|
+
if (!invoice || invoice.status !== "open") return;
|
|
686
|
+
const status = await provider.usageChargeStatus(env, reference);
|
|
687
|
+
if (status === "paid") {
|
|
688
|
+
await env.DB.prepare("UPDATE invoices SET status = 'paid', paid_at = ? WHERE id = ?").bind(new Date().toISOString(), invoice.id).run();
|
|
689
|
+
await clearOverdueIfSettled(env, invoice.workspaceId);
|
|
690
|
+
} else if (status === "void") {
|
|
691
|
+
await env.DB.prepare("UPDATE invoices SET status = 'void' WHERE id = ?").bind(invoice.id).run();
|
|
692
|
+
await clearOverdueIfSettled(env, invoice.workspaceId);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function clearOverdueIfSettled(env: Env, workspaceId: string): Promise<void> {
|
|
697
|
+
const overdue = await env.DB.prepare("SELECT COUNT(*) AS count FROM invoices WHERE workspace_id = ? AND status = 'open' AND due_at < ?")
|
|
698
|
+
.bind(workspaceId, new Date().toISOString()).first<{ count: number }>();
|
|
699
|
+
const row = await planRow(env, workspaceId);
|
|
700
|
+
if (!overdue?.count && row?.status === "past_due" && row.provider_subscription_id) {
|
|
701
|
+
const remote = row.provider ? await billingProvider(row.provider).getSubscription(env, row.provider_subscription_id) : null;
|
|
702
|
+
if (remote?.status === "active") {
|
|
703
|
+
await env.DB.prepare("UPDATE workspace_plans SET status = 'active', grace_until = NULL WHERE workspace_id = ?").bind(workspaceId).run();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// --- Daily jobs ------------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
/** Closes last month's usage for every paid workspace and bills anything over the allowance. */
|
|
711
|
+
export async function closeUsagePeriods(env: Env, now = new Date()): Promise<number> {
|
|
712
|
+
const rows = await env.DB.prepare("SELECT workspace_id AS id FROM workspace_plans WHERE plan IN ('team', 'business') AND status != 'canceled'")
|
|
713
|
+
.all<{ id: string }>();
|
|
714
|
+
let billed = 0;
|
|
715
|
+
for (const { id } of rows.results) {
|
|
716
|
+
const plan = await getWorkspacePlan(env, id);
|
|
717
|
+
if (!isPaidPlan(plan.plan)) continue;
|
|
718
|
+
const current = usagePeriod(periodAnchor(plan), now);
|
|
719
|
+
const previous = usagePeriod(periodAnchor(plan), new Date(Date.parse(current.start) - 1));
|
|
720
|
+
// Nothing to close before the plan started.
|
|
721
|
+
if (plan.periodStart && Date.parse(previous.start) < Date.parse(plan.periodStart) - 1000) continue;
|
|
722
|
+
const result = await billUsagePeriod(env, id, previous, entitlementsFor(plan.plan, plan.overrides), plan.spendingLimitCents, now);
|
|
723
|
+
if (result) billed += 1;
|
|
724
|
+
}
|
|
725
|
+
return billed;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Usage invoices unpaid a week past due pause cloud usage the same way a failed renewal does. */
|
|
729
|
+
export async function chaseOverdueInvoices(env: Env, now = new Date()): Promise<void> {
|
|
730
|
+
const open = await env.DB.prepare("SELECT id, workspace_id AS workspaceId, provider, provider_ref AS ref, due_at AS dueAt FROM invoices WHERE kind = 'usage' AND status = 'open'")
|
|
731
|
+
.all<{ id: string; workspaceId: string; provider: string | null; ref: string | null; dueAt: string | null }>();
|
|
732
|
+
for (const invoice of open.results) {
|
|
733
|
+
if (invoice.provider && invoice.ref) {
|
|
734
|
+
await refreshUsageCharge(env, billingProvider(invoice.provider), invoice.ref).catch((error) => console.error("[billing] invoice status check failed", error));
|
|
735
|
+
}
|
|
736
|
+
const still = await env.DB.prepare("SELECT status FROM invoices WHERE id = ?").bind(invoice.id).first<{ status: string }>();
|
|
737
|
+
if (still?.status !== "open" || !invoice.dueAt) continue;
|
|
738
|
+
if (Date.parse(invoice.dueAt) + GRACE_DAYS * DAY <= now.getTime()) {
|
|
739
|
+
const changed = await env.DB.prepare(`
|
|
740
|
+
UPDATE workspace_plans SET status = 'past_due', grace_until = ?, updated_at = CURRENT_TIMESTAMP
|
|
741
|
+
WHERE workspace_id = ? AND status = 'active'
|
|
742
|
+
`).bind(now.toISOString(), invoice.workspaceId).run();
|
|
743
|
+
if ((changed.meta?.changes ?? 0) > 0) {
|
|
744
|
+
await sendBillingEmail(env, invoice.workspaceId, "Bugmole Cloud is paused until an invoice is paid", [
|
|
745
|
+
"A usage invoice is more than a week overdue, so cloud runs and AI work are paused. Pay it from the PayPal email, or from Billing.",
|
|
746
|
+
"Runs on your own machines and CI keep working.",
|
|
747
|
+
], "billing-overdue");
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function syncAllBilling(env: Env, now = new Date()): Promise<void> {
|
|
754
|
+
const rows = await env.DB.prepare("SELECT workspace_id AS id FROM workspace_plans WHERE plan IN ('team', 'business') AND status != 'canceled'")
|
|
755
|
+
.all<{ id: string }>();
|
|
756
|
+
for (const { id } of rows.results) {
|
|
757
|
+
try {
|
|
758
|
+
await syncWorkspaceBilling(env, id, now);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
console.error(`[billing] sync failed for ${id}`, error);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// Checkouts whose return page never came back (closed tab) and upgrades left half-settled.
|
|
764
|
+
const pending = await env.DB.prepare(`
|
|
765
|
+
SELECT id, workspace_id AS workspaceId, status FROM billing_checkouts
|
|
766
|
+
WHERE (status = 'pending' AND created_at > ?) OR (status = 'completed' AND replaces_subscription_id IS NOT NULL AND settled_at IS NULL)
|
|
767
|
+
`).bind(new Date(now.getTime() - 3 * DAY).toISOString().replace("T", " ").slice(0, 19)).all<{ id: string; workspaceId: string; status: string }>();
|
|
768
|
+
for (const checkout of pending.results) {
|
|
769
|
+
try {
|
|
770
|
+
if (checkout.status === "pending") await completeCheckout(env, checkout.workspaceId, checkout.id, now);
|
|
771
|
+
else await settleReplaced(env, checkout.id, now);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (!(error instanceof BillingError)) console.error(`[billing] checkout ${checkout.id} retry failed`, error);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'abandoned' WHERE status = 'pending' AND created_at <= ?")
|
|
777
|
+
.bind(new Date(now.getTime() - 3 * DAY).toISOString().replace("T", " ").slice(0, 19)).run();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export { BillingUnavailableError, type PlanId };
|