@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,186 @@
|
|
|
1
|
+
// Usage metering. Each billable thing (a cloud run's minutes, an AI action,
|
|
2
|
+
// a real-device session) is one idempotent row in usage_events; totals are
|
|
3
|
+
// summed per usage period on read.
|
|
4
|
+
import {
|
|
5
|
+
type Entitlements,
|
|
6
|
+
type UsageKind,
|
|
7
|
+
includedQuantity,
|
|
8
|
+
overageCents,
|
|
9
|
+
} from "../../billing/plan-catalog.js";
|
|
10
|
+
import { type Env, newId } from "../core.js";
|
|
11
|
+
import { cloudUsageSuspended, getEntitlements, type WorkspacePlan } from "./entitlements.js";
|
|
12
|
+
|
|
13
|
+
export const USAGE_KINDS: UsageKind[] = ["browser_minute", "device_minute", "ai_credit"];
|
|
14
|
+
|
|
15
|
+
function daysInMonth(year: number, month: number): number {
|
|
16
|
+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function anchored(year: number, month: number, anchor: Date): Date {
|
|
20
|
+
const day = Math.min(anchor.getUTCDate(), daysInMonth(year, month));
|
|
21
|
+
return new Date(Date.UTC(year, month, day, anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds()));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The monthly usage window containing `now`. Paid plans count from their
|
|
26
|
+
* subscription start (a yearly plan still closes usage monthly); Free counts
|
|
27
|
+
* calendar months (UTC).
|
|
28
|
+
*/
|
|
29
|
+
export function usagePeriod(anchorIso: string | null, now = new Date()): { start: string; end: string } {
|
|
30
|
+
const anchor = anchorIso ? new Date(anchorIso) : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
31
|
+
if (Number.isNaN(anchor.getTime()) || anchor > now) {
|
|
32
|
+
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
33
|
+
return { start: start.toISOString(), end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString() };
|
|
34
|
+
}
|
|
35
|
+
let year = now.getUTCFullYear();
|
|
36
|
+
let month = now.getUTCMonth();
|
|
37
|
+
let start = anchored(year, month, anchor);
|
|
38
|
+
if (start > now) {
|
|
39
|
+
month -= 1;
|
|
40
|
+
if (month < 0) { month = 11; year -= 1; }
|
|
41
|
+
start = anchored(year, month, anchor);
|
|
42
|
+
}
|
|
43
|
+
const endMonth = (month + 1) % 12;
|
|
44
|
+
const endYear = month === 11 ? year + 1 : year;
|
|
45
|
+
return { start: start.toISOString(), end: anchored(endYear, endMonth, anchor).toISOString() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function periodAnchor(plan: WorkspacePlan): string | null {
|
|
49
|
+
return plan.plan === "free" ? null : plan.periodStart;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type UsageLine = {
|
|
53
|
+
kind: UsageKind;
|
|
54
|
+
included: number | null;
|
|
55
|
+
used: number;
|
|
56
|
+
remaining: number | null;
|
|
57
|
+
overageQuantity: number;
|
|
58
|
+
unitCents: number | null;
|
|
59
|
+
overageCents: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type UsageSummary = {
|
|
63
|
+
period: { start: string; end: string };
|
|
64
|
+
lines: Record<UsageKind, UsageLine>;
|
|
65
|
+
overageCents: number;
|
|
66
|
+
spendingLimitCents: number | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function summarize(entitlements: Entitlements, used: Record<UsageKind, number>, period: UsageSummary["period"], spendingLimitCents: number | null): UsageSummary {
|
|
70
|
+
const lines = {} as Record<UsageKind, UsageLine>;
|
|
71
|
+
let total = 0;
|
|
72
|
+
for (const kind of USAGE_KINDS) {
|
|
73
|
+
const included = includedQuantity(entitlements, kind);
|
|
74
|
+
const unitCents = overageCents(entitlements, kind);
|
|
75
|
+
const quantity = used[kind] ?? 0;
|
|
76
|
+
const overageQuantity = included === null ? 0 : Math.max(0, quantity - included);
|
|
77
|
+
const cents = unitCents === null ? 0 : overageQuantity * unitCents;
|
|
78
|
+
total += cents;
|
|
79
|
+
lines[kind] = {
|
|
80
|
+
kind,
|
|
81
|
+
included,
|
|
82
|
+
used: quantity,
|
|
83
|
+
remaining: included === null ? null : Math.max(0, included - quantity),
|
|
84
|
+
overageQuantity,
|
|
85
|
+
unitCents,
|
|
86
|
+
overageCents: cents,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { period, lines, overageCents: total, spendingLimitCents };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function usedInPeriod(env: Env, workspaceId: string, periodStart: string): Promise<Record<UsageKind, number>> {
|
|
93
|
+
const rows = await env.DB.prepare(`
|
|
94
|
+
SELECT kind, COALESCE(SUM(quantity), 0) AS used
|
|
95
|
+
FROM usage_events WHERE workspace_id = ? AND period_start = ?
|
|
96
|
+
GROUP BY kind
|
|
97
|
+
`).bind(workspaceId, periodStart).all<{ kind: UsageKind; used: number }>();
|
|
98
|
+
const used = { browser_minute: 0, device_minute: 0, ai_credit: 0 } as Record<UsageKind, number>;
|
|
99
|
+
for (const row of rows.results) used[row.kind] = Number(row.used);
|
|
100
|
+
return used;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function usageSummary(env: Env, workspaceId: string, now = new Date()) {
|
|
104
|
+
const entitlements = await getEntitlements(env, workspaceId);
|
|
105
|
+
const period = usagePeriod(periodAnchor(entitlements.plan), now);
|
|
106
|
+
const used = await usedInPeriod(env, workspaceId, period.start);
|
|
107
|
+
return { entitlements, summary: summarize(entitlements, used, period, entitlements.plan.spendingLimitCents) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type SpendDecision =
|
|
111
|
+
| { allowed: true }
|
|
112
|
+
| { allowed: false; reason: "quota" | "spending_limit" | "plan" | "suspended"; message: string };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether a workspace may use `quantity` more of `kind` now. Free stops at its
|
|
116
|
+
* allowance; paid plans may go over it until the spending limit (if set).
|
|
117
|
+
*/
|
|
118
|
+
export async function canSpend(env: Env, workspaceId: string, kind: UsageKind, quantity: number, now = new Date()): Promise<SpendDecision> {
|
|
119
|
+
const { entitlements, summary } = await usageSummary(env, workspaceId, now);
|
|
120
|
+
const plan = entitlements.plan;
|
|
121
|
+
if (cloudUsageSuspended(plan, now)) {
|
|
122
|
+
return { allowed: false, reason: "suspended", message: "Cloud usage is paused until the overdue payment is settled. Local and CI runs keep working." };
|
|
123
|
+
}
|
|
124
|
+
const line = summary.lines[kind];
|
|
125
|
+
if (kind === "device_minute" && (plan.plan === "free" || !entitlements.features.deviceClouds)) {
|
|
126
|
+
return { allowed: false, reason: "plan", message: "Real devices are available on the Business plan." };
|
|
127
|
+
}
|
|
128
|
+
const projectedUsed = line.used + quantity;
|
|
129
|
+
if (line.unitCents === null) {
|
|
130
|
+
if (line.included !== null && projectedUsed > line.included) {
|
|
131
|
+
return {
|
|
132
|
+
allowed: false,
|
|
133
|
+
reason: "quota",
|
|
134
|
+
message: kind === "ai_credit"
|
|
135
|
+
? "This month's AI credits are used up. Upgrade for more, or connect your own model key."
|
|
136
|
+
: "This month's cloud minutes are used up. Upgrade for more, or run on your own machine or CI for free.",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { allowed: true };
|
|
140
|
+
}
|
|
141
|
+
if (summary.spendingLimitCents !== null) {
|
|
142
|
+
const projectedOverage = line.included === null ? 0 : Math.max(0, projectedUsed - line.included) * line.unitCents;
|
|
143
|
+
const otherOverage = summary.overageCents - line.overageCents;
|
|
144
|
+
if (otherOverage + projectedOverage > summary.spendingLimitCents) {
|
|
145
|
+
return { allowed: false, reason: "spending_limit", message: "Your monthly spending limit has been reached. Raise it in Billing to keep using the cloud." };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { allowed: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type UsageRecord = {
|
|
152
|
+
workspaceId: string;
|
|
153
|
+
projectId?: string | null;
|
|
154
|
+
runId?: string | null;
|
|
155
|
+
kind: UsageKind;
|
|
156
|
+
quantity: number;
|
|
157
|
+
source: "cloud_run" | "ai" | "device_farm" | "adjustment";
|
|
158
|
+
idempotencyKey: string;
|
|
159
|
+
meta?: Record<string, unknown>;
|
|
160
|
+
now?: Date;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Records usage once per idempotency key. Returns false if it was already recorded. */
|
|
164
|
+
export async function recordUsage(env: Env, record: UsageRecord): Promise<boolean> {
|
|
165
|
+
const now = record.now ?? new Date();
|
|
166
|
+
const entitlements = await getEntitlements(env, record.workspaceId);
|
|
167
|
+
const period = usagePeriod(periodAnchor(entitlements.plan), now);
|
|
168
|
+
const result = await env.DB.prepare(`
|
|
169
|
+
INSERT INTO usage_events (id, workspace_id, project_id, run_id, kind, quantity, source, idempotency_key, period_start, occurred_at, meta_json)
|
|
170
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
171
|
+
ON CONFLICT(idempotency_key) DO NOTHING
|
|
172
|
+
`).bind(
|
|
173
|
+
newId("use"),
|
|
174
|
+
record.workspaceId,
|
|
175
|
+
record.projectId ?? null,
|
|
176
|
+
record.runId ?? null,
|
|
177
|
+
record.kind,
|
|
178
|
+
Math.round(record.quantity),
|
|
179
|
+
record.source,
|
|
180
|
+
record.idempotencyKey,
|
|
181
|
+
period.start,
|
|
182
|
+
now.toISOString(),
|
|
183
|
+
record.meta ? JSON.stringify(record.meta) : null,
|
|
184
|
+
).run();
|
|
185
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
186
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// PayPal Subscriptions v1, sale refunds, webhook verification, and Invoicing
|
|
2
|
+
// v2. Subscriptions, refunds and verification are ported from realistiz-dd
|
|
3
|
+
// (src/worker/newsletter/paypalSubscriptions.ts); invoicing is new, for
|
|
4
|
+
// billing usage above a plan's allowance after the month closes.
|
|
5
|
+
//
|
|
6
|
+
// Most of this is reads on purpose: whether a subscription is active, whose
|
|
7
|
+
// it is (custom_id, stamped by us) and which sale paid all come from PayPal,
|
|
8
|
+
// never from a browser or a webhook body.
|
|
9
|
+
import { PayPalBusinessError, fail, issueOf, payPalApiFetch, type PayPalEnv } from "./client.js";
|
|
10
|
+
|
|
11
|
+
export type PayPalSubscriptionState = {
|
|
12
|
+
id: string;
|
|
13
|
+
/** APPROVAL_PENDING | APPROVED | ACTIVE | SUSPENDED | CANCELLED | EXPIRED. */
|
|
14
|
+
status: string;
|
|
15
|
+
customId: string | null;
|
|
16
|
+
planId: string | null;
|
|
17
|
+
startTime: string | null;
|
|
18
|
+
/** When PayPal will next charge: the end of the paid period. */
|
|
19
|
+
nextBillingTime: string | null;
|
|
20
|
+
lastPayment: { amount: string; currency: string; time: string } | null;
|
|
21
|
+
failedPayments: number;
|
|
22
|
+
statusChangeNote: string | null;
|
|
23
|
+
statusUpdateTime: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PayPalSale = { id: string; amount: string; currency: string; time: string };
|
|
27
|
+
|
|
28
|
+
/** PayPal's ids are short alphanumerics; anything else never reaches a URL path. */
|
|
29
|
+
const PAYPAL_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
30
|
+
|
|
31
|
+
function subscriptionPath(id: string, suffix = ""): string {
|
|
32
|
+
if (!PAYPAL_ID.test(id)) throw new PayPalBusinessError(400, "INVALID_RESOURCE_ID");
|
|
33
|
+
return `/v1/billing/subscriptions/${encodeURIComponent(id)}${suffix}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createPayPalSubscription(
|
|
37
|
+
env: PayPalEnv,
|
|
38
|
+
params: { planId: string; customId: string; email?: string; returnUrl: string; cancelUrl: string; requestId: string; startTime?: string },
|
|
39
|
+
): Promise<{ id: string; approveUrl: string }> {
|
|
40
|
+
const res = await payPalApiFetch(env, "/v1/billing/subscriptions", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "content-type": "application/json", "PayPal-Request-Id": params.requestId, Prefer: "return=representation" },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
plan_id: params.planId,
|
|
45
|
+
custom_id: params.customId,
|
|
46
|
+
...(params.startTime ? { start_time: params.startTime } : {}),
|
|
47
|
+
...(params.email ? { subscriber: { email_address: params.email } } : {}),
|
|
48
|
+
application_context: {
|
|
49
|
+
brand_name: "Bugmole",
|
|
50
|
+
shipping_preference: "NO_SHIPPING",
|
|
51
|
+
user_action: "SUBSCRIBE_NOW",
|
|
52
|
+
return_url: params.returnUrl,
|
|
53
|
+
cancel_url: params.cancelUrl,
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) return fail(res, "create subscription");
|
|
58
|
+
const body = (await res.json()) as { id: string; links?: Array<{ href: string; rel: string }> };
|
|
59
|
+
const approveUrl = body.links?.find((link) => link.rel === "approve")?.href;
|
|
60
|
+
if (!approveUrl) throw new Error(`PayPal subscription ${body.id} came back without an approve link`);
|
|
61
|
+
return { id: body.id, approveUrl };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Null when PayPal has no such subscription. */
|
|
65
|
+
export async function getPayPalSubscription(env: PayPalEnv, id: string): Promise<PayPalSubscriptionState | null> {
|
|
66
|
+
if (!PAYPAL_ID.test(id)) return null;
|
|
67
|
+
const res = await payPalApiFetch(env, subscriptionPath(id));
|
|
68
|
+
if (res.status === 404) return null;
|
|
69
|
+
if (!res.ok) return fail(res, "get subscription");
|
|
70
|
+
const body = (await res.json()) as {
|
|
71
|
+
id: string;
|
|
72
|
+
status: string;
|
|
73
|
+
custom_id?: string;
|
|
74
|
+
plan_id?: string;
|
|
75
|
+
start_time?: string;
|
|
76
|
+
status_change_note?: string;
|
|
77
|
+
status_update_time?: string;
|
|
78
|
+
billing_info?: {
|
|
79
|
+
next_billing_time?: string;
|
|
80
|
+
failed_payments_count?: number;
|
|
81
|
+
last_payment?: { amount?: { value: string; currency_code: string }; time?: string };
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const last = body.billing_info?.last_payment;
|
|
85
|
+
return {
|
|
86
|
+
id: body.id,
|
|
87
|
+
status: body.status,
|
|
88
|
+
customId: body.custom_id ?? null,
|
|
89
|
+
planId: body.plan_id ?? null,
|
|
90
|
+
startTime: body.start_time ?? null,
|
|
91
|
+
nextBillingTime: body.billing_info?.next_billing_time ?? null,
|
|
92
|
+
lastPayment: last?.amount ? { amount: last.amount.value, currency: last.amount.currency_code, time: last.time ?? "" } : null,
|
|
93
|
+
failedPayments: body.billing_info?.failed_payments_count ?? 0,
|
|
94
|
+
statusChangeNote: body.status_change_note ?? null,
|
|
95
|
+
statusUpdateTime: body.status_update_time ?? null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The most recent COMPLETED sale on a subscription in [since, until], or null. */
|
|
100
|
+
export async function latestCompletedSale(env: PayPalEnv, id: string, since: Date, until: Date): Promise<PayPalSale | null> {
|
|
101
|
+
const query = new URLSearchParams({ start_time: since.toISOString(), end_time: until.toISOString() });
|
|
102
|
+
const res = await payPalApiFetch(env, subscriptionPath(id, `/transactions?${query}`));
|
|
103
|
+
// Some PayPal environments report "no transactions yet" as 404.
|
|
104
|
+
if (res.status === 404) return null;
|
|
105
|
+
if (!res.ok) return fail(res, "list subscription transactions");
|
|
106
|
+
const body = (await res.json()) as {
|
|
107
|
+
transactions?: Array<{ id: string; status: string; time: string; amount_with_breakdown?: { gross_amount?: { value: string; currency_code: string } } }>;
|
|
108
|
+
};
|
|
109
|
+
const latest = (body.transactions ?? [])
|
|
110
|
+
.filter((t) => t.status === "COMPLETED" && t.amount_with_breakdown?.gross_amount)
|
|
111
|
+
.sort((a, b) => Date.parse(b.time) - Date.parse(a.time))[0];
|
|
112
|
+
if (!latest) return null;
|
|
113
|
+
const gross = latest.amount_with_breakdown!.gross_amount!;
|
|
114
|
+
return { id: latest.id, amount: gross.value, currency: gross.currency_code, time: latest.time };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Already cancelled counts as done: the outcome we wanted (no more charges) is already true. */
|
|
118
|
+
export async function cancelPayPalSubscription(env: PayPalEnv, id: string, reason: string): Promise<void> {
|
|
119
|
+
const res = await payPalApiFetch(env, subscriptionPath(id, "/cancel"), {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ reason: reason.slice(0, 128) }),
|
|
123
|
+
});
|
|
124
|
+
if (res.ok) return;
|
|
125
|
+
if (res.status === 422 && issueOf(await res.clone().text()) === "SUBSCRIPTION_STATUS_INVALID") return;
|
|
126
|
+
return fail(res, "cancel subscription");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Refunds part of a subscription payment. Subscription payments are sales,
|
|
131
|
+
* refunded through /v1/payments/sale/{id}/refund (v2 only refunds captures).
|
|
132
|
+
* `requestId` makes a retry refund once, not twice.
|
|
133
|
+
*/
|
|
134
|
+
export async function refundPayPalSale(
|
|
135
|
+
env: PayPalEnv,
|
|
136
|
+
saleId: string,
|
|
137
|
+
amount: { total: string; currency: string },
|
|
138
|
+
requestId: string,
|
|
139
|
+
description: string,
|
|
140
|
+
): Promise<{ id: string; state: string }> {
|
|
141
|
+
if (!PAYPAL_ID.test(saleId)) throw new PayPalBusinessError(400, "INVALID_RESOURCE_ID");
|
|
142
|
+
const res = await payPalApiFetch(env, `/v1/payments/sale/${encodeURIComponent(saleId)}/refund`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "content-type": "application/json", "PayPal-Request-Id": requestId },
|
|
145
|
+
body: JSON.stringify({ amount, description: description.slice(0, 255) }),
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) return fail(res, "refund sale");
|
|
148
|
+
const body = (await res.json()) as { id: string; state: string };
|
|
149
|
+
if (body.state === "failed") throw new PayPalBusinessError(res.status, "REFUND_FAILED");
|
|
150
|
+
return { id: body.id, state: body.state };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* True only when PayPal says SUCCESS. The event goes back as the exact bytes
|
|
155
|
+
* it arrived as: the signature covers the original body.
|
|
156
|
+
*/
|
|
157
|
+
export async function verifyPayPalWebhookSignature(env: PayPalEnv, headers: Headers, rawBody: string): Promise<boolean> {
|
|
158
|
+
const fields = {
|
|
159
|
+
transmission_id: headers.get("paypal-transmission-id"),
|
|
160
|
+
transmission_time: headers.get("paypal-transmission-time"),
|
|
161
|
+
cert_url: headers.get("paypal-cert-url"),
|
|
162
|
+
auth_algo: headers.get("paypal-auth-algo"),
|
|
163
|
+
transmission_sig: headers.get("paypal-transmission-sig"),
|
|
164
|
+
webhook_id: env.PAYPAL_WEBHOOK_ID ?? null,
|
|
165
|
+
};
|
|
166
|
+
if (Object.values(fields).some((value) => !value)) return false;
|
|
167
|
+
const res = await payPalApiFetch(env, "/v1/notifications/verify-webhook-signature", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": "application/json" },
|
|
170
|
+
body: `${JSON.stringify(fields).slice(0, -1)},"webhook_event":${rawBody}}`,
|
|
171
|
+
});
|
|
172
|
+
if (res.status >= 400 && res.status < 500) {
|
|
173
|
+
console.error(`[billing] PayPal webhook verification refused: ${res.status} ${await res.text()}`);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!res.ok) return fail(res, "verify webhook signature");
|
|
177
|
+
return ((await res.json()) as { verification_status?: string }).verification_status === "SUCCESS";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type InvoiceLine = { name: string; description?: string; quantity: number; unitCents: number };
|
|
181
|
+
|
|
182
|
+
function money(cents: number): string {
|
|
183
|
+
return (cents / 100).toFixed(2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Creates and sends a PayPal invoice for usage above the plan's allowance.
|
|
188
|
+
* The payer gets PayPal's email with a pay link. `invoiceNumber` is ours and
|
|
189
|
+
* unique, so a retry that already created the invoice is found, not doubled.
|
|
190
|
+
*/
|
|
191
|
+
export async function sendPayPalInvoice(
|
|
192
|
+
env: PayPalEnv,
|
|
193
|
+
params: { invoiceNumber: string; email: string; name?: string; note: string; lines: InvoiceLine[]; netDays: 10 | 15 | 30; requestId: string },
|
|
194
|
+
): Promise<{ id: string; url: string | null }> {
|
|
195
|
+
const res = await payPalApiFetch(env, "/v2/invoicing/invoices", {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "content-type": "application/json", "PayPal-Request-Id": params.requestId, Prefer: "return=representation" },
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
detail: {
|
|
200
|
+
invoice_number: params.invoiceNumber,
|
|
201
|
+
currency_code: "USD",
|
|
202
|
+
note: params.note,
|
|
203
|
+
payment_term: { term_type: `NET_${params.netDays}` },
|
|
204
|
+
},
|
|
205
|
+
invoicer: { business_name: "Bugmole" },
|
|
206
|
+
primary_recipients: [{ billing_info: { email_address: params.email, ...(params.name ? { name: { full_name: params.name } } : {}) } }],
|
|
207
|
+
items: params.lines.map((line) => ({
|
|
208
|
+
name: line.name.slice(0, 200),
|
|
209
|
+
...(line.description ? { description: line.description.slice(0, 1000) } : {}),
|
|
210
|
+
quantity: String(line.quantity),
|
|
211
|
+
unit_amount: { currency_code: "USD", value: money(line.unitCents) },
|
|
212
|
+
unit_of_measure: "QUANTITY",
|
|
213
|
+
})),
|
|
214
|
+
configuration: { allow_tip: false, tax_calculated_after_discount: true, tax_inclusive: false },
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
let id: string;
|
|
218
|
+
if (res.ok) {
|
|
219
|
+
id = ((await res.json()) as { id: string }).id;
|
|
220
|
+
} else {
|
|
221
|
+
// A retry after a created-but-unsent invoice: PayPal rejects the duplicate number; find the original.
|
|
222
|
+
const text = await res.clone().text();
|
|
223
|
+
if (res.status !== 422 || issueOf(text) !== "DUPLICATE_INVOICE_NUMBER") return fail(res, "create invoice");
|
|
224
|
+
const found = await findPayPalInvoice(env, params.invoiceNumber);
|
|
225
|
+
if (!found) return fail(res, "create invoice");
|
|
226
|
+
id = found;
|
|
227
|
+
}
|
|
228
|
+
const sent = await payPalApiFetch(env, `/v2/invoicing/invoices/${encodeURIComponent(id)}/send`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "content-type": "application/json", "PayPal-Request-Id": `${params.requestId}:send` },
|
|
231
|
+
body: JSON.stringify({ send_to_invoicer: false, send_to_recipient: true }),
|
|
232
|
+
});
|
|
233
|
+
// Already sent counts as sent.
|
|
234
|
+
if (!sent.ok && !(sent.status === 422 && issueOf(await sent.clone().text()) === "INVALID_INVOICE_STATUS")) {
|
|
235
|
+
return fail(sent, "send invoice");
|
|
236
|
+
}
|
|
237
|
+
const payload = sent.ok ? await sent.json().catch(() => ({})) as { href?: string } : {};
|
|
238
|
+
return { id, url: payload.href ?? null };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function findPayPalInvoice(env: PayPalEnv, invoiceNumber: string): Promise<string | null> {
|
|
242
|
+
const res = await payPalApiFetch(env, "/v2/invoicing/search-invoices?page_size=1", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "content-type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ invoice_number: invoiceNumber }),
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) return null;
|
|
248
|
+
const body = (await res.json()) as { items?: Array<{ id: string }> };
|
|
249
|
+
return body.items?.[0]?.id ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** PAID, MARKED_AS_PAID, PARTIALLY_PAID, SENT, UNPAID, CANCELLED, REFUNDED… or null when unknown. */
|
|
253
|
+
export async function getPayPalInvoiceStatus(env: PayPalEnv, id: string): Promise<string | null> {
|
|
254
|
+
if (!PAYPAL_ID.test(id)) return null;
|
|
255
|
+
const res = await payPalApiFetch(env, `/v2/invoicing/invoices/${encodeURIComponent(id)}`);
|
|
256
|
+
if (res.status === 404) return null;
|
|
257
|
+
if (!res.ok) return fail(res, "get invoice");
|
|
258
|
+
return ((await res.json()) as { status?: string }).status ?? null;
|
|
259
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// PayPal REST client: credentials, the live/sandbox switch, and error
|
|
2
|
+
// classification. Ported from realistiz-dd (src/worker/paypal.ts).
|
|
3
|
+
//
|
|
4
|
+
// Live and sandbox credentials live side by side under different names and
|
|
5
|
+
// PAYPAL_ENV picks the pair, so testing never means re-uploading the live
|
|
6
|
+
// secrets, and a sandbox deploy can't quietly charge real cards.
|
|
7
|
+
|
|
8
|
+
const LIVE_BASE = "https://api-m.paypal.com";
|
|
9
|
+
const SANDBOX_BASE = "https://api-m.sandbox.paypal.com";
|
|
10
|
+
|
|
11
|
+
export type PayPalEnv = {
|
|
12
|
+
PAYPAL_ENV?: string;
|
|
13
|
+
PAYPAL_CLIENT_ID?: string;
|
|
14
|
+
PAYPAL_CLIENT_SECRET?: string;
|
|
15
|
+
PAYPAL_SANDBOX_CLIENT_ID?: string;
|
|
16
|
+
PAYPAL_SANDBOX_CLIENT_SECRET?: string;
|
|
17
|
+
PAYPAL_WEBHOOK_ID?: string;
|
|
18
|
+
/** Billing plan ids created by scripts/billing/paypal-setup.mjs. Not secret. */
|
|
19
|
+
PAYPAL_PLAN_TEAM_MONTH?: string;
|
|
20
|
+
PAYPAL_PLAN_TEAM_YEAR?: string;
|
|
21
|
+
PAYPAL_PLAN_BUSINESS_MONTH?: string;
|
|
22
|
+
PAYPAL_PLAN_BUSINESS_YEAR?: string;
|
|
23
|
+
/** Test seam: replaces fetch for every PayPal call. */
|
|
24
|
+
PAYPAL_FETCH?: typeof fetch;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A refusal from PayPal about the request itself (not approved, already
|
|
29
|
+
* cancelled, declined). A 5xx is PayPal being unwell and is a plain Error,
|
|
30
|
+
* worth retrying.
|
|
31
|
+
*/
|
|
32
|
+
export class PayPalBusinessError extends Error {
|
|
33
|
+
constructor(readonly httpStatus: number, readonly issue: string) {
|
|
34
|
+
super(`PayPal refused the request: ${httpStatus} ${issue}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Credentials = { clientId: string; clientSecret: string; apiBase: string };
|
|
39
|
+
|
|
40
|
+
export function payPalCredentials(env: PayPalEnv): Credentials | null {
|
|
41
|
+
const sandbox = env.PAYPAL_ENV === "sandbox";
|
|
42
|
+
const clientId = sandbox ? env.PAYPAL_SANDBOX_CLIENT_ID : env.PAYPAL_CLIENT_ID;
|
|
43
|
+
const clientSecret = sandbox ? env.PAYPAL_SANDBOX_CLIENT_SECRET : env.PAYPAL_CLIENT_SECRET;
|
|
44
|
+
if (!clientId || !clientSecret) return null;
|
|
45
|
+
return { clientId, clientSecret, apiBase: sandbox ? SANDBOX_BASE : LIVE_BASE };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isPayPalConfigured(env: PayPalEnv): boolean {
|
|
49
|
+
return payPalCredentials(env) !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireCredentials(env: PayPalEnv): Credentials {
|
|
53
|
+
const credentials = payPalCredentials(env);
|
|
54
|
+
if (!credentials) throw new Error(`PayPal credentials missing for env "${env.PAYPAL_ENV ?? "live"}"`);
|
|
55
|
+
return credentials;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Not cached: one extra round trip per billing action is nothing, and a stale token is a class of bug. */
|
|
59
|
+
async function accessToken(env: PayPalEnv): Promise<string> {
|
|
60
|
+
const { clientId, clientSecret, apiBase } = requireCredentials(env);
|
|
61
|
+
const res = await (env.PAYPAL_FETCH ?? fetch)(`${apiBase}/v1/oauth2/token`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`, "content-type": "application/x-www-form-urlencoded" },
|
|
64
|
+
body: "grant_type=client_credentials",
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) throw new Error(`PayPal auth failed: ${res.status} ${await res.text()}`);
|
|
67
|
+
return ((await res.json()) as { access_token: string }).access_token;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An authenticated call to a PayPal REST path in the active environment. */
|
|
71
|
+
export async function payPalApiFetch(env: PayPalEnv, path: string, init: RequestInit = {}): Promise<Response> {
|
|
72
|
+
const headers = new Headers(init.headers);
|
|
73
|
+
headers.set("Authorization", `Bearer ${await accessToken(env)}`);
|
|
74
|
+
return (env.PAYPAL_FETCH ?? fetch)(`${requireCredentials(env).apiBase}${path}`, { ...init, headers });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** PayPal nests the useful part as details[0].issue, e.g. SUBSCRIPTION_STATUS_INVALID. */
|
|
78
|
+
export function issueOf(body: string): string {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(body) as { name?: string; details?: Array<{ issue?: string }> };
|
|
81
|
+
return parsed.details?.[0]?.issue ?? parsed.name ?? "UNKNOWN";
|
|
82
|
+
} catch {
|
|
83
|
+
return "UNPARSEABLE";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function fail(res: Response, what: string): Promise<never> {
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
if (res.status >= 400 && res.status < 500) throw new PayPalBusinessError(res.status, issueOf(text));
|
|
90
|
+
throw new Error(`PayPal ${what} failed: ${res.status} ${text}`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// PayPal as a billing provider: Subscriptions for plan fees, Invoicing for
|
|
2
|
+
// usage above the allowance, sale refunds for upgrades.
|
|
3
|
+
import type { BillingInterval } from "../../../billing/plan-catalog.js";
|
|
4
|
+
import type { Env } from "../../core.js";
|
|
5
|
+
import { registerBillingProvider, toCents, fromCents, type BillingProvider, type PaidPlan, type ProviderSubscription } from "../provider.js";
|
|
6
|
+
import { isPayPalConfigured, type PayPalEnv } from "./client.js";
|
|
7
|
+
import {
|
|
8
|
+
cancelPayPalSubscription,
|
|
9
|
+
createPayPalSubscription,
|
|
10
|
+
getPayPalInvoiceStatus,
|
|
11
|
+
getPayPalSubscription,
|
|
12
|
+
latestCompletedSale,
|
|
13
|
+
refundPayPalSale,
|
|
14
|
+
sendPayPalInvoice,
|
|
15
|
+
verifyPayPalWebhookSignature,
|
|
16
|
+
} from "./api.js";
|
|
17
|
+
|
|
18
|
+
const PLAN_VARS: Record<`${PaidPlan}:${BillingInterval}`, keyof PayPalEnv> = {
|
|
19
|
+
"team:month": "PAYPAL_PLAN_TEAM_MONTH",
|
|
20
|
+
"team:year": "PAYPAL_PLAN_TEAM_YEAR",
|
|
21
|
+
"business:month": "PAYPAL_PLAN_BUSINESS_MONTH",
|
|
22
|
+
"business:year": "PAYPAL_PLAN_BUSINESS_YEAR",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function payPal(env: Env): PayPalEnv {
|
|
26
|
+
return env as Env & PayPalEnv;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function planIdFor(env: Env, plan: PaidPlan, interval: BillingInterval): string | undefined {
|
|
30
|
+
return (payPal(env)[PLAN_VARS[`${plan}:${interval}`]] as string | undefined) || undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function planOfId(env: Env, planId: string | null): ProviderSubscription["plan"] {
|
|
34
|
+
if (!planId) return null;
|
|
35
|
+
for (const [key, variable] of Object.entries(PLAN_VARS)) {
|
|
36
|
+
if (payPal(env)[variable] === planId) {
|
|
37
|
+
const [plan, interval] = key.split(":") as [PaidPlan, BillingInterval];
|
|
38
|
+
return { plan, interval };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STATUS: Record<string, ProviderSubscription["status"]> = {
|
|
45
|
+
APPROVAL_PENDING: "pending",
|
|
46
|
+
APPROVED: "pending",
|
|
47
|
+
ACTIVE: "active",
|
|
48
|
+
SUSPENDED: "past_due",
|
|
49
|
+
CANCELLED: "ended",
|
|
50
|
+
EXPIRED: "ended",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const INVOICE_STATUS: Record<string, "open" | "paid" | "void"> = {
|
|
54
|
+
PAID: "paid",
|
|
55
|
+
MARKED_AS_PAID: "paid",
|
|
56
|
+
CANCELLED: "void",
|
|
57
|
+
REFUNDED: "void",
|
|
58
|
+
MARKED_AS_REFUNDED: "void",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const payPalProvider: BillingProvider = {
|
|
62
|
+
id: "paypal",
|
|
63
|
+
|
|
64
|
+
canSell(env, plan, interval) {
|
|
65
|
+
return isPayPalConfigured(payPal(env)) && Boolean(planIdFor(env, plan, interval));
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async createSubscription(env, params) {
|
|
69
|
+
const planId = planIdFor(env, params.plan, params.interval);
|
|
70
|
+
if (!planId) throw new Error(`PayPal has no billing plan for ${params.plan} (${params.interval})`);
|
|
71
|
+
const created = await createPayPalSubscription(payPal(env), {
|
|
72
|
+
planId,
|
|
73
|
+
customId: params.reference,
|
|
74
|
+
email: params.email,
|
|
75
|
+
returnUrl: params.returnUrl,
|
|
76
|
+
cancelUrl: params.cancelUrl,
|
|
77
|
+
requestId: params.requestId,
|
|
78
|
+
startTime: params.startAt,
|
|
79
|
+
});
|
|
80
|
+
return { subscriptionId: created.id, approveUrl: created.approveUrl };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async getSubscription(env, id) {
|
|
84
|
+
const remote = await getPayPalSubscription(payPal(env), id);
|
|
85
|
+
if (!remote) return null;
|
|
86
|
+
const ended = STATUS[remote.status] === "ended";
|
|
87
|
+
return {
|
|
88
|
+
id: remote.id,
|
|
89
|
+
status: STATUS[remote.status] ?? "pending",
|
|
90
|
+
reference: remote.customId,
|
|
91
|
+
plan: planOfId(env, remote.planId),
|
|
92
|
+
startedAt: remote.startTime,
|
|
93
|
+
paidThrough: remote.nextBillingTime,
|
|
94
|
+
endNote: ended ? remote.statusChangeNote : null,
|
|
95
|
+
endedAt: ended ? remote.statusUpdateTime : null,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async latestPayment(env, subscriptionId, since, until) {
|
|
100
|
+
const sale = await latestCompletedSale(payPal(env), subscriptionId, since, until);
|
|
101
|
+
return sale ? { reference: sale.id, amountCents: toCents(sale.amount), currency: sale.currency, paidAt: sale.time } : null;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
cancelSubscription(env, id, reason) {
|
|
105
|
+
return cancelPayPalSubscription(payPal(env), id, reason);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async refund(env, payment, amountCents, requestId, description) {
|
|
109
|
+
const result = await refundPayPalSale(payPal(env), payment.reference, { total: fromCents(amountCents), currency: payment.currency }, requestId, description);
|
|
110
|
+
return { reference: result.id };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async chargeUsage(env, params) {
|
|
114
|
+
const sent = await sendPayPalInvoice(payPal(env), { ...params, netDays: 15 });
|
|
115
|
+
return { reference: sent.id, url: sent.url };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async usageChargeStatus(env, reference) {
|
|
119
|
+
const status = await getPayPalInvoiceStatus(payPal(env), reference);
|
|
120
|
+
return status === null ? null : INVOICE_STATUS[status] ?? "open";
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async verifyWebhook(env, request, rawBody) {
|
|
124
|
+
if (!payPal(env).PAYPAL_WEBHOOK_ID || !isPayPalConfigured(payPal(env))) return null;
|
|
125
|
+
let event: { id?: string; event_type?: string; resource?: { id?: string; billing_agreement_id?: string; invoice?: { id?: string } } };
|
|
126
|
+
try {
|
|
127
|
+
event = JSON.parse(rawBody);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (!(await verifyPayPalWebhookSignature(payPal(env), request.headers, rawBody))) return null;
|
|
132
|
+
const type = event.event_type ?? "";
|
|
133
|
+
return {
|
|
134
|
+
id: event.id ?? "",
|
|
135
|
+
type,
|
|
136
|
+
...(type.startsWith("BILLING.SUBSCRIPTION.") && event.resource?.id ? { subscriptionId: event.resource.id } : {}),
|
|
137
|
+
...(type.startsWith("PAYMENT.SALE.") && event.resource?.billing_agreement_id ? { subscriptionId: event.resource.billing_agreement_id } : {}),
|
|
138
|
+
...(type.startsWith("INVOICING.INVOICE.") ? { usageChargeId: event.resource?.invoice?.id ?? event.resource?.id } : {}),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
registerBillingProvider(payPalProvider);
|