@bugmole/cli 0.4.0 → 0.6.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/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Plan limits at the points where they apply. The "entitlements" flag lets
|
|
2
2
|
// limits run in shadow mode first: violations are logged, nothing is blocked.
|
|
3
|
+
import { PLANS, type PlanId } from "../../billing/plan-catalog.js";
|
|
3
4
|
import type { Env } from "../core.js";
|
|
4
5
|
import { response } from "../core.js";
|
|
5
|
-
import {
|
|
6
|
+
import { featureEnabled, featureState } from "../feature-access.js";
|
|
7
|
+
import { flagMode } from "../flags.js";
|
|
6
8
|
import { getEntitlements } from "./entitlements.js";
|
|
7
9
|
import { canSpend } from "./ledger.js";
|
|
8
10
|
|
|
@@ -10,14 +12,38 @@ export type EnforcementMode = "enforce" | "shadow" | "off";
|
|
|
10
12
|
|
|
11
13
|
export async function entitlementMode(env: Env, workspaceId: string | null): Promise<EnforcementMode> {
|
|
12
14
|
const mode = flagMode(env, "entitlements");
|
|
13
|
-
if (mode === "on") return "enforce";
|
|
14
15
|
if (mode === "shadow") return "shadow";
|
|
15
|
-
if (mode === "
|
|
16
|
-
|
|
16
|
+
if (!workspaceId) return mode === "on" ? "enforce" : "off";
|
|
17
|
+
// Kill switch, beta opt-in and a workspace override (e.g. limits lifted
|
|
18
|
+
// during an incident) all come from the shared evaluator.
|
|
19
|
+
return (await featureState(env, workspaceId, "entitlements")).enabled ? "enforce" : "off";
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
/** Where Billing lives, relative (for the dashboard) and absolute (for the CLI, email and anything outside it). */
|
|
23
|
+
export function billingLinks(env: Env, workspaceId: string | null, plan?: PlanId | null): { upgradeUrl: string; billingUrl: string } {
|
|
24
|
+
const query = new URLSearchParams();
|
|
25
|
+
if (workspaceId) query.set("workspace", workspaceId);
|
|
26
|
+
if (plan) query.set("plan", plan);
|
|
27
|
+
const search = query.toString();
|
|
28
|
+
const upgradeUrl = `/billing${search ? `?${search}` : ""}`;
|
|
29
|
+
return { upgradeUrl, billingUrl: `${(env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "")}${upgradeUrl}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The smallest paid plan with room for more projects than `current`; Enterprise when none has. */
|
|
33
|
+
export function planForProjects(current: number): PlanId {
|
|
34
|
+
for (const plan of ["team", "business"] as const) {
|
|
35
|
+
const limit = PLANS[plan].projects;
|
|
36
|
+
if (limit === null || limit > current) return plan;
|
|
37
|
+
}
|
|
38
|
+
return "enterprise";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function planLimitBody(env: Env, workspaceId: string | null, message: string, limit: string, extra: Record<string, unknown> & { suggestedPlan?: PlanId } = {}) {
|
|
42
|
+
return { error: message, code: "plan_limit", limit, ...billingLinks(env, workspaceId, extra.suggestedPlan), ...extra };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function planLimit(env: Env, workspaceId: string, message: string, limit: string, extra: Record<string, unknown> & { suggestedPlan?: PlanId } = {}): Response {
|
|
46
|
+
return response(planLimitBody(env, workspaceId, message, limit, extra), 402);
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
/** Null when allowed; otherwise the response to return. */
|
|
@@ -32,10 +58,18 @@ async function decide(env: Env, workspaceId: string, violation: Response | null,
|
|
|
32
58
|
export async function checkProjectLimit(env: Env, workspaceId: string): Promise<Response | null> {
|
|
33
59
|
const { projects } = await getEntitlements(env, workspaceId);
|
|
34
60
|
if (projects === null) return null;
|
|
35
|
-
|
|
61
|
+
// Paused (inactive) projects don't count until someone activates them.
|
|
62
|
+
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ? AND deleted_at IS NULL AND inactive_at IS NULL")
|
|
36
63
|
.bind(workspaceId).first<{ count: number }>();
|
|
37
|
-
const
|
|
38
|
-
|
|
64
|
+
const count = row?.count ?? 0;
|
|
65
|
+
const suggestedPlan = planForProjects(count);
|
|
66
|
+
const violation = count >= projects
|
|
67
|
+
? planLimit(
|
|
68
|
+
env, workspaceId,
|
|
69
|
+
`Your plan includes ${projects} project${projects === 1 ? "" : "s"} and this workspace has ${count}. ${suggestedPlan === "enterprise" ? "Contact us about Enterprise" : `Upgrade to ${PLANS[suggestedPlan].name}`} to add more.`,
|
|
70
|
+
"projects",
|
|
71
|
+
{ included: projects, suggestedPlan },
|
|
72
|
+
)
|
|
39
73
|
: null;
|
|
40
74
|
return decide(env, workspaceId, violation, "project creation");
|
|
41
75
|
}
|
|
@@ -52,18 +86,39 @@ export function executionModeOf(value: unknown): ExecutionMode | null {
|
|
|
52
86
|
* A cloud run needs the cloud-runner rollout and minutes (or budget) left.
|
|
53
87
|
*/
|
|
54
88
|
export async function checkCloudRun(env: Env, workspaceId: string): Promise<Response | null> {
|
|
55
|
-
if (!(await
|
|
89
|
+
if (!(await featureEnabled(env, workspaceId, "cloud_runners"))) {
|
|
56
90
|
return response({ error: "Bugmole Cloud runs aren't available for this workspace yet. Run on your own machine or CI.", code: "cloud_unavailable" }, 422);
|
|
57
91
|
}
|
|
58
92
|
const decision = await canSpend(env, workspaceId, "browser_minute", 1);
|
|
59
93
|
const violation = decision.allowed
|
|
60
94
|
? null
|
|
61
|
-
: planLimit(decision.message, decision.reason === "spending_limit" ? "spending_limit" : decision.reason === "suspended" ? "payment" : "cloud_minutes");
|
|
95
|
+
: planLimit(env, workspaceId, decision.message, decision.reason === "spending_limit" ? "spending_limit" : decision.reason === "suspended" ? "payment" : "cloud_minutes");
|
|
62
96
|
return decide(env, workspaceId, violation, "cloud run");
|
|
63
97
|
}
|
|
64
98
|
|
|
99
|
+
/** Runs that finished before this are past the plan's history. Null keeps everything. */
|
|
100
|
+
export function historyCutoff(historyDays: number | null, now: Date): string | null {
|
|
101
|
+
return historyDays === null ? null : new Date(now.getTime() - historyDays * 86_400_000).toISOString();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The history cutoff reads apply: the same one the retention job deletes at,
|
|
106
|
+
* and only once plan limits are enforced (shadow and off keep everything).
|
|
107
|
+
*/
|
|
65
108
|
export async function historyCutoffFor(env: Env, workspaceId: string | null, now = new Date()): Promise<string | null> {
|
|
66
109
|
if (!workspaceId || (await entitlementMode(env, workspaceId)) !== "enforce") return null;
|
|
67
110
|
const { historyDays } = await getEntitlements(env, workspaceId);
|
|
68
|
-
return historyDays
|
|
111
|
+
return historyCutoff(historyDays, now);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const TERMINAL_RUN = new Set(["success", "partial", "error", "cancelled"]);
|
|
115
|
+
|
|
116
|
+
/** Whether a run is past the history cutoff: finished, and finished before it. Unfinished runs are never hidden. */
|
|
117
|
+
export function runExpired(run: { status: string; completedAt?: string | null; requestedAt?: string | null }, cutoff: string | null): boolean {
|
|
118
|
+
if (!cutoff || !TERMINAL_RUN.has(run.status)) return false;
|
|
119
|
+
const at = run.completedAt ?? run.requestedAt;
|
|
120
|
+
if (!at) return false;
|
|
121
|
+
// D1's CURRENT_TIMESTAMP is "YYYY-MM-DD HH:MM:SS" in UTC.
|
|
122
|
+
const time = Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(at) ? at : `${at.replace(" ", "T")}Z`);
|
|
123
|
+
return Number.isFinite(time) && time < Date.parse(cutoff);
|
|
69
124
|
}
|
|
@@ -126,6 +126,20 @@ export async function cancelPayPalSubscription(env: PayPalEnv, id: string, reaso
|
|
|
126
126
|
return fail(res, "cancel subscription");
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Restarts a SUSPENDED subscription. PayPal only activates suspended ones: a
|
|
131
|
+
* CANCELLED or EXPIRED subscription is final and needs a new checkout.
|
|
132
|
+
*/
|
|
133
|
+
export async function activatePayPalSubscription(env: PayPalEnv, id: string, reason: string): Promise<void> {
|
|
134
|
+
const res = await payPalApiFetch(env, subscriptionPath(id, "/activate"), {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/json" },
|
|
137
|
+
body: JSON.stringify({ reason: reason.slice(0, 128) }),
|
|
138
|
+
});
|
|
139
|
+
if (res.ok) return;
|
|
140
|
+
return fail(res, "activate subscription");
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
/**
|
|
130
144
|
* Refunds part of a subscription payment. Subscription payments are sales,
|
|
131
145
|
* refunded through /v1/payments/sale/{id}/refund (v2 only refunds captures).
|
|
@@ -45,6 +45,15 @@ export function payPalCredentials(env: PayPalEnv): Credentials | null {
|
|
|
45
45
|
return { clientId, clientSecret, apiBase: sandbox ? SANDBOX_BASE : LIVE_BASE };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* PayPal's "Automatic payments" page, where a subscriber changes the card or
|
|
50
|
+
* bank a subscription charges. There's no API for it: the subscriber does it
|
|
51
|
+
* signed in to PayPal, in the same environment the subscription lives in.
|
|
52
|
+
*/
|
|
53
|
+
export function payPalAutopayUrl(env: PayPalEnv): string {
|
|
54
|
+
return env.PAYPAL_ENV === "sandbox" ? "https://www.sandbox.paypal.com/myaccount/autopay/" : "https://www.paypal.com/myaccount/autopay/";
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
export function isPayPalConfigured(env: PayPalEnv): boolean {
|
|
49
58
|
return payPalCredentials(env) !== null;
|
|
50
59
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import type { BillingInterval } from "../../../billing/plan-catalog.js";
|
|
4
4
|
import type { Env } from "../../core.js";
|
|
5
5
|
import { registerBillingProvider, toCents, fromCents, type BillingProvider, type PaidPlan, type ProviderSubscription } from "../provider.js";
|
|
6
|
-
import { isPayPalConfigured, type PayPalEnv } from "./client.js";
|
|
6
|
+
import { isPayPalConfigured, payPalAutopayUrl, type PayPalEnv } from "./client.js";
|
|
7
7
|
import {
|
|
8
|
+
activatePayPalSubscription,
|
|
8
9
|
cancelPayPalSubscription,
|
|
9
10
|
createPayPalSubscription,
|
|
10
11
|
getPayPalInvoiceStatus,
|
|
@@ -105,6 +106,14 @@ export const payPalProvider: BillingProvider = {
|
|
|
105
106
|
return cancelPayPalSubscription(payPal(env), id, reason);
|
|
106
107
|
},
|
|
107
108
|
|
|
109
|
+
reactivateSubscription(env, id, reason) {
|
|
110
|
+
return activatePayPalSubscription(payPal(env), id, reason);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
manageUrl(env) {
|
|
114
|
+
return payPalAutopayUrl(payPal(env));
|
|
115
|
+
},
|
|
116
|
+
|
|
108
117
|
async refund(env, payment, amountCents, requestId, description) {
|
|
109
118
|
const result = await refundPayPalSale(payPal(env), payment.reference, { total: fromCents(amountCents), currency: payment.currency }, requestId, description);
|
|
110
119
|
return { reference: result.id };
|
|
@@ -3,7 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
import type { Env } from "../core.js";
|
|
4
4
|
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
5
5
|
import { recordUsage } from "./ledger.js";
|
|
6
|
-
import { changeKind, closeUsagePeriods, chaseOverdueInvoices, syncWorkspaceBilling, CANCEL_REASON, SCHEDULED_REASON, UPGRADE_REASON } from "./subscriptions.js";
|
|
6
|
+
import { changeKind, closeUsagePeriods, chaseOverdueInvoices, syncWorkspaceBilling, CANCEL_REASON, RESUME_REASON, SCHEDULED_REASON, UPGRADE_REASON } from "./subscriptions.js";
|
|
7
7
|
import { freePlan } from "./entitlements.js";
|
|
8
8
|
import { proRataCents, toCents } from "./provider.js";
|
|
9
9
|
import { canSpend } from "./ledger.js";
|
|
@@ -46,6 +46,16 @@ function fakePayPal() {
|
|
|
46
46
|
}
|
|
47
47
|
let match = path.match(/^\/v1\/billing\/subscriptions\/([^/]+)\/transactions$/);
|
|
48
48
|
if (match) return Response.json({ transactions: (state.sales.get(match[1]) ?? []).map((sale) => ({ ...sale, amount_with_breakdown: { gross_amount: { value: sale.amount, currency_code: "USD" } } })) });
|
|
49
|
+
match = path.match(/^\/v1\/billing\/subscriptions\/([^/]+)\/activate$/);
|
|
50
|
+
if (match) {
|
|
51
|
+
// Like PayPal: only a SUSPENDED subscription can be activated again.
|
|
52
|
+
const sub = state.subscriptions.get(match[1]);
|
|
53
|
+
if (!sub || sub.status !== "SUSPENDED") {
|
|
54
|
+
return Response.json({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "SUBSCRIPTION_STATUS_INVALID" }] }, { status: 422 });
|
|
55
|
+
}
|
|
56
|
+
Object.assign(sub, { status: "ACTIVE", status_change_note: body.reason });
|
|
57
|
+
return new Response(null, { status: 204 });
|
|
58
|
+
}
|
|
49
59
|
match = path.match(/^\/v1\/billing\/subscriptions\/([^/]+)\/cancel$/);
|
|
50
60
|
if (match) {
|
|
51
61
|
const sub = state.subscriptions.get(match[1]);
|
|
@@ -464,3 +474,100 @@ test("the billing contact is saved for receipts and validated", async () => {
|
|
|
464
474
|
assert.equal(receipt.json.invoice.billingName, "Acme Ltd");
|
|
465
475
|
assert.equal(receipt.json.invoice.kind, "subscription");
|
|
466
476
|
});
|
|
477
|
+
|
|
478
|
+
// --- Keeping a plan, and changing how it's paid -------------------------------------------------
|
|
479
|
+
|
|
480
|
+
test("keeping a cancelled plan: PayPal can't restart it, so a new subscription starts when the period ends", async () => {
|
|
481
|
+
const paypal = fakePayPal();
|
|
482
|
+
const { env, db, workspaceId, owner, subscriptionId } = await activeTeam(paypal);
|
|
483
|
+
const resume = () => call(env, "POST", `/api/workspaces/${workspaceId}/billing/resume`, { as: owner });
|
|
484
|
+
assert.equal((await resume()).json.code, "not_cancelled", "a renewing plan has nothing to undo");
|
|
485
|
+
|
|
486
|
+
assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/billing/cancel`, { as: owner })).status, 200);
|
|
487
|
+
assert.equal(paypal.state.subscriptions.get(subscriptionId)!.status, "CANCELLED", "cancelling ends the PayPal subscription right away");
|
|
488
|
+
const periodEnd = (db.db.prepare("SELECT current_period_end AS end FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any).end;
|
|
489
|
+
|
|
490
|
+
const kept = await resume();
|
|
491
|
+
assert.equal(kept.status, 200, JSON.stringify(kept.json));
|
|
492
|
+
assert.equal(kept.json.status, "checkout_required");
|
|
493
|
+
assert.match(kept.json.message, /can't be restarted/);
|
|
494
|
+
assert.equal(kept.json.effective, "period_end");
|
|
495
|
+
assert.equal(paypal.state.calls.filter((c) => c.path.endsWith("/activate")).length, 0, "a cancelled subscription isn't activated");
|
|
496
|
+
const newSub = kept.json.approveUrl.split("/").pop();
|
|
497
|
+
assert.notEqual(newSub, subscriptionId);
|
|
498
|
+
assert.equal(paypal.state.subscriptions.get(newSub)!.plan_id, "P-TEAM-M", "the same plan and interval");
|
|
499
|
+
assert.equal(paypal.state.subscriptions.get(newSub)!.start_time, new Date(Date.parse(periodEnd)).toISOString(), "starts when the paid period ends: no double charge");
|
|
500
|
+
|
|
501
|
+
paypal.approve(newSub);
|
|
502
|
+
const done = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/checkout/${kept.json.checkoutId}/complete`, { as: owner });
|
|
503
|
+
assert.equal(done.json.status, "scheduled");
|
|
504
|
+
const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
|
|
505
|
+
assert.deepEqual(billing.json.subscription.changesTo, { plan: "team", interval: "month" });
|
|
506
|
+
const audit = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
|
|
507
|
+
assert.ok(audit.json.entries.some((entry: { action: string }) => entry.action === "billing.resume_needs_checkout"));
|
|
508
|
+
|
|
509
|
+
// The period ends and the new subscription takes its first payment: still Team, never Free.
|
|
510
|
+
const after = new Date(Date.parse(periodEnd) + 60_000);
|
|
511
|
+
paypal.paySale(newSub, after.toISOString());
|
|
512
|
+
await syncWorkspaceBilling(env, workspaceId, after);
|
|
513
|
+
const plan = db.db.prepare("SELECT plan, status, provider_subscription_id AS sub, cancel_at_period_end AS cancelling FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any;
|
|
514
|
+
assert.deepEqual({ ...plan }, { plan: "team", status: "active", sub: newSub, cancelling: 0 });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("keeping a plan whose PayPal subscription is only paused activates it again", async () => {
|
|
518
|
+
const paypal = fakePayPal();
|
|
519
|
+
const { env, db, workspaceId, owner, subscriptionId, email } = await activeTeam(paypal);
|
|
520
|
+
// Cancelled on Bugmole while PayPal had only suspended it (a failed renewal).
|
|
521
|
+
db.db.prepare("UPDATE workspace_plans SET cancel_at_period_end = 1, pending_plan = 'free' WHERE workspace_id = ?").run(workspaceId);
|
|
522
|
+
paypal.state.subscriptions.get(subscriptionId)!.status = "SUSPENDED";
|
|
523
|
+
|
|
524
|
+
await inviteMember(env, owner, workspaceId);
|
|
525
|
+
assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/billing/resume`, { as: "user_dev" })).status, 403, "members can't change billing");
|
|
526
|
+
|
|
527
|
+
const kept = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/resume`, { as: owner });
|
|
528
|
+
assert.equal(kept.status, 200, JSON.stringify(kept.json));
|
|
529
|
+
assert.equal(kept.json.status, "resumed");
|
|
530
|
+
assert.equal(paypal.state.subscriptions.get(subscriptionId)!.status, "ACTIVE");
|
|
531
|
+
assert.equal(paypal.state.subscriptions.get(subscriptionId)!.status_change_note, RESUME_REASON);
|
|
532
|
+
const row = db.db.prepare("SELECT cancel_at_period_end AS cancelling, pending_plan AS pending FROM workspace_plans WHERE workspace_id = ?").get(workspaceId) as any;
|
|
533
|
+
assert.deepEqual({ ...row }, { cancelling: 0, pending: null });
|
|
534
|
+
const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
|
|
535
|
+
assert.equal(billing.json.subscription.renews, true);
|
|
536
|
+
assert.ok(email.sent.some((message) => /will renew/.test(message.subject)));
|
|
537
|
+
const audit = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
|
|
538
|
+
assert.ok(audit.json.entries.some((entry: { action: string }) => entry.action === "billing.resumed"));
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("a plan can't be kept once its paid period has ended", async () => {
|
|
542
|
+
const paypal = fakePayPal();
|
|
543
|
+
const { env, db, workspaceId, owner } = await activeTeam(paypal);
|
|
544
|
+
await call(env, "POST", `/api/workspaces/${workspaceId}/billing/cancel`, { as: owner });
|
|
545
|
+
db.db.prepare("UPDATE workspace_plans SET current_period_end = ? WHERE workspace_id = ?").run(new Date(Date.now() - 1000).toISOString(), workspaceId);
|
|
546
|
+
const late = await call(env, "POST", `/api/workspaces/${workspaceId}/billing/resume`, { as: owner });
|
|
547
|
+
assert.equal(late.status, 409);
|
|
548
|
+
assert.equal(late.json.code, "period_ended");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("billing links to PayPal's automatic payments page for the environment in use, and the failed-payment email does too", async () => {
|
|
552
|
+
const paypal = fakePayPal();
|
|
553
|
+
const { env, workspaceId, owner, subscriptionId, email } = await activeTeam(paypal);
|
|
554
|
+
const billing = await call(env, "GET", `/api/workspaces/${workspaceId}/billing`, { as: owner });
|
|
555
|
+
assert.deepEqual(billing.json.paymentProvider, { id: "paypal", manageUrl: "https://www.paypal.com/myaccount/autopay/" });
|
|
556
|
+
|
|
557
|
+
paypal.state.subscriptions.get(subscriptionId)!.status = "SUSPENDED";
|
|
558
|
+
await syncWorkspaceBilling(env, workspaceId);
|
|
559
|
+
const failed = email.sent.find((message) => /didn't go through/.test(message.subject))!;
|
|
560
|
+
assert.match(failed.text, /https:\/\/www\.paypal\.com\/myaccount\/autopay\//);
|
|
561
|
+
assert.match(failed.text, new RegExp(`dash\\.test/billing\\?workspace=${workspaceId}`));
|
|
562
|
+
|
|
563
|
+
const sandbox = billingEnv(fakePayPal(), { PAYPAL_ENV: "sandbox", PAYPAL_SANDBOX_CLIENT_ID: "c", PAYPAL_SANDBOX_CLIENT_SECRET: "s" });
|
|
564
|
+
const other = await seedProject(sandbox.env);
|
|
565
|
+
const view = await call(sandbox.env, "GET", `/api/workspaces/${other.workspaceId}/billing`, { as: other.owner });
|
|
566
|
+
assert.equal(view.json.paymentProvider.manageUrl, "https://www.sandbox.paypal.com/myaccount/autopay/");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
async function inviteMember(env: Env, owner: string, workspaceId: string) {
|
|
570
|
+
const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: owner, body: { email: "dev@acme.test", role: "member" } });
|
|
571
|
+
const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
|
|
572
|
+
await call(env, "POST", `/api/invitations/${token}/accept`, { as: "user_dev", headers: { "x-aegis-email": "dev@acme.test", "x-aegis-email-verified": "true" } });
|
|
573
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Plan limits on every way in (dashboard, API key, CLI device pairing),
|
|
2
|
+
// read-only projects an owner can choose between, and run history that
|
|
3
|
+
// follows the plan everywhere the dashboard reads it.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { entitlementsFor } from "../../billing/plan-catalog.js";
|
|
7
|
+
import type { Env } from "../core.js";
|
|
8
|
+
import { enforceRetention } from "../jobs/retention.js";
|
|
9
|
+
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
10
|
+
import { planForProjects } from "./enforcement.js";
|
|
11
|
+
import { applyProjectLimit } from "./subscriptions.js";
|
|
12
|
+
|
|
13
|
+
const DAY = 86_400_000;
|
|
14
|
+
|
|
15
|
+
async function startDevice(env: Env, projectId: string, projectName = projectId) {
|
|
16
|
+
const start = await call(env, "POST", "/api/device-authorizations", {
|
|
17
|
+
body: { projectId, projectName, environment: "staging", storageProvider: "r2" },
|
|
18
|
+
});
|
|
19
|
+
assert.equal(start.status, 201, JSON.stringify(start.json));
|
|
20
|
+
return start.json as { attemptId: string; code: string; pollSecret: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function claimDevice(env: Env, who: string, projectId: string) {
|
|
24
|
+
const attempt = await startDevice(env, projectId);
|
|
25
|
+
const claimed = await call(env, "POST", "/api/device-authorizations/claim", {
|
|
26
|
+
as: who,
|
|
27
|
+
headers: { "x-aegis-email": `${who}@acme.test`, "x-aegis-email-verified": "true" },
|
|
28
|
+
body: { attemptId: attempt.attemptId, code: attempt.code, deviceId: projectId },
|
|
29
|
+
});
|
|
30
|
+
const poll = () => call(env, "POST", "/api/device-authorizations/poll", { body: { attemptId: attempt.attemptId, pollSecret: attempt.pollSecret } });
|
|
31
|
+
return { claimed, poll };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("the smallest plan with room for more projects", () => {
|
|
35
|
+
assert.equal(planForProjects(1), "team");
|
|
36
|
+
assert.equal(planForProjects(10), "business");
|
|
37
|
+
assert.equal(planForProjects(50), "enterprise");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("creating a project over the limit names the plan that allows more, with a billing link", async () => {
|
|
41
|
+
const { env } = createTestEnv({ FEATURE_ENTITLEMENTS: "on", DASHBOARD_URL: "https://dash.test" });
|
|
42
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
43
|
+
const second = await call(env, "POST", "/api/projects/register", {
|
|
44
|
+
as: owner, body: { id: "second", name: "Second", workspaceId, environment: "staging", storageProvider: "r2" },
|
|
45
|
+
});
|
|
46
|
+
assert.equal(second.status, 402);
|
|
47
|
+
assert.equal(second.json.code, "plan_limit");
|
|
48
|
+
assert.equal(second.json.suggestedPlan, "team");
|
|
49
|
+
assert.equal(second.json.upgradeUrl, `/billing?workspace=${workspaceId}&plan=team`);
|
|
50
|
+
assert.equal(second.json.billingUrl, `https://dash.test/billing?workspace=${workspaceId}&plan=team`);
|
|
51
|
+
assert.match(second.json.error, /includes 1 project and this workspace has 1\. Upgrade to Team/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("CLI pairing can't create a second Free workspace or a project past the plan's limit", async () => {
|
|
55
|
+
const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on", DASHBOARD_URL: "https://dash.test" });
|
|
56
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
57
|
+
|
|
58
|
+
const { claimed, poll } = await claimDevice(env, owner, "cli-app");
|
|
59
|
+
assert.equal(claimed.status, 402, JSON.stringify(claimed.json));
|
|
60
|
+
assert.equal(claimed.json.code, "plan_limit");
|
|
61
|
+
assert.equal(claimed.json.billingUrl, `https://dash.test/billing?workspace=${workspaceId}&plan=team`);
|
|
62
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM projects WHERE id = 'cli-app'").get()?.c, 0, "no project was created");
|
|
63
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM workspaces WHERE id = ?").get(`workspace_${owner}`)?.c, 0, "no second Free workspace");
|
|
64
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM api_keys").get()?.c, 0, "no machine key");
|
|
65
|
+
|
|
66
|
+
// The waiting CLI hears why, instead of polling until the attempt expires.
|
|
67
|
+
const polled = await poll();
|
|
68
|
+
assert.equal(polled.status, 402);
|
|
69
|
+
assert.equal(polled.json.status, "refused");
|
|
70
|
+
assert.equal(polled.json.code, "plan_limit");
|
|
71
|
+
assert.equal(polled.json.billingUrl, claimed.json.billingUrl);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("CLI pairing puts the project in the person's Free workspace when there's room, and re-pairing isn't a new project", async () => {
|
|
75
|
+
const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
|
|
76
|
+
assert.equal((await call(env, "POST", "/api/workspaces", { as: "user_new", body: { id: "solo", name: "Solo" } })).status, 201);
|
|
77
|
+
const first = await claimDevice(env, "user_new", "cli-app");
|
|
78
|
+
assert.equal(first.claimed.status, 200, JSON.stringify(first.claimed.json));
|
|
79
|
+
assert.equal((db.db.prepare("SELECT workspace_id AS w FROM projects WHERE id = 'cli-app'").get() as any).w, "solo");
|
|
80
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM workspaces WHERE id = 'workspace_user_new'").get()?.c, 0);
|
|
81
|
+
assert.equal((await first.poll()).json.status, "completed");
|
|
82
|
+
|
|
83
|
+
const again = await claimDevice(env, "user_new", "cli-app");
|
|
84
|
+
assert.equal(again.claimed.status, 200, "the project already exists, so the limit doesn't apply");
|
|
85
|
+
|
|
86
|
+
// Someone who doesn't manage a project can't pair a machine to it.
|
|
87
|
+
const stranger = await claimDevice(env, "user_stranger", "cli-app");
|
|
88
|
+
assert.equal(stranger.claimed.status, 409);
|
|
89
|
+
assert.equal(stranger.claimed.json.code, "project_exists");
|
|
90
|
+
assert.equal((db.db.prepare("SELECT workspace_id AS w FROM projects WHERE id = 'cli-app'").get() as any).w, "solo", "not moved");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("CLI pairing for someone new still creates their workspace and project", async () => {
|
|
94
|
+
const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
|
|
95
|
+
const { claimed } = await claimDevice(env, "user_fresh", "first-app");
|
|
96
|
+
assert.equal(claimed.status, 200, JSON.stringify(claimed.json));
|
|
97
|
+
assert.equal((db.db.prepare("SELECT workspace_id AS w FROM projects WHERE id = 'first-app'").get() as any).w, "workspace_user_fresh");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function overLimitWorkspace() {
|
|
101
|
+
const setup = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
|
|
102
|
+
const { env, db } = setup;
|
|
103
|
+
const project = await seedProject(env);
|
|
104
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'team')").run(project.workspaceId);
|
|
105
|
+
for (const id of ["second", "third"]) {
|
|
106
|
+
const created = await call(env, "POST", "/api/projects/register", { as: project.owner, body: { id, name: id, workspaceId: project.workspaceId, environment: "staging", storageProvider: "r2" } });
|
|
107
|
+
assert.equal(created.status, 201);
|
|
108
|
+
}
|
|
109
|
+
// Back on Free (as moveToFree does): the newest two become read-only.
|
|
110
|
+
db.db.prepare("UPDATE workspace_plans SET status = 'canceled' WHERE workspace_id = ?").run(project.workspaceId);
|
|
111
|
+
await applyProjectLimit(env, project.workspaceId, entitlementsFor("free"));
|
|
112
|
+
return { ...setup, ...project };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lockedIds = (db: ReturnType<typeof createTestEnv>["db"], workspaceId: string) =>
|
|
116
|
+
(db.db.prepare("SELECT id FROM projects WHERE workspace_id = ? AND locked_at IS NOT NULL ORDER BY id").all(workspaceId) as Array<{ id: string }>).map((row) => row.id);
|
|
117
|
+
|
|
118
|
+
test("an owner chooses which projects stay active within the plan's limit, and the choice survives plan changes", async () => {
|
|
119
|
+
const { env, db, workspaceId, owner } = await overLimitWorkspace();
|
|
120
|
+
assert.deepEqual(lockedIds(db, workspaceId), ["second", "third"]);
|
|
121
|
+
const listed = await call(env, "GET", "/api/workspaces", { as: owner });
|
|
122
|
+
const projects = listed.json.workspaces.find((w: { id: string }) => w.id === workspaceId).projects;
|
|
123
|
+
assert.ok(projects.find((p: { id: string; lockedAt: string | null }) => p.id === "third").lockedAt, "the workspace list shows read-only projects");
|
|
124
|
+
|
|
125
|
+
const choose = (projectIds: unknown, as = owner) => call(env, "PUT", `/api/workspaces/${workspaceId}/projects/active`, { as, body: { projectIds } });
|
|
126
|
+
assert.equal((await choose(["shop", "third"])).json.code, "over_limit");
|
|
127
|
+
assert.equal((await choose(["elsewhere"])).json.code, "unknown_project");
|
|
128
|
+
assert.equal((await choose([])).status, 422);
|
|
129
|
+
assert.equal((await choose("third")).status, 422);
|
|
130
|
+
assert.equal((await choose(["third"], "user_stranger")).status, 403);
|
|
131
|
+
|
|
132
|
+
const kept = await choose(["third"]);
|
|
133
|
+
assert.equal(kept.status, 200, JSON.stringify(kept.json));
|
|
134
|
+
assert.deepEqual(kept.json, { active: ["third"], locked: ["shop", "second"] });
|
|
135
|
+
assert.deepEqual(lockedIds(db, workspaceId), ["second", "shop"]);
|
|
136
|
+
const audit = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
|
|
137
|
+
assert.ok(audit.json.entries.some((entry: { action: string }) => entry.action === "billing.projects_chosen"));
|
|
138
|
+
|
|
139
|
+
// Upgrading unlocks everything; the next downgrade keeps the owner's pick.
|
|
140
|
+
await applyProjectLimit(env, workspaceId, entitlementsFor("team"));
|
|
141
|
+
assert.deepEqual(lockedIds(db, workspaceId), []);
|
|
142
|
+
await applyProjectLimit(env, workspaceId, entitlementsFor("free"));
|
|
143
|
+
assert.deepEqual(lockedIds(db, workspaceId), ["second", "shop"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("re-pairing a read-only project explains the limit instead of claiming it exists", async () => {
|
|
147
|
+
const { env, owner } = await overLimitWorkspace();
|
|
148
|
+
const { claimed } = await claimDevice(env, owner, "third");
|
|
149
|
+
assert.equal(claimed.status, 402);
|
|
150
|
+
assert.equal(claimed.json.code, "plan_limit");
|
|
151
|
+
assert.match(claimed.json.error, /read-only/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
async function historySetup(mode: string) {
|
|
155
|
+
const setup = createTestEnv({ FEATURE_ENTITLEMENTS: mode });
|
|
156
|
+
const { env, db, artifacts } = setup;
|
|
157
|
+
const project = await seedProject(env);
|
|
158
|
+
await seedPlan(env, project.projectId, project.owner);
|
|
159
|
+
const make = async (key: string) =>
|
|
160
|
+
(await call(env, "POST", `/api/projects/${project.projectId}/runs`, { as: project.owner, body: { planId: "checkout", idempotencyKey: key } })).json.run.id as string;
|
|
161
|
+
const oldRun = await make("old");
|
|
162
|
+
const newRun = await make("new");
|
|
163
|
+
const oldDate = new Date(Date.now() - 10 * DAY).toISOString();
|
|
164
|
+
db.db.prepare("UPDATE runs SET status = 'success', completed_at = ?, requested_at = ? WHERE id = ?").run(oldDate, oldDate, oldRun);
|
|
165
|
+
db.db.prepare("UPDATE runs SET status = 'success', completed_at = ? WHERE id = ?").run(new Date().toISOString(), newRun);
|
|
166
|
+
await artifacts.put(`projects/${project.projectId}/runs/${oldRun}/manifest.json`, "{}");
|
|
167
|
+
await artifacts.put(`projects/${project.projectId}/runs/${newRun}/manifest.json`, "{}");
|
|
168
|
+
await artifacts.put(`projects/${project.projectId}/journeys/graph.json`, "{}");
|
|
169
|
+
return { ...setup, ...project, oldRun, newRun };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test("the plan's history cutoff applies to a run, its events and its evidence, not only the runs list", async () => {
|
|
173
|
+
const { env, projectId, owner, oldRun, newRun } = await historySetup("on");
|
|
174
|
+
const old = await call(env, "GET", `/api/runs/${oldRun}`, { as: owner });
|
|
175
|
+
assert.equal(old.status, 404);
|
|
176
|
+
assert.equal(old.json.code, "history_expired");
|
|
177
|
+
assert.equal((await call(env, "GET", `/api/runs/${newRun}`, { as: owner })).status, 200);
|
|
178
|
+
|
|
179
|
+
const listed = await call(env, "GET", `/api/projects/${projectId}/artifacts`, { as: owner });
|
|
180
|
+
const keys = listed.json.objects.map((object: { key: string }) => object.key).sort();
|
|
181
|
+
assert.deepEqual(keys, [`projects/${projectId}/journeys/graph.json`, `projects/${projectId}/runs/${newRun}/manifest.json`], "run evidence past the cutoff is hidden; other project files aren't run history");
|
|
182
|
+
const object = (runId: string) => call(env, "GET", `/api/projects/${projectId}/artifacts/object?key=${encodeURIComponent(`projects/${projectId}/runs/${runId}/manifest.json`)}`, { as: owner });
|
|
183
|
+
assert.equal((await object(oldRun)).status, 404);
|
|
184
|
+
assert.equal((await object(newRun)).status, 200);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("in shadow mode the cutoff hides nothing and retention only logs what it would delete", async () => {
|
|
188
|
+
const { env, db, artifacts, projectId, owner, oldRun } = await historySetup("shadow");
|
|
189
|
+
assert.equal((await call(env, "GET", `/api/runs/${oldRun}`, { as: owner })).status, 200);
|
|
190
|
+
const listed = await call(env, "GET", `/api/projects/${projectId}/runs`, { as: owner });
|
|
191
|
+
assert.equal(listed.json.runs.length, 2);
|
|
192
|
+
|
|
193
|
+
const warnings: string[] = [];
|
|
194
|
+
const warn = console.warn;
|
|
195
|
+
console.warn = (message: string) => void warnings.push(String(message));
|
|
196
|
+
try {
|
|
197
|
+
assert.deepEqual(await enforceRetention(env), { runs: 0, objects: 0, wouldDelete: 1 });
|
|
198
|
+
} finally {
|
|
199
|
+
console.warn = warn;
|
|
200
|
+
}
|
|
201
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM runs WHERE id = ?").get(oldRun)?.c, 1, "nothing deleted");
|
|
202
|
+
assert.ok(artifacts.objects.has(`projects/${projectId}/runs/${oldRun}/manifest.json`));
|
|
203
|
+
assert.ok(warnings.some((line) => /shadow: retention would delete 1 run/.test(line)));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("with limits off, retention deletes nothing", async () => {
|
|
207
|
+
const { env, db, oldRun } = await historySetup("off");
|
|
208
|
+
const warn = console.warn;
|
|
209
|
+
console.warn = () => undefined;
|
|
210
|
+
try {
|
|
211
|
+
assert.equal((await enforceRetention(env)).runs, 0);
|
|
212
|
+
} finally {
|
|
213
|
+
console.warn = warn;
|
|
214
|
+
}
|
|
215
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM runs WHERE id = ?").get(oldRun)?.c, 1);
|
|
216
|
+
});
|
|
@@ -54,6 +54,14 @@ export interface BillingProvider {
|
|
|
54
54
|
/** The payment for the current period (made on or after `since`), once the provider has posted it. */
|
|
55
55
|
latestPayment(env: Env, subscriptionId: string, since: Date, until: Date): Promise<ProviderPayment | null>;
|
|
56
56
|
cancelSubscription(env: Env, id: string, reason: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Restarts a subscription that was paused (a failed renewal), when the
|
|
59
|
+
* provider allows it. A cancelled subscription can't be restarted at PayPal;
|
|
60
|
+
* callers check the status first and send the customer to a new checkout.
|
|
61
|
+
*/
|
|
62
|
+
reactivateSubscription(env: Env, id: string, reason: string): Promise<void>;
|
|
63
|
+
/** Where the customer changes the card or bank behind their subscription, on the provider's site. */
|
|
64
|
+
manageUrl(env: Env): string | null;
|
|
57
65
|
refund(env: Env, payment: ProviderPayment, amountCents: number, requestId: string, description: string): Promise<{ reference: string }>;
|
|
58
66
|
/** Bills usage after the month; the payer receives the provider's pay link. */
|
|
59
67
|
chargeUsage(env: Env, params: {
|
|
@@ -12,7 +12,8 @@ async function getUsage({ env, principal }: AuthedRouteContext, workspaceId: str
|
|
|
12
12
|
if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
13
13
|
const { entitlements, summary } = await usageSummary(env, workspaceId);
|
|
14
14
|
const { plan, ...limits } = entitlements;
|
|
15
|
-
|
|
15
|
+
// Paused (inactive) projects don't count toward the limit.
|
|
16
|
+
const projects = await env.DB.prepare("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ? AND deleted_at IS NULL AND inactive_at IS NULL")
|
|
16
17
|
.bind(workspaceId).first<{ count: number }>();
|
|
17
18
|
return response({
|
|
18
19
|
plan: {
|