| 72 | 72 | | pull: { status: "open", headCommit: SHA, branch: "feature" as string | null, fork: null as null | { namespace: string; name: string } }, |
| 73 | 73 | | collaborator: "write", |
| 74 | 74 | | plan: true, |
| 75 | + | limit: "ok", |
| 75 | 76 | | /** What other services were asked, as `SERVICE method`. */ |
| 76 | 77 | | calls: [] as string[], |
| 77 | 78 | | /** What `resolve_settings` was asked for. */ |
| ⋯ |
| 113 | 114 | | }), |
| 114 | 115 | | BILLING: service("BILLING", (method) => { |
| 115 | 116 | | if (method === "has_feature") return state.plan ? ok(true) : { ok: false, error: { code: "payment_required", message: "Deployments need the Team plan." } }; |
| 116 | | − | if (method === "check_limit") return ok({ state: "ok" }); |
| 117 | + | if (method === "check_limit") return ok({ state: state.limit, message: state.limit === "stopped" ? "The workspace reached its spend limit." : null }); |
| 117 | 118 | | if (method === "prices") return { prices: [] }; |
| 118 | 119 | | return ok(null); |
| 119 | 120 | | }), |
| ⋯ |
| 226 | 227 | | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, false); |
| 227 | 228 | | }); |
| 228 | 229 | | |
| 230 | + | test("a sandbox's build is still charged by the second; a workflow job's never is", async () => { |
| 231 | + | // A classic build: what a push opens, built by g1t's sandbox. |
| 232 | + | const { createHash } = await import("node:crypto"); |
| 233 | + | const token = "f".repeat(64); |
| 234 | + | w.state.sqlite |
| 235 | + | .prepare( |
| 236 | + | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status, |
| 237 | + | token_hash, trusted, created_by, created_at) |
| 238 | + | VALUES ('dpl_sandbox', ?, 'acme', 'web', ?, 'acme/web', 'preview', 'other', NULL, ?, 'web-git-other-acme', 'building', ?, 1, 'g1t', '2026-10-12T00:00:00Z')`, |
| 239 | + | ) |
| 240 | + | .run(PROJECT, REPO, SHA, createHash("sha256").update(token).digest("hex")); |
| 241 | + | const finish = { worker: { compatibilityDate: "2026-09-26", vars: {} }, completionJwt: "done", buildSeconds: 30 }; |
| 242 | + | assert.equal((await post("/jobs/dpl_sandbox/finish", { token, ...finish })).ok, true); |
| 243 | + | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, 1); |
| 244 | + | assert.deepEqual(rows("SELECT build_seconds FROM meters"), [{ build_seconds: 30 }]); |
| 245 | + | // A workflow job's build of the same project, finished and then failed: not charged. |
| 246 | + | const { value: done } = await open(); |
| 247 | + | assert.equal((await post(`/jobs/${done.deploy_id}/finish`, { token: done.token, ...finish })).ok, true); |
| 248 | + | const { value: failed } = await open(); |
| 249 | + | await post(`/jobs/${failed.deploy_id}/fail`, { token: failed.token, message: "npm run build failed", buildSeconds: 99 }); |
| 250 | + | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, 1); |
| 251 | + | assert.deepEqual(rows("SELECT build_seconds FROM meters"), [{ build_seconds: 30 }]); |
| 252 | + | // What runs after the build is the same app as any: in apps, which the sweep meters. |
| 253 | + | assert.deepEqual(rows("SELECT script, kind FROM apps ORDER BY script"), [ |
| 254 | + | { script: "web-acme", kind: "production" }, |
| 255 | + | { script: "web-git-other-acme", kind: "preview" }, |
| 256 | + | ]); |
| 257 | + | }); |
| 258 | + | |
| 229 | 259 | | test("another branch builds its preview; a pull request's run builds the pull request's", async () => { |
| 230 | 260 | | w.state.run = { ...w.state.run, ref: "refs/heads/feature/x" }; |
| 231 | 261 | | const branch = (await open()).value; |
| ⋯ |
| 265 | 295 | | assert.deepEqual([unpaid.ok, unpaid.error.code], [false, "payment_required"]); |
| 266 | 296 | | assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "skipped" }]); |
| 267 | 297 | | w.state.plan = true; |
| 298 | + | w.state.limit = "stopped"; |
| 299 | + | const stopped = await open(); |
| 300 | + | assert.deepEqual([stopped.ok, stopped.error.code, stopped.error.message], [false, "payment_required", "The workspace reached its spend limit."]); |
| 301 | + | w.state.limit = "ok"; |
| 268 | 302 | | w.state.sqlite.prepare("UPDATE settings SET previews = 0").run(); |
| 269 | 303 | | w.state.run = { ...w.state.run, ref: "refs/heads/feature" }; |
| 270 | 304 | | assert.equal(await refused({}), "conflict"); |