@checkbox-site/cli 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -3
  2. package/dist/checkbox.js +949 -301
  3. package/package.json +17 -12
package/dist/checkbox.js CHANGED
@@ -123,7 +123,37 @@ var init_args = __esm({
123
123
  // src/config.ts
124
124
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
125
125
  import { homedir } from "node:os";
126
- import { join } from "node:path";
126
+ import { dirname, join, resolve } from "node:path";
127
+ function readEnvValue(text, name) {
128
+ const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=[ \\t]*(?:"([^"\\r\\n]*)"|'([^'\\r\\n]*)'|([^\\s#]*))[ \\t]*(?:#.*)?$`, "gm");
129
+ let found;
130
+ for (let m = re.exec(text); m; m = re.exec(text)) found = (m[1] ?? m[2] ?? m[3] ?? "").trim();
131
+ return found || void 0;
132
+ }
133
+ function findProjectKey(cwd, env2 = {}) {
134
+ const home = resolve(env2.HOME || homedir());
135
+ let dir = resolve(cwd);
136
+ for (let depth = 0; depth < 8; depth++) {
137
+ for (const f of PROJECT_KEY_FILES) {
138
+ let text;
139
+ try {
140
+ text = readFileSync(join(dir, f), "utf8");
141
+ } catch {
142
+ continue;
143
+ }
144
+ const v = readEnvValue(text, "CHECKBOX_SECRET_KEY");
145
+ if (v && looksLikeApiKey(v)) {
146
+ const up = depth === 0 ? "" : "../".repeat(depth);
147
+ return { apiKey: v, file: `${up}${f}` };
148
+ }
149
+ }
150
+ if (existsSync(join(dir, ".git")) || dir === home) break;
151
+ const parent = dirname(dir);
152
+ if (parent === dir) break;
153
+ dir = parent;
154
+ }
155
+ return void 0;
156
+ }
127
157
  function configDir(env2) {
128
158
  if (env2.CHECKBOX_CONFIG_DIR) return env2.CHECKBOX_CONFIG_DIR;
129
159
  const base = env2.XDG_CONFIG_HOME || join(env2.HOME || homedir(), ".config");
@@ -167,27 +197,35 @@ function createConfigStore(env2) {
167
197
  }
168
198
  };
169
199
  }
170
- function resolveCredentials(env2, stored, flags) {
200
+ function resolveCredentials(env2, stored, flags, project) {
171
201
  const apiBase = (flags.apiBase || env2.CHECKBOX_API_BASE || stored.api_base || DEFAULT_API_BASE).replace(/\/+$/, "");
172
202
  if (flags.apiKey) return { apiKey: flags.apiKey, apiBase, source: "flag" };
173
203
  if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase, source: "env" };
174
204
  if (env2.CHECKBOX_SECRET_KEY) return { apiKey: env2.CHECKBOX_SECRET_KEY, apiBase, source: "env" };
205
+ if (project) return { apiKey: project.apiKey, apiBase, source: "project", file: project.file };
175
206
  if (stored.api_key) return { apiKey: stored.api_key, apiBase, source: "config" };
176
207
  return void 0;
177
208
  }
209
+ function describeKeySource(c, env2 = {}) {
210
+ if (c.source === "flag") return "--api-key";
211
+ if (c.source === "env") return env2.CHECKBOX_API_KEY ? "CHECKBOX_API_KEY" : "CHECKBOX_SECRET_KEY in the environment";
212
+ if (c.source === "project") return `this project's ${c.file ?? ".env.local"}`;
213
+ return "your saved login (checkbox login)";
214
+ }
178
215
  function maskKey(key) {
179
- const m = /^([a-z]{2}_(?:live|test)_)/.exec(key);
216
+ const m = /^([a-z]{2,3}_(?:live|test)_)/.exec(key);
180
217
  const prefix = m?.[1] ?? key.slice(0, 3);
181
218
  return `${prefix}\u2026${key.slice(-4)}`;
182
219
  }
183
220
  function looksLikeApiKey(key) {
184
221
  return /^(?:ckr|ck|rk|sk)_(?:live|test)_[A-Za-z0-9_-]{8,}$/.test(key.trim());
185
222
  }
186
- var DEFAULT_API_BASE;
223
+ var DEFAULT_API_BASE, PROJECT_KEY_FILES;
187
224
  var init_config = __esm({
188
225
  "src/config.ts"() {
189
226
  "use strict";
190
227
  DEFAULT_API_BASE = "https://app.checkbox.site";
228
+ PROJECT_KEY_FILES = [".env.local", ".env", ".checkbox/keys.env"];
191
229
  }
192
230
  });
193
231
 
@@ -2972,7 +3010,7 @@ var init_operations = __esm({
2972
3010
  /**
2973
3011
  * Check Stripe Checkout Session params.
2974
3012
  *
2975
- * Pass exactly the object your code gives stripe.checkout.sessions.create (plus merchant.merchant_name and merchant.cancel_url, and merchant.price/interval when line_items use a Price id). Checkbox models the Stripe-hosted page those params render and returns findings plus `correction`: a JSON Patch (RFC 6902) and the full corrected params (custom_text.submit with the renewal terms, consent_collection.terms_of_service = "required", custom_text.terms_of_service_acceptance as the consent label), verified by the engine. Stripe's own small grey styling keeps "clear and conspicuous" at needs_review on any Stripe-hosted page; also show the terms on your page before redirecting. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
3013
+ * Body: `{ params, merchant }` (or `{ params, site }`). `params` is exactly the object your code gives stripe.checkout.sessions.create; `merchant` adds merchant_name, cancel_url, and price/interval when line_items use a Price id. Checkbox models the Stripe-hosted page those params render and returns findings plus `correction`: a JSON Patch (RFC 6902) and the full corrected params (custom_text.submit with the renewal terms, consent_collection.terms_of_service = "required", custom_text.terms_of_service_acceptance as the consent label), verified by the engine. Stripe's own small grey styling keeps "clear and conspicuous" at needs_review on any Stripe-hosted page; also show the terms on your page before redirecting. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
2976
3014
  *
2977
3015
  * @see POST /v1/checks/stripe_checkout_session (operation `checkStripeCheckoutSession`, scope `checks:read`)
2978
3016
  */
@@ -3297,7 +3335,7 @@ var init_operations = __esm({
3297
3335
  /**
3298
3336
  * Register the Checkout Session params your code uses.
3299
3337
  *
3300
- * Registers exactly the object your code passes to stripe.checkout.sessions.create (customer, URLs and secrets are not needed and are ignored; `warnings` says what was not stored). It is judged exactly like checkStripeCheckoutSession, discounts included. A config with the same name, or for the same Price ids, replaces the older one (`replaced`). Test runs mirror it into Stripe test mode; when it matches your latest live checkout, the scanned test page counts as an observation of your live checkout. Re-register after changing your checkout code, then call createTestRun.
3338
+ * Registers exactly the object your code passes to stripe.checkout.sessions.create, as `params` (that object sent alone as the body is accepted too; customer, URLs and secrets are not needed and are ignored; `warnings` says what was not stored). It is judged exactly like checkStripeCheckoutSession, discounts included. A config with the same name, or for the same Price ids, replaces the older one (`replaced`). Test runs mirror it into Stripe test mode; when it matches your latest live checkout, the scanned test page counts as an observation of your live checkout. Re-register after changing your checkout code, then call createTestRun.
3301
3339
  *
3302
3340
  * @param site Unique identifier of the site (site_…).
3303
3341
  * @see POST /v1/sites/{site}/stripe_checkout_configs (operation `createStripeCheckoutConfig`, scope `test_runs:write`)
@@ -3365,7 +3403,7 @@ var init_operations = __esm({
3365
3403
  /**
3366
3404
  * Start a lifecycle test run.
3367
3405
  *
3368
- * Starts a synthetic test subscription in TEST mode and returns it queued. Stripe runs mirror your checkout into a test-mode Checkout Session, subscribe a test customer (card 4242) whose email is a Checkbox inbox, wait for your acknowledgment, advance a test clock through every legal notice window before trial end and renewal, cancel online in the customer portal and wait for the confirmation; then delete the test clock. Usually takes 2-4 minutes (longer when your own emails are slow to arrive): poll retrieveTestRun (next_action says when) or listen for test_run.completed. Each step reports observations, check results and agent-readable fixes; apply them and run again to verify.
3406
+ * Starts a synthetic test subscription in TEST mode and returns it queued. Stripe runs mirror your checkout into a test-mode Checkout Session, subscribe a test customer (card 4242) whose email is a Checkbox inbox, wait for your acknowledgment, advance a test clock through every legal notice window before trial end and renewal, cancel online in the customer portal and wait for the confirmation; then delete the test clock. Usually takes 1-2 minutes; a real_checkout run a few minutes (longer when your own emails are slow to arrive): poll retrieveTestRun (next_action says when) or listen for test_run.completed. Each step reports observations, check results and agent-readable fixes; apply them and run again to verify.
3369
3407
  *
3370
3408
  * @param site Unique identifier of the site (site_…).
3371
3409
  * @see POST /v1/sites/{site}/test_runs (operation `createTestRun`, scope `test_runs:write`, live mode needs an activated account)
@@ -3579,7 +3617,7 @@ var SDK_VERSION;
3579
3617
  var init_version = __esm({
3580
3618
  "../sdk-node/src/version.ts"() {
3581
3619
  "use strict";
3582
- SDK_VERSION = "0.2.2";
3620
+ SDK_VERSION = "0.2.4";
3583
3621
  }
3584
3622
  });
3585
3623
 
@@ -3768,9 +3806,9 @@ var init_client = __esm({
3768
3806
  const p = globalThis.process;
3769
3807
  return p?.env?.[name] || void 0;
3770
3808
  };
3771
- sleep = (ms, signal) => new Promise((resolve9, reject) => {
3809
+ sleep = (ms, signal) => new Promise((resolve10, reject) => {
3772
3810
  if (signal?.aborted) return reject(signal.reason);
3773
- const t = setTimeout(resolve9, ms);
3811
+ const t = setTimeout(resolve10, ms);
3774
3812
  signal?.addEventListener("abort", () => (clearTimeout(t), reject(signal.reason)), { once: true });
3775
3813
  });
3776
3814
  Checkbox = class {
@@ -4164,7 +4202,7 @@ var init_types2 = __esm({
4164
4202
 
4165
4203
  // src/run.ts
4166
4204
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
4167
- import { dirname, join as join2 } from "node:path";
4205
+ import { dirname as dirname2, join as join2 } from "node:path";
4168
4206
  import { fileURLToPath, pathToFileURL } from "node:url";
4169
4207
  function registerCommands(registry) {
4170
4208
  REGISTRY = registry;
@@ -4208,11 +4246,11 @@ async function listCommands(dir = COMMANDS_DIR) {
4208
4246
  }
4209
4247
  function readAll(stream) {
4210
4248
  if (!stream || stream.isTTY) return Promise.resolve("");
4211
- return new Promise((resolve9, reject) => {
4249
+ return new Promise((resolve10, reject) => {
4212
4250
  let data = "";
4213
4251
  stream.setEncoding?.("utf8");
4214
4252
  stream.on("data", (c) => data += c.toString());
4215
- stream.on("end", () => resolve9(data));
4253
+ stream.on("end", () => resolve10(data));
4216
4254
  stream.on("error", reject);
4217
4255
  });
4218
4256
  }
@@ -4230,7 +4268,8 @@ async function helpText(dir) {
4230
4268
  "",
4231
4269
  "Global flags:",
4232
4270
  " --json One JSON document on stdout (for agents and scripts)",
4233
- " --api-key <key> Use this key (default: $CHECKBOX_API_KEY, then the key saved by `checkbox login`)",
4271
+ " --api-key <key> Use this key (default: $CHECKBOX_API_KEY, then CHECKBOX_SECRET_KEY from the environment or",
4272
+ " this project's .env.local / .env, then the key saved by `checkbox login`)",
4234
4273
  " --api-base <url> API base URL (default: $CHECKBOX_API_BASE or https://app.checkbox.site)",
4235
4274
  " -q, --quiet Only print errors",
4236
4275
  " --no-color Plain output",
@@ -4293,7 +4332,18 @@ async function run(argv, io) {
4293
4332
  }
4294
4333
  const flags = { json: g.json, quiet: g.quiet, color, help: false, ...g.apiKey ? { apiKey: g.apiKey } : {}, ...g.apiBase ? { apiBase: g.apiBase } : {} };
4295
4334
  const config = createConfigStore(io.env);
4296
- const credentials = () => resolveCredentials(io.env, config.load(), flags);
4335
+ let projectKey = null;
4336
+ const project = () => projectKey === null ? projectKey = findProjectKey(io.cwd, io.env) : projectKey;
4337
+ const credentials = () => resolveCredentials(io.env, config.load(), flags, project());
4338
+ let saidWhichKey = false;
4339
+ const sayWhichKey = (c) => {
4340
+ if (saidWhichKey || !c) return;
4341
+ saidWhichKey = true;
4342
+ const stored = config.load().api_key;
4343
+ if (c.source !== "config" && stored && stored !== c.apiKey) {
4344
+ out.info(out.c.dim(`Using the key from ${describeKeySource(c, io.env)} (${maskKey(c.apiKey)}), not your saved login (${maskKey(stored)}).`));
4345
+ }
4346
+ };
4297
4347
  const ctx = {
4298
4348
  argv: rest,
4299
4349
  flags,
@@ -4307,6 +4357,7 @@ async function run(argv, io) {
4307
4357
  const c = credentials();
4308
4358
  const apiKey = o.apiKey ?? c?.apiKey;
4309
4359
  if (!apiKey) throw new NotLoggedInError();
4360
+ if (!o.apiKey) sayWhichKey(c);
4310
4361
  return createApiClient({ apiKey, apiBase: o.apiBase ?? c?.apiBase ?? resolveCredentials(io.env, {}, flags)?.apiBase ?? "https://app.checkbox.site", userAgent: `checkbox-cli/${CLI_VERSION}`, ...io.fetch ? { fetch: io.fetch } : {} });
4311
4362
  },
4312
4363
  readStdin: () => readAll(io.stdin),
@@ -4315,10 +4366,21 @@ async function run(argv, io) {
4315
4366
  try {
4316
4367
  return await cmd.run(ctx);
4317
4368
  } catch (err) {
4318
- return reportError(out, err);
4369
+ return reportError(out, err, { credentials: credentials(), project: project() ?? void 0, env: io.env });
4319
4370
  }
4320
4371
  }
4321
- function reportError(out, err) {
4372
+ function authNextAction(ctxInfo) {
4373
+ const used = ctxInfo?.credentials;
4374
+ const proj = ctxInfo?.project;
4375
+ if (proj && used && proj.apiKey !== used.apiKey) {
4376
+ return {
4377
+ type: "use_project_key",
4378
+ message: `The key from ${describeKeySource(used, ctxInfo?.env)} (${maskKey(used.apiKey)}) was rejected, but this project's ${proj.file} has another key (${maskKey(proj.apiKey)}). Unset ${used.source === "env" ? "that environment variable" : "the --api-key flag"} so the CLI uses it, or check that the right key is in ${proj.file}.`
4379
+ };
4380
+ }
4381
+ return void 0;
4382
+ }
4383
+ function reportError(out, err, auth) {
4322
4384
  if (err instanceof UsageError) {
4323
4385
  out.error({ message: err.message, code: "usage_error", type: "usage_error" });
4324
4386
  return EXIT.usage;
@@ -4341,7 +4403,10 @@ function reportError(out, err) {
4341
4403
  ...err.requestId ? { request_id: err.requestId } : {},
4342
4404
  ...err.docUrl ? { doc_url: err.docUrl } : {},
4343
4405
  status: apiStatus(err),
4344
- ...apiStatus(err) === 0 ? { next_action: { type: "retry", message: "Check your network (and --api-base / CHECKBOX_API_BASE), or add --offline to run a check locally." } } : err.nextAction ? { next_action: err.nextAction } : isAuthError(err) ? { next_action: { type: "login", message: "The API key was rejected. Run `checkbox login` with a valid key, or set CHECKBOX_API_KEY." } } : {}
4406
+ ...apiStatus(err) === 0 ? { next_action: { type: "retry", message: "Check your network (and --api-base / CHECKBOX_API_BASE), or add --offline to run a check locally." } } : isAuthError(err) && authNextAction(auth) ? (
4407
+ // The server's 401 next step sends agents to device approval; a key this CLI can see locally comes first.
4408
+ { next_action: authNextAction(auth), ...auth?.credentials ? { key_source: auth.credentials.source, ...auth.credentials.file ? { key_file: auth.credentials.file } : {} } : {} }
4409
+ ) : err.nextAction ? { next_action: err.nextAction } : isAuthError(err) ? { next_action: { type: "login", message: "The API key was rejected. Run `checkbox login` with a valid key, or set CHECKBOX_API_KEY." } } : {}
4345
4410
  });
4346
4411
  return isAuthError(err) ? EXIT.auth : apiStatus(err) === 400 ? EXIT.usage : EXIT.error;
4347
4412
  }
@@ -4357,7 +4422,7 @@ var init_run = __esm({
4357
4422
  init_http();
4358
4423
  init_output();
4359
4424
  init_types2();
4360
- HERE = dirname(fileURLToPath(import.meta.url));
4425
+ HERE = dirname2(fileURLToPath(import.meta.url));
4361
4426
  COMMANDS_DIR = join2(HERE, "commands");
4362
4427
  CLI_VERSION = (() => {
4363
4428
  try {
@@ -4368,7 +4433,7 @@ var init_run = __esm({
4368
4433
  })();
4369
4434
  NotLoggedInError = class extends Error {
4370
4435
  constructor() {
4371
- super("Not logged in: no API key found (flag --api-key, env CHECKBOX_API_KEY, or `checkbox login`).");
4436
+ super("Not logged in: no API key found (flag --api-key, env CHECKBOX_API_KEY, CHECKBOX_SECRET_KEY in .env.local, or `checkbox login`).");
4372
4437
  this.name = "NotLoggedInError";
4373
4438
  }
4374
4439
  };
@@ -4707,7 +4772,8 @@ var init_secrets = __esm({
4707
4772
  { name: "stripe_webhook_secret", re: /\bwhsec_[A-Za-z0-9+/=_-]{4,}/g, replace: keepPrefix(() => 6) },
4708
4773
  { name: "stripe_publishable_live", re: /\bpk_live_[A-Za-z0-9_-]{4,}/g, replace: keepPrefix(() => 8) },
4709
4774
  // Checkbox API keys (ck_test_ / ck_live_) and scoped tokens.
4710
- { name: "checkbox_key", re: /\bck_(?:live|test)_[A-Za-z0-9_-]{4,}/g, replace: keepPrefix((m) => m.indexOf("_", 3) + 1) },
4775
+ // ckr_ (restricted) keys too: round 29 found they were left whole.
4776
+ { name: "checkbox_key", re: /\bckr?_(?:live|test)_[A-Za-z0-9_-]{4,}/g, replace: keepPrefix((m) => m.indexOf("_", 4) + 1) },
4711
4777
  // AWS access key ids (and the secret that usually follows them on the same line).
4712
4778
  { name: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: () => "AKIA[redacted]" },
4713
4779
  { name: "aws_secret", re: /(aws_secret_access_key|AWS_SECRET_ACCESS_KEY|secretAccessKey)(\s*[:=]\s*["']?)[A-Za-z0-9/+=]{30,}/g, replace: (m) => m.replace(/[A-Za-z0-9/+=]{30,}$/, "[redacted]") },
@@ -5126,7 +5192,7 @@ var init_california = __esm({
5126
5192
  sourceUrl: URL_ARTICLE,
5127
5193
  effectiveDate: ORIGINAL,
5128
5194
  lastVerified: LAST_VERIFIED,
5129
- summary: 'Clear and conspicuous offer terms next to the consent request; affirmative (and since July 2025, express affirmative) consent; retainable acknowledgment; online "click to cancel" for online sign-ups; a notice 3-21 days before a free trial or promotional price lasting more than 31 days ends, and 15-45 days before a term of one year or longer renews; 7-30 day fee-change notice; annual reminder; same-medium cancellation; limits on save offers; consent records kept 3 years, or 1 year after termination if longer. Remedies: civil (UCL) remedies only (\xA7 17604); unconsented goods are gifts (\xA7 17603).',
5195
+ summary: 'Clear and conspicuous offer terms next to the consent request; affirmative (and since July 2025, express affirmative) consent; retainable acknowledgment; online "click to cancel" for online sign-ups; a notice 3-21 days before a free trial or promotional price lasting more than 31 days ends, and 15-45 days before a term of one year or longer renews; 7-30 day fee-change notice; annual reminder; same-medium cancellation; limits on save offers; consent records kept 3 years, or 1 year after termination if longer. No criminal penalties; civil remedies apply (\xA7 17604), in practice through the UCL, FAL and CLRA; unconsented goods are gifts (\xA7 17603).',
5130
5196
  status: "in_force",
5131
5197
  confidence: "high",
5132
5198
  verification: "primary",
@@ -5250,14 +5316,14 @@ var init_federal = __esm({
5250
5316
  effectiveDate: EFTA_EFFECTIVE,
5251
5317
  stage: "reminder",
5252
5318
  title: "Advance notice when a recurring debit will change in amount",
5253
- summary: "When a preauthorized debit from a consumer's account will vary in amount from the previous transfer or from the authorized amount (e.g. a subscription price increase billed to a debit card or by ACH), the payee must send written notice of the amount and date of the transfer at least 10 days before the scheduled transfer date.",
5319
+ summary: "When a preauthorized debit from a consumer's account will vary in amount from the previous transfer or from the authorized amount (e.g. a subscription price increase billed to a debit card or by ACH), the payee must send written notice of the amount and date of the transfer at least 10 days before the scheduled transfer date (\xA7 1005.10(d)(1)). The payee must tell the consumer of the right to notice of every varying transfer, but may let the consumer choose notice only when a transfer falls outside a specified range of amounts, or differs from the most recent transfer by more than an agreed amount (\xA7 1005.10(d)(2)).",
5254
5320
  appliesWhen: { paymentMethods: ["debit", "ach"] },
5255
5321
  checkIds: ["reminder.price_change"],
5256
5322
  severity: "medium",
5257
5323
  noticeWindow: noticeWindowAtLeast(10, "charge"),
5258
5324
  notes: notes(
5259
5325
  windowNote(noticeWindowAtLeast(10, "charge"), "the changed debit"),
5260
- "Applies only to debits from consumer asset accounts (debit, prepaid, ACH), not credit cards. The payee may instead let the consumer elect to be notified only when the amount falls outside a specified range or differs from the most recent transfer by more than an agreed amount."
5326
+ "Applies only to debits from consumer asset accounts (debit, prepaid, ACH), not credit cards. A consumer who chose range-only notice under \xA7 1005.10(d)(2) gets no notice for a change inside that range; Checkbox cannot see that choice, so the checks expect the notice for every price change."
5261
5327
  )
5262
5328
  })
5263
5329
  ];
@@ -5406,7 +5472,7 @@ var init_exposure = __esm({
5406
5472
  fees: NO_PRIVATE
5407
5473
  },
5408
5474
  "US-FED.1005.": {
5409
- privateSuit: "Yes: the Electronic Fund Transfer Act lets consumers sue (15 U.S.C. \xA7 1693m). Applies when the subscription is paid by debit card or ACH.",
5475
+ privateSuit: "Yes, in the law itself: the Electronic Fund Transfer Act lets consumers sue (15 U.S.C. \xA7 1693m). Applies when the subscription is paid by debit card or ACH.",
5410
5476
  damages: "Actual damages, plus statutory damages of $100 to $1,000 in an individual action, or in a class action up to the lesser of $500,000 or 1% of the defendant's net worth (15 U.S.C. \xA7 1693m(a)(1), (2)).",
5411
5477
  penalties: "None in private suits: \xA7 1693m provides damages, costs and fees, not civil penalties. The CFPB enforces the Act (15 U.S.C. \xA7 1693o(a)(5)) and can seek civil money penalties for a violation of federal consumer financial law, which includes the Act (12 U.S.C. \xA7\xA7 5481(12)(C), 5565(a)(2)(H)): up to $5,000 a day, $25,000 a day if reckless and $1,000,000 a day if knowing, before inflation adjustment (12 U.S.C. \xA7 5565(c)(2)).",
5412
5478
  fees: "Costs and reasonable attorney's fees for a successful action (15 U.S.C. \xA7 1693m(a)(3))."
@@ -5418,7 +5484,7 @@ var init_exposure = __esm({
5418
5484
  fees: NO_PRIVATE
5419
5485
  },
5420
5486
  "US-CA": {
5421
- privateSuit: "Through general consumer law: the ARL has no private right of its own (Mayron v. Google, 2020); plaintiffs sue under the UCL, FAL and CLRA and must show economic injury. Before suing for damages under the CLRA, the consumer must send a written notice and demand by certified or registered mail at least 30 days ahead, and no damages action lies if an appropriate correction is given or agreed within 30 days (Cal. Civ. Code \xA7 1782(a), (b)); a CLRA claim for an injunction needs no prior notice (\xA7 1782(d)).",
5487
+ privateSuit: "Yes, through general consumer law: the ARL has no private right of its own (Mayron v. Google, 2020); plaintiffs sue under the UCL, FAL and CLRA and must show economic injury. Before suing for damages under the CLRA, the consumer must send a written notice and demand by certified or registered mail at least 30 days ahead, and no damages action lies if an appropriate correction is given or agreed within 30 days (Cal. Civ. Code \xA7 1782(a), (b)); a CLRA claim for an injunction needs no prior notice (\xA7 1782(d)).",
5422
5488
  damages: "Goods or services provided without the required consent are an unconditional gift (Cal. Bus. & Prof. Code \xA7 17603); all civil remedies are available (\xA7 17604, with a good-faith-compliance safe harbor). Under the CLRA: actual damages (in a class action, at least $1,000 in total), an injunction, restitution of property, punitive damages and other relief the court deems proper (Cal. Civ. Code \xA7 1780(a)). Under the UCL and the FAL: an injunction and orders restoring money or property acquired by the unlawful practice (restitution) (Cal. Bus. & Prof. Code \xA7\xA7 17203, 17535); a private plaintiff must have suffered injury in fact and lost money or property (\xA7 17535, for the FAL). Neither section provides damages.",
5423
5489
  penalties: "In public actions only (the Attorney General, district attorneys and some county counsel and city attorneys): up to $2,500 per violation under the UCL (Cal. Bus. & Prof. Code \xA7 17206(a)) and up to $2,500 per violation under the FAL (\xA7 17536(a)).",
5424
5490
  fees: "Under the CLRA: the court shall award costs and attorney's fees to a prevailing plaintiff; a prevailing defendant may get reasonable fees only if the suit was not brought in good faith (Cal. Civ. Code \xA7 1780(e)). The UCL and FAL sections provide no attorney's fees (Cal. Bus. & Prof. Code \xA7\xA7 17203, 17535). Plaintiffs sometimes seek fees under the private attorney general statute, which lets a court award fees to a successful party in an action that enforced an important right affecting the public interest, when its conditions are met (Cal. Code Civ. Proc. \xA7 1021.5).",
@@ -5551,7 +5617,7 @@ var init_exposure = __esm({
5551
5617
  fees: OPEN
5552
5618
  },
5553
5619
  "US-ND": {
5554
- privateSuit: "Yes: a person aggrieved may sue to enjoin the violation or for restitution, or both (N.D. Cent. Code \xA7 51-37-06).",
5620
+ privateSuit: "Yes, in the law itself: a person aggrieved may sue to enjoin the violation or for restitution, or both (N.D. Cent. Code \xA7 51-37-06).",
5555
5621
  damages: "Restitution (N.D. Cent. Code \xA7 51-37-06); goods or services provided under a non-compliant renewal, or after an attempt to cancel, are an unconditional gift (\xA7 51-37-04).",
5556
5622
  penalties: "Each act is a separate violation of N.D. Cent. Code ch. 51-15, enforced by the attorney general with that chapter's remedies (\xA7 51-37-05). Unsettled: the figure is not yet confirmed from the statute text.",
5557
5623
  fees: "Costs, expenses and reasonable attorney's fees (N.D. Cent. Code \xA7 51-37-06)."
@@ -9824,7 +9890,7 @@ var init_catalog = __esm({
9824
9890
  init_states();
9825
9891
  init_terms();
9826
9892
  init_types3();
9827
- CATALOG_VERSION = "2026.11.09.2";
9893
+ CATALOG_VERSION = "2026.11.09.3";
9828
9894
  CATALOG = [
9829
9895
  ...FEDERAL_REQUIREMENTS,
9830
9896
  ...CALIFORNIA_REQUIREMENTS,
@@ -10645,7 +10711,7 @@ function analyzePolicyText(input) {
10645
10711
  function clausesOf(clauses, code2) {
10646
10712
  return clauses.filter((c) => c.code === code2);
10647
10713
  }
10648
- var POLICY_KINDS, POLICY_FRAGMENT, POLICY_FETCHER_USER_AGENT, SUBSCRIPTION_WORDS, NO_CANCEL, OFFLINE_ONLY, CONTACT_TO_CANCEL, ACCOUNT_NOT_PLACE, CANCEL_ONLINE_STRONG, NOTICE_PERIOD, ALL_SALES_FINAL, CANCEL_ONLINE, AUTO_RENEWAL, HOW_TO_CANCEL, NO_REFUNDS, REFUND_WINDOW, GUARANTEE, REFUND_EXCEPTION;
10714
+ var POLICY_KINDS, POLICY_FRAGMENT, POLICY_FETCHER_USER_AGENT, SUBSCRIPTION_WORDS, NO_CANCEL, OFFLINE_ONLY, STOP_SUBSCRIPTION, CONTACT_TO_CANCEL, ACCOUNT_NOT_PLACE, CANCEL_ONLINE_STRONG, NOTICE_PERIOD, ALL_SALES_FINAL, CANCEL_ONLINE, AUTO_RENEWAL, HOW_TO_CANCEL, NO_REFUNDS, REFUND_WINDOW, GUARANTEE, REFUND_EXCEPTION;
10649
10715
  var init_policy_docs = __esm({
10650
10716
  "../rules/src/policy-docs.ts"() {
10651
10717
  "use strict";
@@ -10666,9 +10732,12 @@ var init_policy_docs = __esm({
10666
10732
  /\bcancell?(?:ations?|ing)?\b[^.]{0,80}\b(?:only|solely|exclusively)\b[^.]{0,60}\b(?:phone|telephone|mail|post|in writing|in person|fax)\b/i,
10667
10733
  /\bcancell?ation requests?\b[^.]{0,60}\b(?:by|via) (?:certified |registered )?(?:mail|post|letter|fax|phone|telephone)\b/i
10668
10734
  ];
10735
+ STOP_SUBSCRIPTION = "(?:stop|end|terminate|discontinue|turn off)\\s+(?:your |the |a |my |this )?(?:paid |monthly |annual |yearly )?(?:subscription|membership|plan|auto-?renewal|automatic renewal|recurring (?:billing|payments?|charges?))";
10669
10736
  CONTACT_TO_CANCEL = [
10670
10737
  /\bto cancel\b[^.]{0,80}\b(?:contact|email|e-mail|reach out to|message)\b/i,
10671
10738
  /\b(?:contact|email|e-mail)\b[^.]{0,60}\bto cancel\b/i,
10739
+ new RegExp(`\\bto ${STOP_SUBSCRIPTION}\\b[^.]{0,80}(?:\\b(?:contact|email|e-mail|reach out to|message|write to)\\b|\\[redacted\\])`, "i"),
10740
+ new RegExp(`(?:\\b(?:contact|email|e-mail|write to)\\b|\\[redacted\\])[^.]{0,60}\\bto ${STOP_SUBSCRIPTION}\\b`, "i"),
10672
10741
  // Round 2 pilot: "You may cancel by contacting support" / "cancellations are handled by emailing us".
10673
10742
  /\bcancel\w*\b[^.]{0,60}\b(?:by|through|via)\s+(?:contacting|emailing|e-mailing|messaging|writing to|reaching out to|calling|phoning|sending (?:us )?an? (?:email|e-mail|message))\b/i,
10674
10743
  // Normal round 2 (nextjs): the scanner redacts e-mail addresses before the rules see the text, so
@@ -11252,6 +11321,9 @@ function introAmountOf(amount, o) {
11252
11321
  return void 0;
11253
11322
  }
11254
11323
  function unverifiedReason(ctx) {
11324
+ const known = ctx.referencePlans ?? [];
11325
+ if (known.length) return `none of your plans (${known.slice(0, 4).map((p) => p.label ?? p.name ?? p.id).join(", ")}) was matched to this checkout's price (the Price id it sells is not one Checkbox has for them)`;
11326
+ if ((ctx.checkoutConfigs?.length ?? 0) > 0) return "the price this checkout sells is not in your registered Checkout configs or your billing platform's prices";
11255
11327
  const chosen = ctx.facts?.planPriceId;
11256
11328
  if (chosen && ctx.config) return `the chosen price ${chosen} is not among the active recurring prices read from your billing platform`;
11257
11329
  if (chosen) return `the chosen price ${chosen} could not be read (no billing platform connected)`;
@@ -11309,7 +11381,7 @@ function noticePlanOptions(ctx, priceChange = false) {
11309
11381
  const newPrices = (ctx.events ?? []).filter((e) => e.type === "price_change_scheduled").map((e) => e.amount).filter((n) => typeof n === "number" && n > 0).map((n) => n / 10 ** currencyDigitsOf(currency));
11310
11382
  if (priceChange && newPrices.length) return { plans: plans.map((p) => ({ ...p, amount: -1 })), extraAmounts: newPrices };
11311
11383
  if (!plans.length) return { plans: [], extraAmounts: [] };
11312
- return { plans, extraAmounts: newPrices };
11384
+ return { plans, extraAmounts: newPrices, ...ctx.events?.length ? { events: ctx.events } : {} };
11313
11385
  }
11314
11386
  function declaredPlansFrom(plans) {
11315
11387
  const out = [];
@@ -11392,8 +11464,8 @@ function sameProductName(a, b) {
11392
11464
  function declaredPriceIds(x) {
11393
11465
  return [x.price_id, x.stripe_price, x.priceId, x.id].filter((v) => typeof v === "string" && v.trim().length > 0);
11394
11466
  }
11395
- function reconcileDeclaredPlans(raw, billing, platform = "Stripe") {
11396
- const prices = billing.filter((p) => p && p.amount > 0 && p.interval);
11467
+ function reconcileDeclaredPlans(raw, billing, platform = "Stripe", checkoutTrials) {
11468
+ const prices = billing.filter((p) => p && p.amount > 0 && p.interval).map((p) => checkoutTrials?.has(p.id) ? { ...p, trialDays: checkoutTrials.get(p.id) } : p);
11397
11469
  const out = [...raw];
11398
11470
  const conflicts = [];
11399
11471
  if (!prices.length || !raw.length) return { plans: out, conflicts };
@@ -11551,11 +11623,11 @@ function siteOffers(input) {
11551
11623
  };
11552
11624
  for (const d of declared) {
11553
11625
  const price3 = stripe.find((s2) => sameTerms(s2, d));
11554
- const trial = d.trialDays && d.trialDays > 0 ? d.trialDays : price3?.trialDays && price3.trialDays > 0 ? price3.trialDays : soldTrial({ ...d, ...price3 ? { priceId: price3.id } : {} });
11626
+ const trial = d.trialDays && d.trialDays > 0 ? d.trialDays : soldTrial({ ...d, ...price3 ? { priceId: price3.id } : {} }) || (price3?.trialDays && price3.trialDays > 0 ? price3.trialDays : 0);
11555
11627
  push(toOffer(d, "declared", { trialDays: trial, ...price3 ? { priceId: price3.id } : {} }));
11556
11628
  }
11557
11629
  const chosen = input.facts?.planPriceId ? stripe.find((p) => p.id === input.facts.planPriceId) : void 0;
11558
- if (chosen) push(toOffer(chosen, "chosen", chosen.trialDays && chosen.trialDays > 0 ? {} : { trialDays: soldTrial(chosen) }));
11630
+ if (chosen) push(toOffer(chosen, "chosen", soldTrial(chosen) > 0 ? { trialDays: soldTrial(chosen) } : {}));
11559
11631
  if (!declared.length) {
11560
11632
  for (const s2 of sold) {
11561
11633
  const price3 = stripe.find((p) => sameTerms(p, s2));
@@ -11570,7 +11642,7 @@ function siteOffers(input) {
11570
11642
  const anchors = stripe.filter((s2) => out.some((o) => o.priceId === s2.id));
11571
11643
  for (const s2 of stripe) {
11572
11644
  if (out.some((o) => o.priceId === s2.id) || !anchors.some((a) => sameProductName(a.name, s2.name))) continue;
11573
- push(toOffer(s2, "stripe", s2.trialDays && s2.trialDays > 0 ? {} : { trialDays: soldTrial(s2) }));
11645
+ push(toOffer(s2, "stripe", soldTrial(s2) > 0 ? { trialDays: soldTrial(s2) } : {}));
11574
11646
  }
11575
11647
  }
11576
11648
  const facts = input.facts;
@@ -12937,14 +13009,14 @@ var init_visible_html = __esm({
12937
13009
 
12938
13010
  // ../rules/src/checks/context.ts
12939
13011
  function heuristicReader() {
12940
- const cache = /* @__PURE__ */ new Map();
13012
+ const cache2 = /* @__PURE__ */ new Map();
12941
13013
  return {
12942
13014
  mode: "heuristic",
12943
13015
  async analyze(text) {
12944
- let a = cache.get(text);
13016
+ let a = cache2.get(text);
12945
13017
  if (!a) {
12946
13018
  a = analyzeTerms(text);
12947
- cache.set(text, a);
13019
+ cache2.set(text, a);
12948
13020
  }
12949
13021
  return a;
12950
13022
  }
@@ -13793,7 +13865,7 @@ function remediationFor(checkId, platform) {
13793
13865
  if (guide?.snippet) rem.snippet = { ...guide.snippet };
13794
13866
  return rem;
13795
13867
  }
13796
- var DISCLOSURE_COPY_TEMPLATE, CONSENT_COPY_TEMPLATE, ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_REMINDER_COPY_TEMPLATE, RENEWAL_REMINDER_COPY_TEMPLATE, PRICE_CHANGE_COPY_TEMPLATE, ANNUAL_TERMS_COPY_TEMPLATE, CHARGE_NOTICE_COPY_TEMPLATE, CANCELLATION_CONFIRMATION_COPY_TEMPLATE, TERMS_CANCELLATION_COPY_TEMPLATE, TERMS_REFUND_COPY_TEMPLATE, TERMS_RENEWAL_COPY_TEMPLATE, PLACEHOLDER_STEP, CUSTOM_DISCLOSURE_HTML, CUSTOM_CONSENT_HTML, STRIPE_CHECKOUT_TS, STRIPE_ANNUAL_REMINDER_TS, STRIPE_CHARGE_NOTICE_TS, STRIPE_PORTAL_TS, STRIPE_ACK_WEBHOOK_TS, STRIPE_CANCEL_CONFIRMATION_TS, SHOPIFY_LIQUID, SHOPIFY_CHECKOUT_EXTENSION_TSX, CUSTOM_ACCOUNT_CANCEL_HTML, CUSTOM_ACK_EMAIL_HTML, VERIFY, VERIFY_TERMS, VERIFY_EMAIL, CB_CHECKOUT, CB_PORTAL, CB_EMAILS, CHARGEBEE_DISCLOSURE, CHARGEBEE_CONSENT, CHARGEBEE_ACK, CHARGEBEE_PORTAL, CHARGEBEE_TRIAL_REMINDER, CHARGEBEE_RENEWAL_REMINDER, CHARGEBEE_ANNUAL_TERMS, CHARGEBEE_CHARGE_NOTICE, RC_ACCOUNT_PAGES, RC_EMAILS, RECURLY_DISCLOSURE, RECURLY_CONSENT, RECURLY_ACK, RECURLY_PORTAL, RECURLY_TRIAL_REMINDER, RECURLY_RENEWAL_REMINDER, RECURLY_ANNUAL_TERMS, RECURLY_CHARGE_NOTICE, PD_RETAIN, PADDLE_DISCLOSURE, PADDLE_CONSENT, PADDLE_ACK, PADDLE_PORTAL, PADDLE_TRIAL_REMINDER, PADDLE_RENEWAL_REMINDER, PADDLE_ANNUAL_TERMS, PADDLE_CHARGE_NOTICE, STRIPE_DISCLOSURE, SHOPIFY_DISCLOSURE, RECHARGE_DISCLOSURE, CUSTOM_DISCLOSURE, DISCLOSURE_PLATFORMS, STRIPE_ACK, SHOPIFY_ACK, RECHARGE_ACK, CUSTOM_ACK, ACK_PLATFORMS, STRIPE_PORTAL, SHOPIFY_PORTAL, RECHARGE_PORTAL, CUSTOM_PORTAL, PORTAL_PLATFORMS, STRIPE_TRIAL_REMINDER, STRIPE_RENEWAL_REMINDER, STRIPE_ANNUAL_TERMS, STRIPE_CHARGE_NOTICE, SHOPIFY_ANNUAL_TERMS, SHOPIFY_CHARGE_NOTICE, RECHARGE_ANNUAL_TERMS, RECHARGE_CHARGE_NOTICE, CUSTOM_ANNUAL_TERMS, CUSTOM_CHARGE_NOTICE, SHOPIFY_REMINDER, RECHARGE_REMINDER, CUSTOM_REMINDER, SPECS, PLATFORM_LABELS, OFFER_PERMISSION_FIX, OFFER_QUESTION_STOP_FIX, OFFER_PERMISSION_SNIPPET, CANCEL_HANDLER_REMEDIATION, OFFER_PERMISSION_REMEDIATION;
13868
+ var DISCLOSURE_COPY_TEMPLATE, CONSENT_COPY_TEMPLATE, ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_ACKNOWLEDGMENT_COPY_TEMPLATE, TRIAL_REMINDER_COPY_TEMPLATE, RENEWAL_REMINDER_COPY_TEMPLATE, PRICE_CHANGE_COPY_TEMPLATE, ANNUAL_TERMS_COPY_TEMPLATE, CHARGE_NOTICE_COPY_TEMPLATE, CANCELLATION_CONFIRMATION_COPY_TEMPLATE, TERMS_CANCELLATION_COPY_TEMPLATE, TERMS_REFUND_COPY_TEMPLATE, TERMS_RENEWAL_COPY_TEMPLATE, PLACEHOLDER_STEP, CUSTOM_DISCLOSURE_HTML, CUSTOM_CONSENT_HTML, STRIPE_CHECKOUT_TS, STRIPE_ANNUAL_REMINDER_TS, STRIPE_CHARGE_NOTICE_TS, STRIPE_PORTAL_TS, STRIPE_ACK_WEBHOOK_TS, STRIPE_CANCEL_CONFIRMATION_TS, SHOPIFY_LIQUID, SHOPIFY_CHECKOUT_EXTENSION_TSX, CUSTOM_ACCOUNT_CANCEL_HTML, CUSTOM_ACK_EMAIL_HTML, VERIFY, VERIFY_TERMS, VERIFY_EMAIL, CB_CHECKOUT, CB_PORTAL, CB_EMAILS, CHARGEBEE_DISCLOSURE, CHARGEBEE_CONSENT, CHARGEBEE_ACK, CHARGEBEE_PORTAL, CHARGEBEE_TRIAL_REMINDER, CHARGEBEE_RENEWAL_REMINDER, CHARGEBEE_ANNUAL_TERMS, CHARGEBEE_CHARGE_NOTICE, RC_ACCOUNT_PAGES, RC_EMAILS, RECURLY_DISCLOSURE, RECURLY_CONSENT, RECURLY_ACK, RECURLY_PORTAL, RECURLY_TRIAL_REMINDER, RECURLY_RENEWAL_REMINDER, RECURLY_ANNUAL_TERMS, RECURLY_CHARGE_NOTICE, PD_RETAIN, PADDLE_DISCLOSURE, PADDLE_CONSENT_HTML, PADDLE_CONSENT, PADDLE_ACK, PADDLE_PORTAL, PADDLE_TRIAL_REMINDER, PADDLE_RENEWAL_REMINDER, PADDLE_ANNUAL_TERMS, PADDLE_CHARGE_NOTICE, STRIPE_DISCLOSURE, SHOPIFY_DISCLOSURE, RECHARGE_DISCLOSURE, CUSTOM_DISCLOSURE, DISCLOSURE_PLATFORMS, STRIPE_ACK, SHOPIFY_ACK, RECHARGE_ACK, CUSTOM_ACK, ACK_PLATFORMS, STRIPE_PORTAL, SHOPIFY_PORTAL, RECHARGE_PORTAL, CUSTOM_PORTAL, PORTAL_PLATFORMS, STRIPE_TRIAL_REMINDER, STRIPE_RENEWAL_REMINDER, STRIPE_ANNUAL_TERMS, STRIPE_CHARGE_NOTICE, SHOPIFY_ANNUAL_TERMS, SHOPIFY_CHARGE_NOTICE, RECHARGE_ANNUAL_TERMS, RECHARGE_CHARGE_NOTICE, CUSTOM_ANNUAL_TERMS, CUSTOM_CHARGE_NOTICE, SHOPIFY_REMINDER, RECHARGE_REMINDER, CUSTOM_REMINDER, SPECS, PLATFORM_LABELS, OFFER_PERMISSION_FIX, OFFER_QUESTION_STOP_FIX, OFFER_PERMISSION_SNIPPET, CANCEL_HANDLER_REMEDIATION, OFFER_PERMISSION_REMEDIATION;
13797
13869
  var init_remediation = __esm({
13798
13870
  "../rules/src/remediation.ts"() {
13799
13871
  "use strict";
@@ -14109,13 +14181,32 @@ and click \u201CCancel subscription\u201D. You can cancel anytime online.</p>`;
14109
14181
  ],
14110
14182
  snippet: { language: "html", code: CUSTOM_DISCLOSURE_HTML }
14111
14183
  };
14184
+ PADDLE_CONSENT_HTML = `<!-- Replace your button that opens Paddle Checkout with this form (Paddle.js must be loaded and initialized). -->
14185
+ <form id="checkbox-paddle-checkout">
14186
+ <label style="display:flex;gap:8px;align-items:flex-start;font-size:14px;color:#1f2937;">
14187
+ <!-- Never add the "checked" attribute: consent must be an affirmative act -->
14188
+ <input type="checkbox" name="auto_renew_consent" value="yes" required>
14189
+ <span>I agree that my subscription will automatically renew every {interval} at {price} until I cancel.</span>
14190
+ </label>
14191
+ <button type="submit">Subscribe</button>
14192
+ </form>
14193
+ <script>
14194
+ document.getElementById('checkbox-paddle-checkout').addEventListener('submit', function (event) {
14195
+ event.preventDefault(); // the browser only gets here once the required box is ticked
14196
+ var label = this.querySelector('label span').textContent.trim();
14197
+ Paddle.Checkout.open({
14198
+ items: [{ priceId: 'pri_REPLACE_ME', quantity: 1 }],
14199
+ customData: { auto_renew_consent: { at: new Date().toISOString(), label: label } },
14200
+ });
14201
+ });
14202
+ </script>`;
14112
14203
  PADDLE_CONSENT = {
14113
14204
  steps: [
14114
14205
  "Add a required, unchecked renewal-consent checkbox (the wording below) on your page, directly above the button that opens Paddle Checkout, and only open checkout once it is ticked.",
14115
- "Save when it was ticked and the terms that were shown, so you can prove consent later.",
14116
- "For developers: call Paddle.Checkout.open() only after the box is ticked, and pass the consent time in customData so it is stored on the transaction."
14206
+ 'Paddle opens checkout from a button, not a form, so a "required" box alone blocks nothing: put the box and the button in one form (the snippet does) or keep the button disabled until the box is ticked.',
14207
+ "Save when it was ticked and the terms that were shown, so you can prove consent later: the snippet passes them in customData, which Paddle stores on the transaction. On Lemon Squeezy, pass them as checkout[custom][\u2026] on the checkout URL."
14117
14208
  ],
14118
- snippet: { language: "html", code: CUSTOM_CONSENT_HTML }
14209
+ snippet: { language: "html", code: PADDLE_CONSENT_HTML }
14119
14210
  };
14120
14211
  PADDLE_ACK = {
14121
14212
  steps: [
@@ -14829,7 +14920,8 @@ function inAppPath(input) {
14829
14920
  return { kind: "in_app", state: "works", detail: "a test cancellation completed online" };
14830
14921
  }
14831
14922
  if (hostedStep2) return void 0;
14832
- const kinds = (flow.blockers ?? []).map(classifyBlocker);
14923
+ const onHosted = (flow.steps ?? []).some((s2) => s2.source !== "snippet" && isHostedCancelUrl(s2.url));
14924
+ const kinds = (flow.blockers ?? []).map(classifyBlocker).filter((k) => !(onHosted && k === "contact"));
14833
14925
  if (kinds.some((k) => k === "contact" || k === "not_found" || k === "obstacle")) {
14834
14926
  return { kind: "in_app", state: "broken", detail: `the test cancellation did not complete (${flow.blockers.join("; ")})` };
14835
14927
  }
@@ -14906,7 +14998,7 @@ function offlineOnlyCancellation(input) {
14906
14998
  const hosted = input.ctx.hostedCancel;
14907
14999
  const helpHits = [];
14908
15000
  for (const c of captures) {
14909
- if (isFetchedDocument(c) || c.pageProblem || c.pageKind === "checkout") continue;
15001
+ if (isFetchedDocument(c) || c.pageProblem || c.pageKind === "checkout" || isHostedCancelUrl(c.url)) continue;
14910
15002
  const sentence = contactOnlySentence(c.text);
14911
15003
  if (!sentence) continue;
14912
15004
  if (ownOnlineCancelControls(c, hosted).length) continue;
@@ -14917,7 +15009,7 @@ function offlineOnlyCancellation(input) {
14917
15009
  }
14918
15010
  return { where: `${c.pageKind === "account" ? "account page" : c.pageKind === "cancel" ? "cancel page" : "page"} ${c.url.split("#")[0]}`, quote: sentence, capture: c };
14919
15011
  }
14920
- const contactOnlyUrls = new Set(captures.filter((c) => !isFetchedDocument(c) && contactOnlySentence(c.text)).map((c) => stripUrl(c.url)));
15012
+ const contactOnlyUrls = new Set(captures.filter((c) => !isFetchedDocument(c) && !isHostedCancelUrl(c.url) && contactOnlySentence(c.text)).map((c) => stripUrl(c.url)));
14921
15013
  const onlineControl = captures.some(
14922
15014
  (c) => !isFetchedDocument(c) && ownOnlineCancelControls(c, hosted).some((el) => !contactOnlyUrls.has(stripUrl(hrefOf(el) ?? "", c.url)))
14923
15015
  );
@@ -16291,15 +16383,34 @@ function offlineOnlyCancelIn(text, cancelMethods) {
16291
16383
  const online = cancelMethods.some((m) => !CONTACT_TO_CANCEL_RE.test(m.text) && !/@|\d{3}[\s.-]\d{4}|\b(?:call|phone|chat|e-?mail|contact|mail|reply)\b/i.test(m.text));
16292
16384
  return online ? null : offline;
16293
16385
  }
16386
+ function inForceAmount(e, events, fallbackCurrency = "usd") {
16387
+ const own = e.noticeTerms;
16388
+ if (own && typeof own.amount === "number" && own.amount > 0) return own.amount / 10 ** currencyDigitsOf(typeof own.currency === "string" ? own.currency : fallbackCurrency);
16389
+ const ref = typeof e.subscriptionRef === "string" ? e.subscriptionRef.trim() : "";
16390
+ const sent2 = toDate(e.receivedAt)?.getTime();
16391
+ if (!ref || sent2 === void 0) return void 0;
16392
+ let best;
16393
+ let bestAt = -Infinity;
16394
+ for (const ev of events) {
16395
+ if (ev.subscriptionRef !== ref || !CHARGE_AMOUNT_EVENTS.has(ev.type) || !(typeof ev.amount === "number" && ev.amount > 0)) continue;
16396
+ const at = toDate(ev.occurredAt)?.getTime();
16397
+ if (at === void 0 || at > sent2 + 36e5 || at < bestAt) continue;
16398
+ best = ev;
16399
+ bestAt = at;
16400
+ }
16401
+ return best ? best.amount / 10 ** currencyDigitsOf(best.currency ?? fallbackCurrency) : void 0;
16402
+ }
16294
16403
  function noticeContent(e, opts = {}) {
16295
16404
  const body = emailBody(e);
16296
16405
  const a = analyzeTerms(body);
16297
16406
  const quotes = {};
16298
16407
  const wrong = {};
16299
16408
  const plans = opts.plans ?? [];
16409
+ const inForce = opts.events ? inForceAmount(e, opts.events, plans[0]?.currency) : void 0;
16300
16410
  const expected = [
16301
16411
  ...plans.map((p) => p.amount / 10 ** currencyDigitsOf(p.currency)),
16302
- ...opts.extraAmounts ?? []
16412
+ ...opts.extraAmounts ?? [],
16413
+ ...inForce !== void 0 ? [inForce] : []
16303
16414
  ];
16304
16415
  const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
16305
16416
  const stated = positiveAmounts(a).filter((m) => m.amount !== null);
@@ -16364,7 +16475,7 @@ function contentFixFromText(checkId, text) {
16364
16475
  const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
16365
16476
  return m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16366
16477
  }
16367
- var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, NOTICE_NOUN;
16478
+ var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, CHARGE_AMOUNT_EVENTS, NOTICE_NOUN;
16368
16479
  var init_notice_content = __esm({
16369
16480
  "../rules/src/checks/notice-content.ts"() {
16370
16481
  "use strict";
@@ -16390,6 +16501,7 @@ var init_notice_content = __esm({
16390
16501
  PRODUCT_RE = /\b(?:[Yy]our|[Tt]he|[Tt]his)\s+(?:[A-Z0-9][\w&'’.+-]*\s+){1,5}(?:[Ss]ubscription|[Mm]embership|[Pp]lan|[Bb]ox|[Ss]ervice|[Cc]lub|[Pp]ackage)\b|\b(?:[Pp]lan|[Pp]roduct|[Ss]ubscription|[Mm]embership)\s*:\s*\S[^\n]{1,60}/;
16391
16502
  CONTACT_RE = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+|(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b|\bcontact\s+(?:us|support|our\s+(?:support|customer|help))\b[^\n]{0,80}(?:https?:\/\/\S+|@)|\b(?:support|help)\s+(?:center|centre)\s*:?\s*https?:\/\/\S+/i;
16392
16503
  CANCEL_URL_RE = /https?:\/\/\S*(?:cancel|billing\.stripe\.com|customer[-_]?portal|\/portal|manage|subscription|account)\S*/i;
16504
+ CHARGE_AMOUNT_EVENTS = /* @__PURE__ */ new Set(["subscription_created", "subscription_renewed", "upcoming_renewal", "trial_will_end"]);
16393
16505
  NOTICE_NOUN = {
16394
16506
  "reminder.trial_ending": "trial-ending reminder",
16395
16507
  "reminder.annual_renewal": "renewal reminder",
@@ -16459,6 +16571,64 @@ var init_email_plan = __esm({
16459
16571
  }
16460
16572
  });
16461
16573
 
16574
+ // ../rules/src/checks/forwarded.ts
16575
+ function headerAddress(block, name) {
16576
+ const m = new RegExp(String.raw`^\s*[*>]*\s*${name}\s*:\s*\*?\s*(.+)$`, "im").exec(block);
16577
+ if (!m) return void 0;
16578
+ const a = ADDRESS_RE.exec(m[1]);
16579
+ return a ? a[0].toLowerCase().replace(/[.>]+$/, "") : void 0;
16580
+ }
16581
+ function forwardedInfo(e) {
16582
+ const hit = cache.get(e);
16583
+ if (hit) return hit;
16584
+ const text = e.text ?? "";
16585
+ const marker = FWD_MARKER_RE.exec(text);
16586
+ const forwarded = !!marker || FWD_SUBJECT_RE.test(e.subject ?? "");
16587
+ const info = { forwarded };
16588
+ if (forwarded) {
16589
+ const start = marker ? marker.index : 0;
16590
+ const block = text.slice(start, start + 1200);
16591
+ const from = headerAddress(block, "From");
16592
+ const to = headerAddress(block, "To");
16593
+ if (from) info.originalFrom = from;
16594
+ if (to) info.originalTo = to;
16595
+ }
16596
+ cache.set(e, info);
16597
+ return info;
16598
+ }
16599
+ function domainOf(address) {
16600
+ const m = /@([^\s<>"]+)/.exec(address ?? "");
16601
+ return m ? m[1].toLowerCase().replace(/[>.]+$/, "") : "";
16602
+ }
16603
+ function isMorDomain(domain) {
16604
+ return !!domain && MERCHANT_OF_RECORD_DOMAINS.some((d) => domain === d || domain.endsWith(`.${d}`));
16605
+ }
16606
+ function isMerchantOfRecordEmail(e) {
16607
+ return isMorDomain(domainOf(e.from)) || isMorDomain(domainOf(forwardedInfo(e).originalFrom));
16608
+ }
16609
+ function inboundDomain() {
16610
+ const env2 = typeof process !== "undefined" ? process.env.INBOUND_EMAIL_DOMAIN : void 0;
16611
+ return (env2 ?? "").trim().replace(/^@/, "").replace(/\.$/, "").toLowerCase() || "inbound.subcomply.localhost";
16612
+ }
16613
+ function sentStraightToCheckbox(e) {
16614
+ if (forwardedInfo(e).originalTo) return false;
16615
+ if (e.scanId || e.relatedSignupAt || e.subscriptionRef || e.testRun) return false;
16616
+ const addresses = (e.to ?? "").match(/[^\s<>,"]+@[^\s<>,"]+/g) ?? [];
16617
+ const domain = inboundDomain();
16618
+ return addresses.length > 0 && addresses.every((a) => domainOf(a) === domain);
16619
+ }
16620
+ var FWD_SUBJECT_RE, FWD_MARKER_RE, ADDRESS_RE, cache, MERCHANT_OF_RECORD_DOMAINS;
16621
+ var init_forwarded = __esm({
16622
+ "../rules/src/checks/forwarded.ts"() {
16623
+ "use strict";
16624
+ FWD_SUBJECT_RE = /^\s*(?:(?:re|aw|sv)\s*:\s*)*(?:fwd?|fw|wg|tr)\s*:/i;
16625
+ FWD_MARKER_RE = /(?:-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message\s*:|-{2,}\s*Original Message\s*-{2,}|-{2,}\s*Forwarded Message\s*-{2,}|^\s*_{8,}\s*$\s*^\s*From\s*:)/im;
16626
+ ADDRESS_RE = /[^\s<>,"':;()[\]]+@[^\s<>,"':;()[\]]+\.[a-z]{2,}/i;
16627
+ cache = /* @__PURE__ */ new WeakMap();
16628
+ MERCHANT_OF_RECORD_DOMAINS = ["paddle.com", "paddle.net", "lemonsqueezy.com", "fastspring.com"];
16629
+ }
16630
+ });
16631
+
16462
16632
  // ../rules/src/checks/confirmation.ts
16463
16633
  function isPostSignupEmail(e) {
16464
16634
  if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
@@ -16471,10 +16641,12 @@ function findAcknowledgments(emails) {
16471
16641
  if (cached) return cached;
16472
16642
  const acks = [];
16473
16643
  const receipts = [];
16474
- const acknowledged = new Set(emails.filter((e) => e.kind === "acknowledgment").map((e) => recipientOf(e)).filter((r) => r !== ""));
16644
+ const explicit = emails.filter((e) => e.kind === "acknowledgment");
16645
+ const acknowledged = new Set(explicit.map((e) => recipientOf(e)).filter((r) => r !== ""));
16475
16646
  for (const e of emails) {
16476
16647
  if (e.kind === "acknowledgment") acks.push(e);
16477
16648
  else if (acknowledged.has(recipientOf(e))) continue;
16649
+ else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
16478
16650
  else if (e.kind === "receipt" || isPostSignupEmail(e)) {
16479
16651
  (looksLikeSubscriptionTerms(analyzeTerms(emailBody(e))) ? acks : receipts).push(e);
16480
16652
  }
@@ -16484,6 +16656,8 @@ function findAcknowledgments(emails) {
16484
16656
  return set;
16485
16657
  }
16486
16658
  function recipientOf(e) {
16659
+ const original = forwardedInfo(e).originalTo;
16660
+ if (original) return original;
16487
16661
  const m = /[^\s<>,"]+@[^\s<>,"]+/.exec(e.to ?? "");
16488
16662
  return m ? m[0].toLowerCase() : "";
16489
16663
  }
@@ -16681,6 +16855,17 @@ function cancelBeforeTrialMatch(text) {
16681
16855
  if (byDate && TRIAL_ENDS_DATE_RE.test(text)) return byDate[0];
16682
16856
  return null;
16683
16857
  }
16858
+ function statedTrialMismatch(a, p, offers) {
16859
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
16860
+ if (!stated.length) return void 0;
16861
+ const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
16862
+ if (!own.length) return void 0;
16863
+ const fits = (days, trial) => days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
16864
+ if (stated.some((t) => own.some((o) => fits(t.days, o.trialDays)))) return void 0;
16865
+ const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
16866
+ const plan2 = p?.offer ? `${offerLabel(p.offer)} has a ${p.offer.trialDays}-day free trial` : `the plan${lengths.length > 1 ? "s give" : " gives"} a ${lengths.join("- or ")}-day free trial`;
16867
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
16868
+ }
16684
16869
  var ACK_MAX_DELAY_HOURS, NO_ACK_EMAIL_MESSAGE, ackCache, SIGNUP_LINK_TOLERANCE_MS, sent, terms, cancellationInstructions, CANCELLING_RE, CANCEL_BEFORE_TRIAL_RE, MONTH, DATE, CANCEL_BEFORE_NO_CHARGE_RE, CANCEL_BEFORE_DATE_RE, TRIAL_ENDS_DATE_RE, trialTerms, CONFIRMATION_CHECKS;
16685
16870
  var init_confirmation = __esm({
16686
16871
  "../rules/src/checks/confirmation.ts"() {
@@ -16693,6 +16878,7 @@ var init_confirmation = __esm({
16693
16878
  init_intro_terms();
16694
16879
  init_offer();
16695
16880
  init_email_plan();
16881
+ init_forwarded();
16696
16882
  ACK_MAX_DELAY_HOURS = 24;
16697
16883
  NO_ACK_EMAIL_MESSAGE = "No acknowledgment email captured yet \u2014 BCC your sign-up confirmation email to your Checkbox inbound address (see Install), or add a test purchase to your scan settings so our shopper inbox receives it.";
16698
16884
  ackCache = /* @__PURE__ */ new WeakMap();
@@ -16989,6 +17175,15 @@ ${v.pageText}`);
16989
17175
  );
16990
17176
  const units = trialDocs.map((doc) => {
16991
17177
  const a = doc.analysis;
17178
+ const wrongTrial = statedTrialMismatch(a, ackPlan(doc.email, input, offers), offers);
17179
+ if (wrongTrial) {
17180
+ return docUnit(doc, {
17181
+ status: "fail",
17182
+ detail: `says ${wrongTrial.stated}, but ${wrongTrial.plan}: state the trial the subscriber really gets`,
17183
+ confidence: 0.8,
17184
+ meta: { trialLengthMismatch: true }
17185
+ });
17186
+ }
16992
17187
  const method = a.cancelMethod[0];
16993
17188
  const timing = a.trialLengths[0] ?? a.postTrialCharge[0];
16994
17189
  const beforeMatch = cancelBeforeTrialMatch(a.text);
@@ -18309,6 +18504,17 @@ function requiredFor(reqs) {
18309
18504
  if (!out.size) for (const el of CHARGE_NOTICE_REQUIRED) out.add(el);
18310
18505
  return [...out];
18311
18506
  }
18507
+ function chargeGapPhrase(content, gaps, expected) {
18508
+ const wrong = gaps.filter((g) => content.wrong?.[g]);
18509
+ const absent = gaps.filter((g) => !content.wrong?.[g]);
18510
+ const parts = [];
18511
+ if (absent.length) parts.push(`do not state ${describeMissing(absent)}`);
18512
+ for (const g of wrong) {
18513
+ const said = content.wrong[g];
18514
+ parts.push(g === "amount" ? `states ${ELEMENT_LABELS[g]} as \u201C${said}\u201D${expected ? `, but ${expected}` : ""}` : `states ${ELEMENT_LABELS[g]} as \u201C${said}\u201D, which does not count`);
18515
+ }
18516
+ return parts.join("; ");
18517
+ }
18312
18518
  async function runChargeNotice(input) {
18313
18519
  const id = "reminder.charge_notice";
18314
18520
  const configRefs = input.data.configRef ? [input.data.configRef] : [];
@@ -18336,6 +18542,12 @@ async function runChargeNotice(input) {
18336
18542
  }
18337
18543
  return c;
18338
18544
  };
18545
+ const chargeExpected = (e) => {
18546
+ const own = inForceAmount(e, input.ctx.events ?? [], chargePlanOpts.plans[0]?.currency);
18547
+ const cur = (chargePlanOpts.plans[0]?.currency ?? "usd").toUpperCase();
18548
+ if (own !== void 0) return `this subscription\u2019s charge is ${own.toFixed(2)} ${cur}`;
18549
+ return chargePlanOpts.plans.length ? `the plan is ${chargePlanOpts.plans.map((p) => `${planAmount(p).toFixed(2)} ${p.currency.toUpperCase()} ${planEvery(p)}`).join(", ")}` : "";
18550
+ };
18339
18551
  const counted = judged.filter((j) => j.state === "matched" || j.state === "partial" || j.state === "outside_window" || j.state === "missing");
18340
18552
  const matched = counted.filter((j) => j.state === "matched");
18341
18553
  const partial = counted.filter((j) => j.state === "partial");
@@ -18367,10 +18579,12 @@ async function runChargeNotice(input) {
18367
18579
  return makeResult(
18368
18580
  id,
18369
18581
  "fail",
18370
- `${incompleteMatched.length} of ${matched.length} charge notice(s) do not state ${describeMissing(x.missing)} (e.g. ${emailLabel(x.j.email)})`,
18582
+ `${incompleteMatched.length} of ${matched.length} charge notice(s) ${chargeGapPhrase(content(x.j.email), x.missing, chargeExpected(x.j.email))} (e.g. ${emailLabel(x.j.email)})`,
18371
18583
  [...refs2(incompleteMatched.map((i) => i.j)), ...refs2(missing), ...refs2(outside)],
18372
18584
  0.75,
18373
- data
18585
+ // Round 29 (pilot 28 item 11): when every failing notice is Checkbox Notices' own send, the plan fixes Checkbox's
18586
+ // template and settings; it never asks the merchant's agent to change an email it does not send.
18587
+ incompleteMatched.every((x2) => isCheckboxSentEmail(x2.j.email)) && !missing.length && !outside.length ? { ...data, contentByCheckbox: true } : data
18374
18588
  );
18375
18589
  }
18376
18590
  if (outside.length) {
@@ -20663,9 +20877,9 @@ function limiter(max) {
20663
20877
  run2();
20664
20878
  }
20665
20879
  };
20666
- return async (fn) => new Promise((resolve9, reject) => {
20880
+ return async (fn) => new Promise((resolve10, reject) => {
20667
20881
  queue.push(() => {
20668
- fn().then(resolve9, reject).finally(() => {
20882
+ fn().then(resolve10, reject).finally(() => {
20669
20883
  active--;
20670
20884
  next();
20671
20885
  });
@@ -22031,7 +22245,7 @@ function classifyRaw(input) {
22031
22245
  }
22032
22246
  }
22033
22247
  function fixFromFail(c) {
22034
- return withPlanTerms(contentFixLine(String(c.checkId), c.data) ?? contentFixFromText(String(c.checkId), c.detail) ?? introFixFromText(c) ?? planFrequencyFix(c) ?? FAIL_FIX[String(c.checkId)] ?? "Apply the change in the task for this check.", c.data);
22248
+ return withPlanTerms(contentFixLine(String(c.checkId), c.data) ?? contentFixFromText(String(c.checkId), c.detail) ?? introFixFromText(c) ?? trialLengthFixFromText(c) ?? planFrequencyFix(c) ?? FAIL_FIX[String(c.checkId)] ?? "Apply the change in the task for this check.", c.data);
22035
22249
  }
22036
22250
  function withPlanTerms(fix, data) {
22037
22251
  if (!/<(?:price|interval)>/.test(fix)) return fix;
@@ -22051,6 +22265,11 @@ function introFixFromText(c) {
22051
22265
  const m = /introductory price of (\S+?)(?: \(\d+% off\))? for (the first [^—,]+?)\s*(?:,|—)/i.exec(c.detail);
22052
22266
  return m ? `State the introductory price (${m[1]} for ${m[2]}) and when it ends, then the regular price from the next charge, in the renewal line.` : "State the introductory price and how long it lasts, then the regular price from the next charge, in the renewal line.";
22053
22267
  }
22268
+ function trialLengthFixFromText(c) {
22269
+ if (c.checkId !== "confirmation.email.trial_terms") return void 0;
22270
+ const m = /says “([^”]+)”, but .*?\b(?:has|gives?) a ([\d -]+?or [\d]+|\d+)-day free trial/.exec(c.detail);
22271
+ return m ? `The acknowledgment says \u201C${m[1]}\u201D, but the plan has a ${m[2]}-day free trial: state the trial the subscriber really gets, and how to cancel before it ends.` : void 0;
22272
+ }
22054
22273
  function planFrequencyFix(c) {
22055
22274
  if (c.checkId !== "signup.disclosure.frequency") return void 0;
22056
22275
  const every = /\bplans? (?:is|are) billed (every [a-z0-9 ]+?)(?= \/ | —|[.;,]|$)/i.exec(c.detail)?.[1];
@@ -22136,7 +22355,7 @@ function classifyFindings(findings) {
22136
22355
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22137
22356
  }));
22138
22357
  }
22139
- var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22358
+ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, RENEWAL_LINE_EXAMPLE, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22140
22359
  var init_status = __esm({
22141
22360
  "../rules/src/status.ts"() {
22142
22361
  "use strict";
@@ -22332,7 +22551,7 @@ var init_status = __esm({
22332
22551
  },
22333
22552
  { code: "receipt_without_terms", category: "content", when: has(/only a receipt without subscription terms/), build: () => ({ status: "needs_fix", fix: "Send an acknowledgment that restates the renewal terms (price, frequency, renews until cancelled) and how to cancel; a plain receipt is not enough." }) },
22334
22553
  // Audit P2: Stripe wording (custom_text) only for Stripe inputs; any other checkout gets the same line, platform-neutral.
22335
- { code: "processor_default_line", category: "content", when: has(/payment processor.s default line|authorization line is not the renewal terms|no automatic-renewal statement was found/), build: (c) => ({ status: "needs_fix", fix: withPlanTerms(`Add your own renewal line next to the pay button${isStripeInput(c) ? " (Stripe custom_text.submit)" : ""}: "Renews automatically every <interval> at <price> until you cancel. Cancel anytime with your cancel link."`, c.data) }) },
22554
+ { code: "processor_default_line", category: "content", when: has(/payment processor.s default line|authorization line is not the renewal terms|no automatic-renewal statement was found/), build: (c) => ({ status: "needs_fix", fix: withPlanTerms(`Add your own renewal line next to the pay button${isStripeInput(c) ? " (Stripe custom_text.submit)" : ""}: "${RENEWAL_LINE_EXAMPLE} Cancel anytime with your cancel link."`, c.data) }) },
22336
22555
  { code: "renewal_without_until_cancel", category: "content", when: has(/but not that it continues until the customer cancels/), build: () => ({ status: "needs_fix", fix: 'Add "until you cancel" to the renewal line.' }) },
22337
22556
  { code: "terms_hidden", category: "content", when: has(/collapsed <details>|\(nearly\) the colour of its background|nearly the colour of the background|hidden by a stylesheet|sr-only/), build: () => ({ status: "needs_fix", fix: "Show the renewal line as plain visible text next to the button (not collapsed, not faded into the background)." }) },
22338
22557
  // Pilot round 11 (change-guard): an intro / discounted price the Stripe session does not apply.
@@ -22361,7 +22580,7 @@ var init_status = __esm({
22361
22580
  )
22362
22581
  })
22363
22582
  },
22364
- { code: "consent_implied", category: "content", when: has(/consent is implied by clicking/), build: () => ({ status: "needs_fix", fix: 'Add one unchecked checkbox "I agree to the automatic renewal terms above".' }) },
22583
+ { code: "consent_implied", category: "content", when: has(/consent is implied by clicking/), build: () => ({ status: "needs_fix", fix: 'Ask for a separate yes to the renewal: usually one unchecked box, "I agree to the automatic renewal terms above".' }) },
22365
22584
  { code: "consent_general_terms", category: "content", when: has(/covers general terms, not the/), build: () => ({ status: "needs_fix", fix: 'Make the checkbox about the renewal: "I agree to the automatic renewal terms above."' }) },
22366
22585
  // Round 23 (pilot 22 nontech-founder done blocker): "does not appear to be required" means the capture could not see
22367
22586
  // whether the box is enforced (a script may block the purchase, as Stripe's own page does). A person who can try it
@@ -22795,8 +23014,9 @@ var init_status = __esm({
22795
23014
  PORTAL_OFFLINE_FIX = 'Turn on "Cancel subscriptions" in the Stripe customer portal and link it from your account page, then replace the "email / call us to cancel" line with "Cancel anytime online in your account".';
22796
23015
  PAYMENT_LINK_PORTAL_FIX = `Turn on "Cancel subscriptions" in the Stripe customer portal (Stripe Dashboard \u2192 Settings \u2192 Billing \u2192 Customer portal), copy its billing.stripe.com login link and add it to your site's footer as a "Cancel subscription" link.`;
22797
23016
  PAYMENT_LINK_OFFLINE_FIX = `${PAYMENT_LINK_PORTAL_FIX} Then replace the "email / call us to cancel" line with "Cancel anytime online" and that link.`;
23017
+ RENEWAL_LINE_EXAMPLE = "<price> every <interval> (plus applicable taxes). Renews automatically until you cancel.";
22798
23018
  FAIL_FIX = {
22799
- "signup.disclosure.autorenew_language": 'Add one line under the price, before the card and email fields: "Renews automatically every <interval> at <price> until you cancel."',
23019
+ "signup.disclosure.autorenew_language": `Add one line under the price, before the card and email fields: "${RENEWAL_LINE_EXAMPLE}"`,
22800
23020
  "signup.disclosure.price": "State the recurring price in the renewal line (the amount actually charged each time).",
22801
23021
  "signup.disclosure.frequency": `State how often it is charged, in the plan's own interval ("every month", "every year").`,
22802
23022
  "signup.disclosure.cancellation_policy": 'Add "Cancel anytime online" to the renewal line, with your cancel link.',
@@ -22804,7 +23024,7 @@ var init_status = __esm({
22804
23024
  "signup.disclosure.term_length": 'Say how often it renews ("renews every month until you cancel").',
22805
23025
  "signup.disclosure.proximity": "Put the renewal line under the price, before the card and email fields and the pay button.",
22806
23026
  "signup.disclosure.conspicuous": "Show the renewal line at body size, semibold, in your normal text colour.",
22807
- "signup.consent.affirmative": 'Add one unchecked checkbox "I agree to the automatic renewal terms above".',
23027
+ "signup.consent.affirmative": 'Ask for a separate yes to the renewal: usually one unchecked box, "I agree to the automatic renewal terms above".',
22808
23028
  "signup.consent.not_prechecked": "Leave the consent checkbox unchecked by default.",
22809
23029
  "confirmation.email.sent": "Send an acknowledgment email right after sign-up (Checkbox has a template).",
22810
23030
  "confirmation.email.terms": "Restate the renewal terms (price, frequency, until cancelled) in the acknowledgment.",
@@ -25061,6 +25281,24 @@ var init_stripe2 = __esm({
25061
25281
  }
25062
25282
  });
25063
25283
 
25284
+ // ../rules/src/preflight/stripe-verdict.ts
25285
+ function stripeParamsVerdict(r) {
25286
+ if (r.verdict === "not_applicable") return { verdict: "not_applicable", open: [] };
25287
+ const open2 = r.checks.filter((c) => c.status === "fail" || c.status === "needs_review" && !(c.compliance_status && SATISFIED_COMPLIANCE.has(c.compliance_status))).map((c) => ({ check_id: c.check_id, status: c.status, title: c.title ?? c.check_id, detail: c.explanation ?? "" })).sort((a, b) => (a.status === "fail" ? 0 : 1) - (b.status === "fail" ? 0 : 1));
25288
+ if (r.verdict === "fail" || r.compliance_status === "needs_fix" || open2.some((c) => c.status === "fail")) return { verdict: "fail", open: open2 };
25289
+ const coupon = (r.warnings ?? []).find((w) => w.code === "coupon_unverified");
25290
+ if (open2.length) return { verdict: "needs_review", open: open2, ...coupon ? { reason: coupon.message } : {} };
25291
+ if (coupon) return { verdict: "needs_review", open: [], reason: coupon.message };
25292
+ return { verdict: "pass", open: [] };
25293
+ }
25294
+ var SATISFIED_COMPLIANCE;
25295
+ var init_stripe_verdict = __esm({
25296
+ "../rules/src/preflight/stripe-verdict.ts"() {
25297
+ "use strict";
25298
+ SATISFIED_COMPLIANCE = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
25299
+ }
25300
+ });
25301
+
25064
25302
  // ../rules/src/preflight/email.ts
25065
25303
  function linkify(escaped) {
25066
25304
  return escaped.replace(/https?:\/\/[^\s<]+/g, (raw) => {
@@ -25244,6 +25482,10 @@ function contextFor(kind, occ, plan2, jurisdictions, asOf, email) {
25244
25482
  function longDate2(d) {
25245
25483
  return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
25246
25484
  }
25485
+ function supportContactLine(contact, opts = {}) {
25486
+ if (contact || opts.required) return `Questions? Reply to this email or contact us at ${contact || SUPPORT_CONTACT_PLACEHOLDER}.`;
25487
+ return "Questions? Reply to this email.";
25488
+ }
25247
25489
  function emailTemplate(kind, plan2, opts = {}) {
25248
25490
  const brand = plan2.merchantName ?? "";
25249
25491
  const sub = `${brand ? `${brand} ` : ""}subscription`;
@@ -25251,7 +25493,7 @@ function emailTemplate(kind, plan2, opts = {}) {
25251
25493
  const every = intervalPhrase(plan2.interval, plan2.intervalCount);
25252
25494
  const cancel = plan2.cancelUrl ?? CANCEL_URL_PLACEHOLDER;
25253
25495
  const effect = plan2.cancelTiming === "immediately" ? "cancellation takes effect immediately" : "cancellation takes effect at the end of your current billing period";
25254
- const contact = opts.supportContact ? `Questions? Contact us at ${opts.supportContact}.` : "Questions? Reply to this email.";
25496
+ const contact = supportContactLine(opts.supportContact);
25255
25497
  const lines = (...l) => l.filter(Boolean).join("\n\n");
25256
25498
  const intro = plan2.introAmount !== void 0 && plan2.introAmount < plan2.amount ? `${formatMoney(plan2.introAmount, plan2.currency)} for ${plan2.introPeriods && plan2.introPeriods > 1 ? `your first ${plan2.introPeriods} billing periods` : `your first ${every}`}` : "";
25257
25499
  const introLine = intro ? `Introductory price: ${intro}, then ${money3} every ${every}.` : "";
@@ -25259,11 +25501,13 @@ function emailTemplate(kind, plan2, opts = {}) {
25259
25501
  case "acknowledgment":
25260
25502
  return {
25261
25503
  subject: `Your ${sub} is confirmed`,
25504
+ // Round 31 (pilot 30 item 22): the same sentences, in the same order, as the "You send" acknowledgment template
25505
+ // (apps/api notices/merchant-templates.ts), so the plan, checkEmail and the due notice give one email.
25262
25506
  text: lines(
25263
- `Thanks for subscribing${brand ? ` to ${brand}` : ""}! This email confirms your subscription terms.`,
25264
- introLine,
25507
+ `Thanks for subscribing${brand ? ` to ${brand}` : ""}. This email confirms your subscription terms.`,
25265
25508
  plan2.trialDays ? `Your free trial lasts ${plan2.trialDays} days and ends on {{trial_end_date}}. To avoid being charged, cancel before your trial ends at ${cancel} \u2014 it takes less than a minute. If you do nothing, your subscription starts automatically on {{trial_end_date}} and you will be charged ${money3} every ${every}.` : "",
25266
- `Your ${sub} automatically renews every ${every} at ${money3} until you cancel. Your next charge is on {{next_charge_date}}.`,
25509
+ `Your ${sub} automatically renews every ${every} at ${money3} until you cancel. Your ${plan2.trialDays ? "first" : "next"} charge is on {{next_charge_date}}.`,
25510
+ introLine,
25267
25511
  `How to cancel: go to ${cancel} \u2014 you can cancel anytime online; ${effect}.`,
25268
25512
  contact
25269
25513
  ),
@@ -25319,7 +25563,7 @@ function emailTemplate(kind, plan2, opts = {}) {
25319
25563
  `To stop future charges, cancel online anytime at ${cancel}.`,
25320
25564
  // Pilot round 11 (invariant test): Georgia's charge notice must give the business's contact information;
25321
25565
  // "reply to this email" is not one, so without a support contact the template asks for it.
25322
- opts.supportContact ? contact : `Questions? Contact us at ${SUPPORT_CONTACT_PLACEHOLDER}.`
25566
+ supportContactLine(opts.supportContact, { required: true })
25323
25567
  ),
25324
25568
  // Round 12: the template used {{support_contact}} without listing it as a merge tag to fill.
25325
25569
  tags: ["{{charge_date}}", "{{next_charge_date}}", ...opts.supportContact ? [] : [SUPPORT_CONTACT_PLACEHOLDER]]
@@ -25860,6 +26104,7 @@ __export(preflight_exports, {
25860
26104
  PREFLIGHT_DISCLAIMER: () => PREFLIGHT_DISCLAIMER,
25861
26105
  PREFLIGHT_DOCS_URL: () => PREFLIGHT_DOCS_URL,
25862
26106
  PreflightInputError: () => PreflightInputError,
26107
+ SATISFIED_COMPLIANCE: () => SATISFIED_COMPLIANCE,
25863
26108
  STRIPE_HOSTED_STYLING_NOTE: () => STRIPE_HOSTED_STYLING_NOTE,
25864
26109
  checkDisclosure: () => checkDisclosure,
25865
26110
  checkEmail: () => checkEmail,
@@ -25881,6 +26126,8 @@ __export(preflight_exports, {
25881
26126
  runChecks: () => runChecks,
25882
26127
  shapeChecks: () => shapeChecks,
25883
26128
  signupStripeObjects: () => signupStripeObjects,
26129
+ stripeParamsVerdict: () => stripeParamsVerdict,
26130
+ supportContactLine: () => supportContactLine,
25884
26131
  withIntroOffers: () => withIntroOffers
25885
26132
  });
25886
26133
  var init_preflight = __esm({
@@ -25889,6 +26136,7 @@ var init_preflight = __esm({
25889
26136
  init_common();
25890
26137
  init_disclosure();
25891
26138
  init_stripe2();
26139
+ init_stripe_verdict();
25892
26140
  init_email();
25893
26141
  init_html_page();
25894
26142
  init_page();
@@ -25904,7 +26152,7 @@ __export(check_exports, {
25904
26152
  default: () => check_default
25905
26153
  });
25906
26154
  import { readFileSync as readFileSync3 } from "node:fs";
25907
- import { resolve } from "node:path";
26155
+ import { resolve as resolve2 } from "node:path";
25908
26156
  function operationFor(endpoint) {
25909
26157
  const op = Object.values(OPERATIONS).find((o) => o.httpMethod === "POST" && o.path === endpoint);
25910
26158
  if (!op) throw new Error(`No API operation for POST ${endpoint}`);
@@ -25913,7 +26161,7 @@ function operationFor(endpoint) {
25913
26161
  async function readInput(ctx, file) {
25914
26162
  if (file === "-") return ctx.readStdin();
25915
26163
  try {
25916
- return readFileSync3(resolve(ctx.cwd, file), "utf8");
26164
+ return readFileSync3(resolve2(ctx.cwd, file), "utf8");
25917
26165
  } catch (err) {
25918
26166
  throw new UsageError(`Cannot read ${file}: ${err.code ?? err.message}`);
25919
26167
  }
@@ -26201,7 +26449,7 @@ var init_check = __esm({
26201
26449
 
26202
26450
  // src/ci/config.ts
26203
26451
  import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
26204
- import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve2 } from "node:path";
26452
+ import { dirname as dirname3, isAbsolute, join as join3, resolve as resolve3 } from "node:path";
26205
26453
  function planList(config, ref) {
26206
26454
  if (Array.isArray(ref)) return ref;
26207
26455
  if (ref === "all" && !(config.plans ?? []).some((p) => p.name === "all")) return (config.plans ?? []).map((p) => p.name);
@@ -26209,7 +26457,7 @@ function planList(config, ref) {
26209
26457
  }
26210
26458
  function findConfig(cwd, explicit) {
26211
26459
  if (explicit) {
26212
- const p = isAbsolute(explicit) ? explicit : resolve2(cwd, explicit);
26460
+ const p = isAbsolute(explicit) ? explicit : resolve3(cwd, explicit);
26213
26461
  return existsSync3(p) ? p : void 0;
26214
26462
  }
26215
26463
  for (const f of CONFIG_FILES) if (existsSync3(join3(cwd, f))) return join3(cwd, f);
@@ -26230,7 +26478,7 @@ function loadConfig(path) {
26230
26478
  }
26231
26479
  const issues = validateConfig(value);
26232
26480
  if (issues.length) throw new ConfigError(path, issues);
26233
- return { config: value, path, dir: dirname2(path), raw };
26481
+ return { config: value, path, dir: dirname3(path), raw };
26234
26482
  }
26235
26483
  function validateConfig(value) {
26236
26484
  const is = new Issues();
@@ -26509,8 +26757,8 @@ var init_config3 = __esm({
26509
26757
  });
26510
26758
 
26511
26759
  // src/commands/init/literal.ts
26512
- function parseObjectLiteral(source, env2 = {}) {
26513
- const p = new Parser(source, env2);
26760
+ function parseObjectLiteral(source, env2 = {}, consts = {}) {
26761
+ const p = new Parser(source, env2, consts);
26514
26762
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
26515
26763
  const v = p.object("");
26516
26764
  return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
@@ -26520,10 +26768,11 @@ var init_literal = __esm({
26520
26768
  "src/commands/init/literal.ts"() {
26521
26769
  "use strict";
26522
26770
  UNKNOWN = Symbol("unknown");
26523
- Parser = class {
26524
- constructor(s2, env2) {
26771
+ Parser = class _Parser {
26772
+ constructor(s2, env2, consts = {}) {
26525
26773
  this.s = s2;
26526
26774
  this.env = env2;
26775
+ this.consts = consts;
26527
26776
  }
26528
26777
  i = 0;
26529
26778
  unresolved = [];
@@ -26613,6 +26862,12 @@ var init_literal = __esm({
26613
26862
  if (name === "false") return false;
26614
26863
  if (name === "null") return null;
26615
26864
  if (name === "undefined") return void 0;
26865
+ if (Object.prototype.hasOwnProperty.call(this.consts, name)) {
26866
+ const sub = new _Parser(this.consts[name], this.env);
26867
+ const v = sub.value("");
26868
+ if (v !== UNKNOWN && v !== void 0 && path) this.fromEnv[path] = name;
26869
+ return v;
26870
+ }
26616
26871
  const env2 = /^(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
26617
26872
  if (env2) {
26618
26873
  if (this.env[env2[1]] !== void 0 && path) this.fromEnv[path] = name;
@@ -26932,11 +27187,17 @@ function findStripeKey(env2, processEnv) {
26932
27187
  sources.push([`${k} (environment)`, processEnv[k]]);
26933
27188
  sources.push([`${k} (.env)`, env2[k]]);
26934
27189
  }
26935
- const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_test_[A-Za-z0-9]{10,}$/.test(s2[1].trim()));
26936
- const pick = found.find((s2) => s2[1].startsWith("rk_test_")) ?? found[0];
26937
- if (!pick) return void 0;
26938
- const key = pick[1].trim();
26939
- return { key, hint: `${key.slice(0, 8)}\u2026${key.slice(-4)}`, source: pick[0], restricted: key.startsWith("rk_test_") };
27190
+ const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_(test|live)_[A-Za-z0-9]{10,}$/.test(s2[1].trim())).map(([src, v]) => [src, v.trim()]);
27191
+ const hint = (key) => `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
27192
+ const pick = found.find((s2) => s2[1].startsWith("rk_test_"));
27193
+ if (pick) return { key: { key: pick[1], hint: hint(pick[1]), source: pick[0], restricted: true } };
27194
+ const other = found.find((s2) => s2[1].startsWith("sk_test_")) ?? found[0];
27195
+ if (!other) return {};
27196
+ return { refused: { hint: hint(other[1]), source: other[0], kind: /_live_/.test(other[1]) ? "live" : "secret" } };
27197
+ }
27198
+ function stripeKeyRefusedMessage(r) {
27199
+ const what = r.kind === "live" ? `${r.hint} in ${r.source} is a LIVE Stripe key. Live keys are never stored by the CLI or an agent: a person adds them in the Checkbox dashboard.` : `${r.hint} in ${r.source} is the Stripe secret key, which can do everything in your Stripe test account, so Checkbox refuses it.`;
27200
+ return `${what} For test runs, make a restricted TEST key instead: in Stripe, switch to Test mode, open Developers \u2192 API keys \u2192 Create restricted key, tick the permissions on https://checkbox.site/docs/guides/stripe-key, and put the key (rk_test_\u2026) in STRIPE_RESTRICTED_KEY in .env.local (never in a chat). Then run \`checkbox init\` again.`;
26940
27201
  }
26941
27202
  function sessionLiteral(text, index) {
26942
27203
  const open2 = text.indexOf("{", index);
@@ -26951,7 +27212,13 @@ function sessionLiteral(text, index) {
26951
27212
  else if (ch === quote2) quote2 = null;
26952
27213
  continue;
26953
27214
  }
26954
- if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27215
+ if (ch === "/" && text[i + 1] === "/") {
27216
+ const nl = text.indexOf("\n", i);
27217
+ i = nl === -1 ? text.length : nl;
27218
+ } else if (ch === "/" && text[i + 1] === "*") {
27219
+ const end = text.indexOf("*/", i + 2);
27220
+ i = end === -1 ? text.length : end + 1;
27221
+ } else if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
26955
27222
  else if (ch === "{") depth++;
26956
27223
  else if (ch === "}") {
26957
27224
  depth--;
@@ -26960,6 +27227,58 @@ function sessionLiteral(text, index) {
26960
27227
  }
26961
27228
  return null;
26962
27229
  }
27230
+ function declaredExpr(text, from) {
27231
+ let depth = 0;
27232
+ let quote2 = null;
27233
+ for (let i = from; i < text.length && i - from < 2e3; i++) {
27234
+ const ch = text[i];
27235
+ if (quote2) {
27236
+ if (ch === "\\") i++;
27237
+ else if (ch === quote2) quote2 = null;
27238
+ continue;
27239
+ }
27240
+ if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27241
+ else if (ch === "(" || ch === "[" || ch === "{") depth++;
27242
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
27243
+ else if ((ch === ";" || ch === "\n") && depth <= 0) return text.slice(from, i).trim();
27244
+ }
27245
+ return text.slice(from, from + 2e3).trim();
27246
+ }
27247
+ function constDeclarations(text, exportedOnly) {
27248
+ const out = {};
27249
+ const re = exportedOnly ? /(?:^|\n)[ \t]*export\s+const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g : /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g;
27250
+ for (const m of text.matchAll(re)) if (!(m[1] in out)) out[m[1]] = declaredExpr(text, m.index + m[0].length);
27251
+ return out;
27252
+ }
27253
+ function resolveImport(root, fromFile, spec) {
27254
+ const dir = fromFile.includes("/") ? fromFile.slice(0, fromFile.lastIndexOf("/")) : "";
27255
+ const bases = spec.startsWith(".") ? [join4(dir, spec)] : /^[@~]\//.test(spec) ? [spec.slice(2), join4("src", spec.slice(2))] : [];
27256
+ for (const b of bases) {
27257
+ const base = b.split("\\").join("/").replace(/\.(?:[cm]?js|jsx)$/, "");
27258
+ for (const f of [b, ...[".ts", ".tsx", ".js", ".mjs", ".jsx", "/index.ts", "/index.js"].map((e) => `${base}${e}`)]) {
27259
+ if (/\.[cm]?[jt]sx?$/.test(f) && existsSync4(join4(root, f))) return f.split("\\").join("/");
27260
+ }
27261
+ }
27262
+ return void 0;
27263
+ }
27264
+ function constantsFor(root, relFile, text) {
27265
+ const out = {};
27266
+ for (const m of text.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
27267
+ const target = resolveImport(root, relFile, m[2]);
27268
+ if (!target) continue;
27269
+ let exported;
27270
+ try {
27271
+ exported = constDeclarations(readFileSync5(join4(root, target), "utf8"), true);
27272
+ } catch {
27273
+ continue;
27274
+ }
27275
+ for (const part of m[1].split(",")) {
27276
+ const [name, alias] = part.trim().split(/\s+as\s+/).map((x) => x.trim());
27277
+ if (name && exported[name] !== void 0) out[alias || name] = exported[name];
27278
+ }
27279
+ }
27280
+ return { ...out, ...constDeclarations(text, false) };
27281
+ }
26963
27282
  function detectProject(root, processEnv = {}) {
26964
27283
  const pkg = readJson(join4(root, "package.json"));
26965
27284
  const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
@@ -26996,7 +27315,7 @@ function detectProject(root, processEnv = {}) {
26996
27315
  checkoutSessions.push({ file: rel2, line: line2, unresolved: ["<params are not an object literal>"] });
26997
27316
  continue;
26998
27317
  }
26999
- const parsed = parseObjectLiteral(lit, { ...env2, ...Object.fromEntries(Object.entries(processEnv).filter((e) => typeof e[1] === "string" && /^(STRIPE_|NEXT_PUBLIC_STRIPE_|PRICE_)/.test(e[0]))) });
27318
+ const parsed = parseObjectLiteral(lit, { ...env2, ...Object.fromEntries(Object.entries(processEnv).filter((e) => typeof e[1] === "string" && /^(STRIPE_|NEXT_PUBLIC_STRIPE_|PRICE_)/.test(e[0]))) }, constantsFor(root, rel2, text));
27000
27319
  checkoutSessions.push({ file: rel2, line: line2, ...parsed.value ? { params: redactSecretsDeep(parsed.value) } : {}, unresolved: parsed.unresolved });
27001
27320
  }
27002
27321
  });
@@ -27018,7 +27337,8 @@ function detectProject(root, processEnv = {}) {
27018
27337
  checkoutCode,
27019
27338
  payButtons,
27020
27339
  checkoutSessions,
27021
- ...stripeKey ? { stripeTestKey: stripeKey } : {},
27340
+ ...stripeKey.key ? { stripeTestKey: stripeKey.key } : {},
27341
+ ...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
27022
27342
  ...brand ? { brand } : {},
27023
27343
  plans: plansFromSessions(checkoutSessions),
27024
27344
  env: env2
@@ -27059,7 +27379,7 @@ var init_detect = __esm({
27059
27379
 
27060
27380
  // src/ci/scaffold.ts
27061
27381
  import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27062
- import { basename, dirname as dirname3, join as join5, relative as relative2 } from "node:path";
27382
+ import { basename, dirname as dirname4, join as join5, relative as relative2 } from "node:path";
27063
27383
  function walk3(root, visit) {
27064
27384
  let n = 0;
27065
27385
  const go = (dir, depth) => {
@@ -27128,11 +27448,38 @@ function paramsBuilder(root, file, line2) {
27128
27448
  const imp = new RegExp(`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from\\s*['"](\\.{1,2}/[^'"]+)['"]`).exec(text);
27129
27449
  let target;
27130
27450
  if (imp) {
27131
- const base = join5(dirname3(file), imp[1]).split("\\").join("/");
27451
+ const base = join5(dirname4(file), imp[1]).split("\\").join("/");
27132
27452
  target = [base, ...[".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.js"].map((e) => `${base.replace(/\.(?:[cm]?js)$/, "")}${e}`)].find((f) => /\.[cm]?[jt]sx?$/.test(f) && existsSync5(join5(root, f)));
27133
27453
  } else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
27134
27454
  return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
27135
27455
  }
27456
+ function plansSoldBy(params, plans) {
27457
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
27458
+ const out = [];
27459
+ for (const li of items) {
27460
+ const pd = li?.price_data;
27461
+ if (!pd || typeof pd.unit_amount !== "number") continue;
27462
+ const match = plans.find((p) => (typeof p.amount === "number" ? p.amount : typeof p.price === "number" ? Math.round(p.price * 100) : NaN) === pd.unit_amount && p.interval === pd.recurring?.interval);
27463
+ if (match && !out.includes(match)) out.push(match);
27464
+ }
27465
+ return out;
27466
+ }
27467
+ function cancelUrlInCode(sessions) {
27468
+ const urls = /* @__PURE__ */ new Set();
27469
+ for (const s2 of sessions) {
27470
+ const text = JSON.stringify(s2.params?.custom_text ?? "");
27471
+ for (const m of text.matchAll(/cancel[^"]{0,80}?(https:\/\/[^\s"'<>)]+?)(?=[.,;:!?]?(?:[\s"'<>)]|$))/gi)) urls.add(m[1]);
27472
+ }
27473
+ return urls.size === 1 ? [...urls][0] : void 0;
27474
+ }
27475
+ function pageSource(root, built) {
27476
+ const route = /(?:^|\/)(?:public|dist|out|build|static|_site|www)\/(?:(.*)\/)?([^/]+?)(?:\/index)?\.html?$/i.exec(built);
27477
+ if (!route) return void 0;
27478
+ const path = [route[1], route[2]].filter(Boolean).join("/");
27479
+ const bases = [`app/${path}/page`, `src/app/${path}/page`, `app/(marketing)/${path}/page`, `pages/${path}`, `src/pages/${path}`, `src/routes/${path}/+page`, `pages/${path}/index`, `src/pages/${path}/index`];
27480
+ const hits = bases.flatMap((b) => [".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".astro", ".mdx"].map((e) => `${b}${e}`)).filter((f) => existsSync5(join5(root, f)));
27481
+ return hits.length === 1 ? hits[0] : void 0;
27482
+ }
27136
27483
  function scaffoldConfig(root, env2 = {}, facts = {}) {
27137
27484
  const det = detectProject(root, env2);
27138
27485
  const todos = [];
@@ -27151,13 +27498,21 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27151
27498
  merchant.cancel_url = cancelFact;
27152
27499
  found.push(`cancel URL ${cancelFact} (from checkbox init)`);
27153
27500
  } else if (siteUrl && cancelPath) merchant.cancel_url = `${originOf(siteUrl)}${cancelPath}`;
27154
- else {
27501
+ else if (cancelUrlInCode(det.checkoutSessions)) {
27502
+ merchant.cancel_url = cancelUrlInCode(det.checkoutSessions);
27503
+ found.push(`cancel URL ${String(merchant.cancel_url)} (from the Checkout custom_text)`);
27504
+ } else {
27155
27505
  merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
27156
27506
  todos.push("merchant.cancel_url: the page where subscribers cancel online");
27157
27507
  }
27158
27508
  if (fact("support_email")) merchant.support_contact = fact("support_email");
27509
+ const initPlans = Array.isArray(facts.plans) ? facts.plans : [];
27510
+ const initName = (p) => {
27511
+ const m = initPlans.find((x) => typeof x.name === "string" && x.amount === p.amount && x.interval === p.interval && (Number(x.interval_count ?? 1) || 1) === (p.interval_count ?? 1));
27512
+ return m ? String(m.name) : void 0;
27513
+ };
27159
27514
  const plans = det.plans.map((p, i) => ({
27160
- name: p.name ?? `Plan ${i + 1}`,
27515
+ name: p.name ?? initName(p) ?? `Plan ${i + 1}`,
27161
27516
  amount: p.amount,
27162
27517
  currency: p.currency,
27163
27518
  interval: p.interval,
@@ -27196,8 +27551,13 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27196
27551
  todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
27197
27552
  }
27198
27553
  const planNames = plans.map((p) => String(p.name));
27199
- const planFor = () => plans.length > 1 ? { plan: `TODO: one of ${planNames.map((n) => JSON.stringify(n)).join(", ")}` } : {};
27200
- if (plans.length > 1) todos.push("plan: pick the plan of each disclosure, page and email entry");
27554
+ const oneOf = planNames.map((n) => JSON.stringify(n)).join(", ");
27555
+ let planTodo = false;
27556
+ const planFor = (kind = "entry") => {
27557
+ if (plans.length <= 1) return {};
27558
+ planTodo = true;
27559
+ return { plan: kind === "page" ? `TODO: "all" (the page shows every plan) or one of ${oneOf}` : `TODO: one of ${oneOf}` };
27560
+ };
27201
27561
  const stripe = [];
27202
27562
  const perFile = /* @__PURE__ */ new Map();
27203
27563
  for (const s2 of det.checkoutSessions) {
@@ -27228,7 +27588,12 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27228
27588
  } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
27229
27589
  else found.push(`${s2.file}:${s2.line}: Checkout Session`);
27230
27590
  entry.strict = true;
27231
- stripe.push(entry);
27591
+ const sold = s2.params ? plansSoldBy(s2.params, plans) : [];
27592
+ if (plans.length === 1 || sold.length === 1) stripe.push({ ...entry, plan: String((sold[0] ?? plans[0]).name) });
27593
+ else if (s2.params && plans.length > 1 && !plans.some((p) => String(p.name).startsWith("TODO"))) {
27594
+ for (const p of plans) stripe.push({ ...entry, name: unique(names, `${String(entry.name).replace(/\)$/, "")}: ${String(p.name)})`), plan: String(p.name) });
27595
+ names.delete(String(entry.name));
27596
+ } else stripe.push(entry);
27232
27597
  }
27233
27598
  const emails = [];
27234
27599
  const pages = [];
@@ -27248,7 +27613,8 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27248
27613
  }
27249
27614
  if (PAGE_FILE.test(rel2)) {
27250
27615
  const kind = /checkout|subscribe/i.test(rel2) ? "checkout" : "pricing";
27251
- pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor() });
27616
+ const src = pageSource(root, rel2);
27617
+ pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor("page"), ...src ? { source: src } : {} });
27252
27618
  found.push(`${rel2}: ${kind} page`);
27253
27619
  return;
27254
27620
  }
@@ -27268,6 +27634,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27268
27634
  if (!disclosures.length && det.payButtons.length) {
27269
27635
  todos.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
27270
27636
  }
27637
+ if (planTodo) todos.push('plan: pick the plan of each disclosure, page and email entry marked TODO ("all" for a page that shows every plan)');
27271
27638
  const checks = {};
27272
27639
  if (stripe.length) checks.stripe_checkout = stripe;
27273
27640
  if (disclosures.length) checks.disclosures = disclosures;
@@ -27315,7 +27682,7 @@ var init_scaffold = __esm({
27315
27682
 
27316
27683
  // src/ci/coverage.ts
27317
27684
  import { readFileSync as readFileSync7 } from "node:fs";
27318
- import { join as join6, relative as relative3, resolve as resolve3 } from "node:path";
27685
+ import { join as join6, relative as relative3, resolve as resolve4 } from "node:path";
27319
27686
  function callsIn(text, file) {
27320
27687
  const hits = [];
27321
27688
  for (const [re, kind] of PATTERNS) {
@@ -27337,7 +27704,7 @@ function callsIn(text, file) {
27337
27704
  }
27338
27705
  function coverageOf(loaded, root, files) {
27339
27706
  const cfg = loaded.config;
27340
- const toRoot = (p) => relative3(root, resolve3(loaded.dir, p)).split("\\").join("/");
27707
+ const toRoot = (p) => relative3(root, resolve4(loaded.dir, p)).split("\\").join("/");
27341
27708
  const calls = [];
27342
27709
  const visit = (rel2) => {
27343
27710
  if (!CODE.test(rel2) || TESTISH.test(rel2)) return;
@@ -27438,10 +27805,10 @@ var init_coverage = __esm({
27438
27805
  // src/ci/inputs.ts
27439
27806
  import { execFile } from "node:child_process";
27440
27807
  import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
27441
- import { dirname as dirname4, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve4 } from "node:path";
27808
+ import { dirname as dirname5, isAbsolute as isAbsolute2, join as join7, relative as relative4, resolve as resolve5 } from "node:path";
27442
27809
  import { pathToFileURL as pathToFileURL2 } from "node:url";
27443
27810
  function readRel(dir, file) {
27444
- const abs = isAbsolute2(file) ? file : resolve4(dir, file);
27811
+ const abs = isAbsolute2(file) ? file : resolve5(dir, file);
27445
27812
  try {
27446
27813
  return readFileSync8(abs, "utf8");
27447
27814
  } catch (err) {
@@ -27452,26 +27819,42 @@ function readRel(dir, file) {
27452
27819
  function hasTsx(dir) {
27453
27820
  for (let d = dir, i = 0; i < 8; i++) {
27454
27821
  if (existsSync6(join7(d, "node_modules", "tsx", "package.json"))) return true;
27455
- const up = dirname4(d);
27822
+ const up = dirname5(d);
27456
27823
  if (up === d) break;
27457
27824
  d = up;
27458
27825
  }
27459
27826
  return false;
27460
27827
  }
27828
+ function declaresTsx(dir) {
27829
+ for (let d = dir, i = 0; i < 8; i++) {
27830
+ try {
27831
+ const pkg = JSON.parse(readFileSync8(join7(d, "package.json"), "utf8"));
27832
+ if (pkg.dependencies?.tsx || pkg.devDependencies?.tsx) return true;
27833
+ } catch {
27834
+ }
27835
+ const up = dirname5(d);
27836
+ if (up === d) break;
27837
+ d = up;
27838
+ }
27839
+ return false;
27840
+ }
27841
+ function addTsx(dir, detail = "") {
27842
+ return declaresTsx(dir) ? NOT_INSTALLED : `Add tsx to the project (npm install -D tsx${detail})`;
27843
+ }
27461
27844
  function loaderFlags(file, dir, nodeVersion = process.versions.node, features = process.features) {
27462
27845
  if (/\.[cm]?js$/.test(file)) return [];
27463
27846
  if (hasTsx(dir)) return ["--import", "tsx"];
27464
- if (/\.[jt]sx$/.test(file)) return `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
27847
+ if (/\.[jt]sx$/.test(file)) return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
27465
27848
  if (features.typescript) return [];
27466
27849
  const [major, minor] = nodeVersion.split(".").map(Number);
27467
27850
  if (major > 22 || major === 22 && minor >= 6) return ["--experimental-strip-types"];
27468
- return `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
27851
+ return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
27469
27852
  }
27470
27853
  function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
27471
27854
  const hash = ref.lastIndexOf("#");
27472
27855
  const file = hash > 0 ? ref.slice(0, hash) : ref;
27473
27856
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
27474
- const abs = resolve4(dir, file);
27857
+ const abs = resolve5(dir, file);
27475
27858
  if (!existsSync6(abs)) return Promise.reject(new InputError(`${file} does not exist (paths are relative to checkbox.json)`));
27476
27859
  const flags = loaderFlags(file, dir);
27477
27860
  if (typeof flags === "string") return Promise.reject(new InputError(flags));
@@ -27512,19 +27895,19 @@ function moduleErrorMessage(stderr, dir, flags = []) {
27512
27895
  }
27513
27896
  const noExt = !/\.[cm]?[jt]sx?$|\.json$/.test(target);
27514
27897
  if (noExt && !tsx) {
27515
- return `${rel2(from)} imports ${rel2(target)} without a file extension, which Node cannot resolve on its own. Add tsx to the project (npm install -D tsx; checkbox ci then imports modules the way your bundler does), or write the import with its extension (e.g. './${rel2(target).split("/").pop()}.ts').`;
27898
+ return `${rel2(from)} imports ${rel2(target)} without a file extension, which Node cannot resolve on its own. ${addTsx(dir, "; checkbox ci then imports modules the way your bundler does")}, or write the import with its extension (e.g. './${rel2(target).split("/").pop()}.ts').`;
27516
27899
  }
27517
27900
  return `${rel2(from)} imports ${rel2(target)}, which does not exist${noExt ? " (tsx found no .ts / .js file of that name either)" : ""}.`;
27518
27901
  }
27519
27902
  if (/ERR_UNKNOWN_FILE_EXTENSION/.test(text)) {
27520
27903
  const file = /Unknown file extension "([^"]+)" for (\S+)/.exec(text);
27521
- return `Node cannot import ${file ? `${rel2(file[2])} (${file[1]})` : "a file the module imports"}. Add tsx to the project (npm install -D tsx), or point the entry at a .js / .ts file that imports no ${file?.[1] ?? "such"} files.`;
27904
+ return `Node cannot import ${file ? `${rel2(file[2])} (${file[1]})` : "a file the module imports"}. ${addTsx(dir)}, or point the entry at a .js / .ts file that imports no ${file?.[1] ?? "such"} files.`;
27522
27905
  }
27523
27906
  const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^at /.test(l) && !/^Node\.js v\d/.test(l));
27524
27907
  const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
27525
27908
  return first2.replace(/file:\/\/\S+/g, (u) => rel2(u)).replace(/\s+/g, " ").slice(0, 400);
27526
27909
  }
27527
- function paramsFromSource(text, file, call = 1, env2 = {}) {
27910
+ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
27528
27911
  const calls = [...text.matchAll(/checkout\.sessions\.create\s*\(/g)];
27529
27912
  if (!calls.length) throw new InputError(`${file} has no stripe.checkout.sessions.create( call`);
27530
27913
  const m = calls[call - 1];
@@ -27532,7 +27915,7 @@ function paramsFromSource(text, file, call = 1, env2 = {}) {
27532
27915
  const line2 = text.slice(0, m.index).split("\n").length;
27533
27916
  const lit = sessionLiteral(text, m.index + m[0].length);
27534
27917
  if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
27535
- const parsed = parseObjectLiteral(lit, env2);
27918
+ const parsed = parseObjectLiteral(lit, env2, consts);
27536
27919
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
27537
27920
  return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
27538
27921
  }
@@ -27552,7 +27935,7 @@ function exportLine(dir, ref) {
27552
27935
  const name = hash > 0 ? ref.slice(hash + 1) : "default";
27553
27936
  let text;
27554
27937
  try {
27555
- text = readFileSync8(resolve4(dir, file), "utf8");
27938
+ text = readFileSync8(resolve5(dir, file), "utf8");
27556
27939
  } catch {
27557
27940
  return 1;
27558
27941
  }
@@ -27591,7 +27974,7 @@ function planTerms(config, plan2) {
27591
27974
  return t;
27592
27975
  }
27593
27976
  function rel(loaded, root, file) {
27594
- return relative4(root, resolve4(loaded.dir, file)).split("\\").join("/") || file;
27977
+ return relative4(root, resolve5(loaded.dir, file)).split("\\").join("/") || file;
27595
27978
  }
27596
27979
  function emailFromFile(content, file, subject) {
27597
27980
  let body = content;
@@ -27636,7 +28019,8 @@ async function stripeJob(loaded, e, o, root) {
27636
28019
  } else {
27637
28020
  const src = e.source;
27638
28021
  job.inputs.push(src);
27639
- const r = paramsFromSource(readRel(dir, src), src, e.call ?? 1, projectEnv(dir));
28022
+ const srcText = readRel(dir, src);
28023
+ const r = paramsFromSource(srcText, src, e.call ?? 1, projectEnv(dir), constantsFor(dir, src, srcText));
27640
28024
  job.target = { file: rel(loaded, root, src), line: r.line };
27641
28025
  job.fromSource = true;
27642
28026
  params = r.params;
@@ -27648,9 +28032,16 @@ async function stripeJob(loaded, e, o, root) {
27648
28032
  }
27649
28033
  }
27650
28034
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
28035
+ const items0 = Array.isArray(params.line_items) ? params.line_items : [];
28036
+ if (!e.plan && (config.plans?.length ?? 0) > 1 && !items0.some((li) => li && typeof li === "object" && li.price_data)) {
28037
+ throw new InputError(`"${e.name}" does not say which plan it sells, and its price is a Price id or an expression Checkbox cannot read: add "plan": one of ${(config.plans ?? []).map((p) => JSON.stringify(p.name)).join(", ")} to this entry (one entry per plan when one call serves several plans).`);
28038
+ }
27651
28039
  const merchant = planTerms(config, pickPlan(config, e.plan));
27652
28040
  const drift = planDrift(params, pickPlan(config, e.plan));
27653
28041
  if (drift) job.warnings.push(drift);
28042
+ const soldPlan = pickPlan(config, e.plan);
28043
+ const overrides = soldPlan ? planAsSold(params, soldPlan) : void 0;
28044
+ if (soldPlan && overrides) job.sold = { plan: soldPlan.name, overrides };
27654
28045
  const items = Array.isArray(params.line_items) ? params.line_items : [];
27655
28046
  if (items.length && items.every((li) => li && typeof li === "object" && li.price_data)) for (const k of ["price", "amount", "currency", "interval", "interval_count"]) delete merchant[k];
27656
28047
  job.body = { params, ...Object.keys(merchant).length ? { merchant } : {}, ...jurisdictionsOf(config) };
@@ -27659,6 +28050,29 @@ async function stripeJob(loaded, e, o, root) {
27659
28050
  }
27660
28051
  return job;
27661
28052
  }
28053
+ function planAsSold(params, plan2) {
28054
+ const sd = params.subscription_data ?? {};
28055
+ const trial = typeof sd.trial_period_days === "number" ? sd.trial_period_days : void 0;
28056
+ const out = {};
28057
+ if (trial !== void 0 && trial !== (plan2.trial_days ?? 0)) out.trial_days = trial;
28058
+ if (trial === void 0 && plan2.trial_days && plan2.trial_days > 0 && sd.trial_end === void 0) out.trial_days = 0;
28059
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
28060
+ const pd = items.map((li) => li && typeof li === "object" ? li.price_data : void 0).find((x) => x && typeof x === "object");
28061
+ const rec = pd && typeof pd.recurring === "object" ? pd.recurring : void 0;
28062
+ if (rec && typeof rec.interval === "string" && rec.interval !== plan2.interval && ["day", "week", "month", "year"].includes(rec.interval)) out.interval = rec.interval;
28063
+ const price3 = typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
28064
+ if (pd && typeof pd.unit_amount === "number" && price3 !== void 0 && /^(?:usd|eur|gbp|cad|aud|nzd)$/i.test(String(pd.currency ?? plan2.currency ?? "usd")) && (!pd.currency || pd.currency === (plan2.currency ?? "usd")) && Math.abs(pd.unit_amount - Math.round(price3 * 100)) >= 1) {
28065
+ out.price = pd.unit_amount / 100;
28066
+ }
28067
+ return Object.keys(out).length ? out : void 0;
28068
+ }
28069
+ function describeSold(o) {
28070
+ const parts = [];
28071
+ if (o.trial_days !== void 0) parts.push(o.trial_days ? `a ${o.trial_days}-day trial` : "no trial");
28072
+ const charge = [o.price !== void 0 ? Number(o.price).toFixed(2) : "", o.interval ? `every ${o.interval}` : ""].filter(Boolean).join(" ");
28073
+ if (charge) parts.push(charge);
28074
+ return parts.join(", ");
28075
+ }
27662
28076
  function planDrift(params, plan2) {
27663
28077
  if (!plan2) return void 0;
27664
28078
  const sd = params.subscription_data ?? {};
@@ -27674,7 +28088,7 @@ function planDrift(params, plan2) {
27674
28088
  if (pd && typeof pd.unit_amount === "number" && price3 !== void 0 && /^(?:usd|eur|gbp|cad|aud|nzd)$/i.test(String(pd.currency ?? plan2.currency ?? "usd")) && (!pd.currency || pd.currency === (plan2.currency ?? "usd")) && Math.abs(pd.unit_amount - Math.round(price3 * 100)) >= 1) {
27675
28089
  out.push(`the params charge ${(pd.unit_amount / 100).toFixed(2)}, plan "${plan2.name}" ${price3.toFixed(2)}`);
27676
28090
  }
27677
- return out.length ? `The params and checkbox.json disagree: ${out.join("; ")}. The checks use the params; update "plans" in checkbox.json so the required emails and pages are checked for the plan you really sell.` : void 0;
28091
+ return out.length ? `The params and checkbox.json disagree: ${out.join("; ")}. The Checkout check uses the params, and the emails, pages and disclosures of plan "${plan2.name}" are checked against both until they agree: update "plans" in checkbox.json to the plan you really sell.` : void 0;
27678
28092
  }
27679
28093
  function disclosureJob(loaded, e, root) {
27680
28094
  const { config, dir } = loaded;
@@ -27726,14 +28140,20 @@ function pageJob(loaded, e, o, root) {
27726
28140
  };
27727
28141
  }
27728
28142
  if (e.url) {
27729
- if (!o.online) job.skip = "URL pages are fetched by the API: run with --online (and CHECKBOX_SECRET_KEY)";
28143
+ if (!o.online) job.skip = 'URL pages are fetched by the API: run with --online and a live-mode key (ck_live_\u2026 on an activated account), or list the built page under "file" to check it offline';
27730
28144
  else job.body = { url: e.url, ...common3 };
27731
28145
  return job;
27732
28146
  }
27733
28147
  try {
27734
28148
  if (!e.source) job.target = { file: rel(loaded, root, e.file), line: 1 };
27735
28149
  job.inputs.push(e.file);
27736
- const html = readRel(dir, e.file);
28150
+ let html;
28151
+ try {
28152
+ html = readRel(dir, e.file);
28153
+ } catch (err) {
28154
+ if (!existsSync6(resolve5(dir, e.file))) throw new InputError(`${e.file} does not exist (paths are relative to checkbox.json). If it is build output, build it before checkbox ci: in GitHub Actions, add a build step before the Checkbox step (\`checkbox ci init\` writes one when package.json has a build script).`);
28155
+ throw err;
28156
+ }
27737
28157
  if (!e.source) job.locate = (needles) => lineOfAny(html, needles);
27738
28158
  job.body = { html, ...common3 };
27739
28159
  } catch (err) {
@@ -27774,9 +28194,35 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27774
28194
  }
27775
28195
  async function resolveJobs(loaded, o) {
27776
28196
  const root = o.root ?? loaded.dir;
28197
+ const jobs = [];
28198
+ for (const e of loaded.config.checks.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
28199
+ const dependent = await planJobs(loaded, o, root);
28200
+ jobs.push(...dependent);
28201
+ const seen = /* @__PURE__ */ new Set();
28202
+ for (const sj of jobs.filter((j) => j.type === "stripe_checkout" && j.sold)) {
28203
+ const key = JSON.stringify(sj.sold);
28204
+ if (seen.has(key)) continue;
28205
+ seen.add(key);
28206
+ const { plan: planName, overrides } = sj.sold;
28207
+ const config = { ...loaded.config, plans: (loaded.config.plans ?? []).map((p) => p.name === planName ? { ...p, ...overrides } : p) };
28208
+ const variant = await planJobs({ ...loaded, config }, o, root);
28209
+ const sold = describeSold(overrides);
28210
+ variant.forEach((v, i) => {
28211
+ const orig = dependent[i];
28212
+ if (orig.error || orig.skip || !v.body || JSON.stringify(v.body) === JSON.stringify(orig.body)) return;
28213
+ jobs.push({
28214
+ ...v,
28215
+ name: `${orig.name} (as "${sj.name}" sells it: ${sold})`,
28216
+ inputs: [.../* @__PURE__ */ new Set([...v.inputs, ...sj.inputs])],
28217
+ warnings: [`The params and checkbox.json disagree: "${sj.name}" sells plan "${planName}" with ${sold}, which checkbox.json does not say. This check judges ${orig.name} against what the params sell; the same check against checkbox.json runs too, and both must pass. Update "plans" in checkbox.json (or the params) so they agree.`, ...v.warnings]
28218
+ });
28219
+ });
28220
+ }
28221
+ return jobs;
28222
+ }
28223
+ async function planJobs(loaded, o, root) {
27777
28224
  const c = loaded.config.checks;
27778
28225
  const jobs = [];
27779
- for (const e of c.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
27780
28226
  for (const e of c.disclosures ?? []) jobs.push(disclosureJob(loaded, e, root));
27781
28227
  for (const e of c.pages ?? []) jobs.push(pageJob(loaded, e, o, root));
27782
28228
  for (const e of c.emails ?? []) {
@@ -27800,7 +28246,7 @@ async function resolveJobs(loaded, o) {
27800
28246
  }
27801
28247
  return jobs;
27802
28248
  }
27803
- var InputError, CHILD, jurisdictionsOf;
28249
+ var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
27804
28250
  var init_inputs = __esm({
27805
28251
  "src/ci/inputs.ts"() {
27806
28252
  "use strict";
@@ -27813,6 +28259,7 @@ var init_inputs = __esm({
27813
28259
  this.name = "InputError";
27814
28260
  }
27815
28261
  };
28262
+ NOT_INSTALLED = "tsx is in package.json but not installed: install the project's dependencies before checkbox ci (npm ci; in GitHub Actions a `- run: npm ci` step before the Checkbox step, which `checkbox ci init` writes)";
27816
28263
  CHILD = `
27817
28264
  const [url, name, argsJson] = process.argv.slice(1);
27818
28265
  const mod = await import(url);
@@ -27831,106 +28278,6 @@ process.exit(0);
27831
28278
  }
27832
28279
  });
27833
28280
 
27834
- // src/ci/report.ts
27835
- function icon(s2, c) {
27836
- if (s2 === "pass") return c.green("\u2713");
27837
- if (s2 === "fail" || s2 === "error") return c.red("\u2717");
27838
- if (s2 === "needs_review") return c.yellow("?");
27839
- return c.dim("\u2013");
27840
- }
27841
- function overallLine(run2) {
27842
- const s2 = run2.summary;
27843
- const parts = [];
27844
- if (s2.error) parts.push(`${s2.error} could not run`);
27845
- if (s2.fail) parts.push(`${s2.fail} need${s2.fail === 1 ? "s" : ""} a fix`);
27846
- if (s2.needs_review) parts.push(`${s2.needs_review} need${s2.needs_review === 1 ? "s" : ""} review${run2.strict ? " (strict)" : ""}`);
27847
- if (s2.pass) parts.push(`${s2.pass} pass`);
27848
- if (s2.not_applicable) parts.push(`${s2.not_applicable} not required`);
27849
- if (s2.skipped) parts.push(`${s2.skipped} skipped`);
27850
- return parts.join(" \xB7 ") || "nothing checked";
27851
- }
27852
- function renderHuman(run2, c, opts = {}) {
27853
- const lines = [];
27854
- const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
27855
- lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
27856
- const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
27857
- for (const x of run2.checks) {
27858
- const label = x.status === "fail" || x.status === "error" ? c.red(x.label) : x.status === "needs_review" ? c.yellow(x.label) : x.status === "pass" ? c.green(x.label) : c.dim(x.label);
27859
- lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
27860
- if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
27861
- if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
27862
- if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
27863
- const alerts = x.alerts ?? [];
27864
- for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
27865
- for (const w of x.warnings.filter((w2) => !alerts.includes(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
27866
- if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
27867
- }
27868
- const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
27869
- lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
27870
- if (run2.checks.some((x) => x.corrected) && !opts.verbose) lines.push(c.dim("Corrected params / text: --verbose, --json, or the job summary in GitHub Actions."));
27871
- lines.push(c.dim(run2.disclaimer));
27872
- return lines;
27873
- }
27874
- function annotations(run2) {
27875
- const out = [];
27876
- for (const x of run2.checks) {
27877
- const alerts = x.alerts ?? [];
27878
- const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
27879
- if (!level) continue;
27880
- const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
27881
- const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
27882
- ${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
27883
- out.push(`::${level} ${props.join(",")}::${escData(body)}`);
27884
- }
27885
- return out;
27886
- }
27887
- function stepSummary(run2) {
27888
- const lines = [];
27889
- lines.push("## Checkbox compliance checks", "");
27890
- const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
27891
- lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
27892
- lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
27893
- for (const x of run2.checks) {
27894
- const fix0 = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : x.status === "needs_review" && x.message ? x.message : "";
27895
- const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
27896
- lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
27897
- }
27898
- const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
27899
- for (const x of detailed) {
27900
- lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
27901
- if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
27902
- if (x.corrected) {
27903
- const fence = x.corrected.body.includes("```") ? "````" : "```";
27904
- lines.push(`${fence}${x.corrected.format === "json" ? "json" : "text"}`, x.corrected.body, fence, "");
27905
- }
27906
- lines.push("</details>");
27907
- }
27908
- lines.push("", `<sub>${run2.disclaimer} Docs: https://checkbox.site/docs/guides/ci</sub>`, "");
27909
- return lines.join("\n");
27910
- }
27911
- function escapeHtml(s2) {
27912
- return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27913
- }
27914
- var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
27915
- var init_report = __esm({
27916
- "src/ci/report.ts"() {
27917
- "use strict";
27918
- init_output();
27919
- TYPE_LABEL = {
27920
- stripe_checkout: "Stripe Checkout",
27921
- disclosure: "Disclosure",
27922
- page: "Page",
27923
- email: "Email",
27924
- notice_schedule: "Notice schedule"
27925
- };
27926
- where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
27927
- escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
27928
- escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
27929
- cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
27930
- STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
27931
- }
27932
- });
27933
-
27934
28281
  // ../rules/src/fixes/conflicts.ts
27935
28282
  var init_conflicts = __esm({
27936
28283
  "../rules/src/fixes/conflicts.ts"() {
@@ -28435,10 +28782,35 @@ function onlineEngine(client) {
28435
28782
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28436
28783
  }
28437
28784
  function ciVocabulary(text) {
28438
- return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored");
28785
+ return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored").replace(/comes from merchant\.price and is not verified against the Price\. Connect Stripe on the site, or register the params with createStripeCheckoutConfig on a site whose Stripe account has this Price\./g, "comes from the plan's price in checkbox.json and is not verified against the Price. Connect Stripe on the site in Checkbox, then run checkbox ci --online (CHECKBOX_SECRET_KEY) so Checkbox reads the Price.").replace(/comes from merchant\.price\b/g, "comes from the plan's price in checkbox.json").replace(/\bmerchant\.price says\b/g, "The plan's price in checkbox.json says").replace(/\(or pass merchant\.cancel_url\)/g, "(or set merchant.cancel_url in checkbox.json)").replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
28786
+ }
28787
+ function pricingPageFixes(fixes) {
28788
+ const reworded = fixes.map(
28789
+ (f) => f.replace(/,? before the card and email fields and the (?:pay|purchase) button/g, ", next to the plan's button").replace(/,? before the card and email fields/g, ", next to the plan's button")
28790
+ );
28791
+ const out = [];
28792
+ const renewalLineFor = /* @__PURE__ */ new Set();
28793
+ for (const f of reworded) {
28794
+ const card = /^(Plan card [^:]+): /.exec(f)?.[1] ?? "";
28795
+ const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price|Add your own renewal line next to the (?:pay|plan's) button)/.test(f);
28796
+ if (isRenewalLine) {
28797
+ const cards = [...card.matchAll(/“([^”]+)”/g)].map((m) => m[1]);
28798
+ const fresh = cards.filter((c) => !renewalLineFor.has(c));
28799
+ if (cards.length ? !fresh.length : renewalLineFor.has(card)) continue;
28800
+ if (!cards.length) renewalLineFor.add(card);
28801
+ for (const c of fresh) renewalLineFor.add(c);
28802
+ if (cards.length && fresh.length < cards.length) {
28803
+ const g = `Plan card ${fresh.map((c) => `\u201C${c}\u201D`).join(", ")}: ${f.slice(card.length + 2)}`;
28804
+ if (!out.includes(g)) out.push(g);
28805
+ continue;
28806
+ }
28807
+ }
28808
+ if (!out.includes(f)) out.push(f);
28809
+ }
28810
+ return out;
28439
28811
  }
28440
28812
  function stripeConsentFix(text, params) {
28441
- if (!/^Add one unchecked checkbox/.test(text)) return text;
28813
+ if (!/^(Ask for a separate yes to the renewal|Add one unchecked checkbox)/.test(text)) return text;
28442
28814
  const cc = params?.consent_collection;
28443
28815
  if (cc?.terms_of_service === "required") return text;
28444
28816
  return 'Set consent_collection: { terms_of_service: "required" } in the params: Stripe then shows an unchecked consent checkbox, labelled with custom_text.terms_of_service_acceptance.message.';
@@ -28457,12 +28829,23 @@ function fixesOf(r, onlyNeedsFix = false) {
28457
28829
  failing.sort((a, b) => (a.compliance_status === "needs_fix" ? 0 : 1) - (b.compliance_status === "needs_fix" ? 0 : 1) || (RANK2[a.status] ?? 2) - (RANK2[b.status] ?? 2));
28458
28830
  for (const c of failing) {
28459
28831
  const base = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
28460
- const cards = failingCards(c.explanation);
28832
+ let cards = failingCards(c.explanation);
28833
+ const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
28834
+ if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
28835
+ cards = cards.filter((x) => !plain.includes(x));
28836
+ const standOut = `Plan card ${plain.map((x) => `\u201C${x}\u201D`).join(", ")}: ${STAND_OUT_FIX}`;
28837
+ if (!out.includes(standOut)) out.push(standOut);
28838
+ }
28461
28839
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
28462
28840
  if (text && !out.includes(text)) out.push(text);
28463
28841
  }
28464
28842
  return out;
28465
28843
  }
28844
+ function cardClauses(explanation) {
28845
+ if (!explanation) return [];
28846
+ const ms = [...explanation.matchAll(/plan button “([^”]{1,80})”(?: in the card “([^”]{1,80})”)?/g)];
28847
+ return ms.map((m, i) => ({ card: m[2] ?? m[1], clause: explanation.slice(m.index + m[0].length, ms[i + 1]?.index ?? explanation.length) }));
28848
+ }
28466
28849
  function failingCards(explanation) {
28467
28850
  if (!explanation) return [];
28468
28851
  if (/\(\d+ checkout page views? checked\) — e\.g\./.test(explanation)) return [];
@@ -28596,7 +28979,9 @@ async function runJob(job, engine, config, opts = {}) {
28596
28979
  if (alerts.length) Object.assign(base, { alerts });
28597
28980
  const status = ciStatusOf(report);
28598
28981
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28599
- const fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
28982
+ let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
28983
+ const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
28984
+ if (pricingPage) fixes = pricingPageFixes(fixes);
28600
28985
  const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
28601
28986
  const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review" || status === "pass" && unreadText.some((p) => /^(?:custom_text|consent_collection|discounts|subscription_data\.trial)/.test(p)));
28602
28987
  if (unread) {
@@ -28621,28 +29006,47 @@ async function runJob(job, engine, config, opts = {}) {
28621
29006
  const line2 = needles.length ? job.locate(needles) : void 0;
28622
29007
  if (line2) base.target = { ...base.target, line: line2 };
28623
29008
  }
28624
- const finalStatus = unread ? literal.length ? "fail" : "needs_review" : status;
29009
+ const declaredCoupon = !unread && status === "pass" && job.type === "stripe_checkout" ? (report.warnings ?? []).find((w) => w.code === "coupon_unverified" && /merchant\.intro_\* and are not verified/.test(w.message)) : void 0;
29010
+ if (declaredCoupon) {
29011
+ const id = /discounts use (coupon|promotion code) (\S+?),/.exec(declaredCoupon.message);
29012
+ const what = id ? `${id[1]} ${id[2]}` : "the coupon";
29013
+ fixes.unshift(
29014
+ engine.mode === "offline" ? `The intro price is declared in checkbox.json, not verified: offline, Checkbox cannot read ${what} from Stripe. Run with --online (CHECKBOX_SECRET_KEY, and the site's Stripe key connected) so Checkbox reads it and compares its percent / amount off and duration with the declared intro.` : `The intro price is declared in checkbox.json, not verified: Checkbox could not read ${what} through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?). Until it is read, its percent / amount off and duration are not compared with the declared intro.`
29015
+ );
29016
+ }
29017
+ const sharedReview = !unread && !declaredCoupon && status === "pass" && job.type === "stripe_checkout" ? stripeParamsVerdict(report) : void 0;
29018
+ const unreadCoupon = sharedReview?.verdict === "needs_review" && sharedReview.reason ? sharedReview.reason : void 0;
29019
+ if (unreadCoupon) {
29020
+ const id = /discounts use (coupon|promotion code) (\S+?),/.exec(unreadCoupon);
29021
+ const what = id ? `${id[1]} ${id[2]}` : "the coupon";
29022
+ fixes.unshift(
29023
+ engine.mode === "offline" ? `The params apply ${what}, which Checkbox cannot read offline, so its discount and duration (what the shopper is charged first) are not checked. Declare the intro on the plan in checkbox.json (intro_percent_off, intro_amount_off or intro_price, with intro_periods) and state it in custom_text.submit, or run with --online so Checkbox reads the coupon.` : `The params apply ${what}, which Checkbox could not read through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?), so its discount and duration are not checked. Declare the intro on the plan in checkbox.json and state it in custom_text.submit, or let Checkbox read the coupon.`
29024
+ );
29025
+ }
29026
+ const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon || unreadCoupon ? "needs_review" : status;
28625
29027
  const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
28626
29028
  if (corrected?.format === "json" && job.placeholders) {
28627
29029
  const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
28628
29030
  if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
28629
29031
  }
28630
- const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || status === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
29032
+ const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || // Round 31 (pilot 30 item 2, headless-agent-pr false pass: a "strict" entry with a declared-but-unverified coupon exited
29033
+ // 0): the entry's strictness applies to the status the check reports (finalStatus), not the engine's raw verdict.
29034
+ finalStatus === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
28631
29035
  return {
28632
29036
  ...base,
28633
29037
  status: finalStatus,
28634
29038
  ...blocking ? { blocking: true } : {},
28635
- label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report),
29039
+ label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : unreadCoupon ? "Needs review \xB7 coupon not read" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
28636
29040
  ...fixes[0] ? { fix: fixes[0] } : {},
28637
29041
  fixes,
28638
29042
  ...corrected ? { corrected } : {},
28639
29043
  report
28640
29044
  };
28641
29045
  }
28642
- function countedLabel(label, r) {
29046
+ function countedLabel(label, r, adjust) {
28643
29047
  const m = /^(\d+) fix(?:es)? needed/.exec(label);
28644
29048
  if (!m) return label;
28645
- const n = fixesOf(r, true).length;
29049
+ const n = (adjust ? adjust(fixesOf(r, true)) : fixesOf(r, true)).length;
28646
29050
  if (!n || n === Number(m[1])) return label;
28647
29051
  return label.replace(m[0], `${n} fix${n === 1 ? "" : "es"} needed`);
28648
29052
  }
@@ -28652,6 +29056,7 @@ function exitCodeOf(checks, strict) {
28652
29056
  if (checks.some((c) => c.status === "error")) return { status: "error", exit: EXIT.usage };
28653
29057
  if (checks.some((c) => c.status === "fail")) return { status: "fail", exit: EXIT.fail };
28654
29058
  if (checks.some((c) => c.status === "needs_review")) return { status: "needs_review", exit: strict || checks.some((c) => c.blocking) ? EXIT.needs_review : EXIT.ok };
29059
+ if (!checks.some((c) => c.status === "pass")) return { status: "skipped", exit: EXIT.needs_review };
28655
29060
  return { status: "pass", exit: EXIT.ok };
28656
29061
  }
28657
29062
  function mergeSourcePatches(checks) {
@@ -28673,6 +29078,11 @@ function mergeSourcePatches(checks) {
28673
29078
  }
28674
29079
  }
28675
29080
  }
29081
+ function nothingCheckedMessage(run2) {
29082
+ const urlSkipped = run2.checks.some((c) => c.status === "skipped" && /^URL pages/.test(c.message ?? ""));
29083
+ const how = urlSkipped ? run2.mode === "offline" ? ' Page entries with a "url" are fetched only with --online and an API key (CHECKBOX_SECRET_KEY); offline, point them at the built HTML with "file" instead.' : ' Page entries with a "url" need a key that can read that site (a live key for a live site); or point them at the built HTML with "file".' : ' Add a check checkbox.json can run (a "file" page, a disclosure, Stripe Checkout params or an email).';
29084
+ return `Nothing was checked: every check was skipped or does not apply, so this run is not a pass (exit 2).${how}`;
29085
+ }
28676
29086
  async function runCi(jobs, engine, opts) {
28677
29087
  const checks = [...opts.extra ?? []];
28678
29088
  for (const j of jobs) checks.push(await runJob(j, engine, opts.config, { ...opts.notices ? { notices: opts.notices } : {} }));
@@ -28697,7 +29107,7 @@ async function runCi(jobs, engine, opts) {
28697
29107
  disclaimer: CI_DISCLAIMER
28698
29108
  };
28699
29109
  }
28700
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
29110
+ var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, STAND_OUT_FIX, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
28701
29111
  var init_runner = __esm({
28702
29112
  "src/ci/runner.ts"() {
28703
29113
  "use strict";
@@ -28705,6 +29115,7 @@ var init_runner = __esm({
28705
29115
  init_http();
28706
29116
  init_src();
28707
29117
  init_types2();
29118
+ init_stripe_verdict();
28708
29119
  init_config3();
28709
29120
  ENGINE_KIND = {
28710
29121
  stripe_checkout: "stripe_checkout_session",
@@ -28716,6 +29127,7 @@ var init_runner = __esm({
28716
29127
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
28717
29128
  RANK2 = { fail: 0, needs_review: 1 };
28718
29129
  OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
29130
+ STAND_OUT_FIX = "Make its renewal line stand out from the text around it: larger type, bold (weight 600 or more), a contrasting colour, or a border.";
28719
29131
  NOTICE_TO_EMAIL = {
28720
29132
  acknowledgment: "acknowledgment",
28721
29133
  trial_ending: "trial_reminder",
@@ -28738,8 +29150,112 @@ var init_runner = __esm({
28738
29150
  }
28739
29151
  });
28740
29152
 
29153
+ // src/ci/report.ts
29154
+ function icon(s2, c) {
29155
+ if (s2 === "pass") return c.green("\u2713");
29156
+ if (s2 === "fail" || s2 === "error") return c.red("\u2717");
29157
+ if (s2 === "needs_review") return c.yellow("?");
29158
+ return c.dim("\u2013");
29159
+ }
29160
+ function overallLine(run2) {
29161
+ const s2 = run2.summary;
29162
+ const parts = [];
29163
+ if (s2.error) parts.push(`${s2.error} could not run`);
29164
+ if (s2.fail) parts.push(`${s2.fail} need${s2.fail === 1 ? "s" : ""} a fix`);
29165
+ if (s2.needs_review) parts.push(`${s2.needs_review} need${s2.needs_review === 1 ? "s" : ""} review${run2.strict ? " (strict)" : ""}`);
29166
+ if (s2.pass) parts.push(`${s2.pass} pass`);
29167
+ if (s2.not_applicable) parts.push(`${s2.not_applicable} not required`);
29168
+ if (s2.skipped) parts.push(`${s2.skipped} skipped`);
29169
+ return parts.join(" \xB7 ") || "nothing checked";
29170
+ }
29171
+ function renderHuman(run2, c, opts = {}) {
29172
+ const lines = [];
29173
+ const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
29174
+ lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
29175
+ const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
29176
+ for (const x of run2.checks) {
29177
+ const label = x.status === "fail" || x.status === "error" ? c.red(x.label) : x.status === "needs_review" ? c.yellow(x.label) : x.status === "pass" ? c.green(x.label) : c.dim(x.label);
29178
+ lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
29179
+ if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
29180
+ if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
29181
+ if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
29182
+ const alerts = x.alerts ?? [];
29183
+ for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
29184
+ for (const w of x.warnings.filter((w2) => !alerts.includes(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
29185
+ if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
29186
+ }
29187
+ const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
29188
+ if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
29189
+ lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
29190
+ if (run2.checks.some((x) => x.corrected) && !opts.verbose) lines.push(c.dim("Corrected params / text: --verbose, --json, or the job summary in GitHub Actions."));
29191
+ lines.push(c.dim(run2.disclaimer));
29192
+ return lines;
29193
+ }
29194
+ function annotations(run2) {
29195
+ const out = [];
29196
+ for (const x of run2.checks) {
29197
+ const alerts = x.alerts ?? [];
29198
+ const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
29199
+ if (!level) continue;
29200
+ const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
29201
+ const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
29202
+ ${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
29203
+ out.push(`::${level} ${props.join(",")}::${escData(body)}`);
29204
+ }
29205
+ if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
29206
+ return out;
29207
+ }
29208
+ function stepSummary(run2) {
29209
+ const lines = [];
29210
+ lines.push("## Checkbox compliance checks", "");
29211
+ const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
29212
+ lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
29213
+ if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
29214
+ lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
29215
+ for (const x of run2.checks) {
29216
+ const fix0 = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : x.status === "needs_review" && x.message ? x.message : "";
29217
+ const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
29218
+ lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
29219
+ }
29220
+ const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
29221
+ for (const x of detailed) {
29222
+ lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
29223
+ if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
29224
+ if (x.corrected) {
29225
+ const fence = x.corrected.body.includes("```") ? "````" : "```";
29226
+ lines.push(`${fence}${x.corrected.format === "json" ? "json" : "text"}`, x.corrected.body, fence, "");
29227
+ }
29228
+ lines.push("</details>");
29229
+ }
29230
+ lines.push("", `<sub>${run2.disclaimer} Docs: https://checkbox.site/docs/guides/ci</sub>`, "");
29231
+ return lines.join("\n");
29232
+ }
29233
+ function escapeHtml(s2) {
29234
+ return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29235
+ }
29236
+ var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
29237
+ var init_report = __esm({
29238
+ "src/ci/report.ts"() {
29239
+ "use strict";
29240
+ init_output();
29241
+ init_runner();
29242
+ TYPE_LABEL = {
29243
+ stripe_checkout: "Stripe Checkout",
29244
+ disclosure: "Disclosure",
29245
+ page: "Page",
29246
+ email: "Email",
29247
+ notice_schedule: "Notice schedule"
29248
+ };
29249
+ where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
29250
+ escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
29251
+ escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
29252
+ cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
29253
+ STATUS_MD = { pass: "\u2705", fail: "\u274C", needs_review: "\u26A0\uFE0F", error: "\u274C", skipped: "\u23ED\uFE0F", not_applicable: "\u2796" };
29254
+ }
29255
+ });
29256
+
28741
29257
  // src/commands/plan/lib.ts
28742
- import { dirname as dirname5, resolve as resolve5 } from "node:path";
29258
+ import { dirname as dirname6, resolve as resolve6 } from "node:path";
28743
29259
  async function resolveSite(ctx, flag, client) {
28744
29260
  const explicit = flag ?? ctx.env.CHECKBOX_SITE;
28745
29261
  if (explicit) {
@@ -28747,10 +29263,10 @@ async function resolveSite(ctx, flag, client) {
28747
29263
  return explicit;
28748
29264
  }
28749
29265
  const projects = ctx.config.load().init?.projects ?? {};
28750
- for (let dir = resolve5(ctx.cwd); ; dir = dirname5(dir)) {
29266
+ for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
28751
29267
  const site = projects[dir]?.site;
28752
29268
  if (site) return site;
28753
- if (dirname5(dir) === dir) break;
29269
+ if (dirname6(dir) === dir) break;
28754
29270
  }
28755
29271
  const list2 = await client.sdk.sites.list({ limit: 3 });
28756
29272
  const sites = list2?.data ?? [];
@@ -28807,12 +29323,28 @@ var init_lib = __esm({
28807
29323
  });
28808
29324
 
28809
29325
  // src/ci/workflow.ts
28810
- var WORKFLOW_PATH, WORKFLOW_YAML;
28811
- var init_workflow = __esm({
28812
- "src/ci/workflow.ts"() {
28813
- "use strict";
28814
- WORKFLOW_PATH = ".github/workflows/checkbox.yml";
28815
- WORKFLOW_YAML = `# Checkbox: automatic-renewal law checks on every pull request (https://checkbox.site/docs/guides/ci).
29326
+ import { execFileSync } from "node:child_process";
29327
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "node:fs";
29328
+ import { join as join8 } from "node:path";
29329
+ function workflowYaml(needs = {}) {
29330
+ const node = !!(needs.install || needs.build);
29331
+ const steps = [" - uses: actions/checkout@v4"];
29332
+ if (node) {
29333
+ steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 20");
29334
+ if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
29335
+ if (needs.build) steps.push(` # ${needs.why?.build ?? 'checkbox.json "pages" read built HTML, which a fresh clone does not have.'}`, ` - run: ${needs.build}`);
29336
+ } else {
29337
+ steps.push(' # Only needed when checkbox.json uses "module" entries that import your dependencies:', " # - run: npm ci");
29338
+ }
29339
+ steps.push(" - uses: checkbox-site/checkbox@v1", " # with:", " # strict: true # also fail when a check needs review");
29340
+ if (needs.urlPages) {
29341
+ steps.push(
29342
+ ' # args: --online # "url" pages are fetched by the API: needs a LIVE key (ck_live_\u2026) on an',
29343
+ ' # # activated account. Without one, list the built page as "file" instead.'
29344
+ );
29345
+ } else steps.push(" # args: --online # URL pages via the API; needs the env below");
29346
+ steps.push(" # env:", " # CHECKBOX_SECRET_KEY: ${{ secrets.CHECKBOX_SECRET_KEY }}");
29347
+ return `# Checkbox: automatic-renewal law checks on every pull request (https://checkbox.site/docs/guides/ci).
28816
29348
  # Runs the checks listed in checkbox.json with the Checkbox rules engine, offline (no key, no network), annotates the
28817
29349
  # lines that need a fix and writes a summary with the corrected params / text to the job page.
28818
29350
  name: Checkbox
@@ -28830,35 +29362,85 @@ jobs:
28830
29362
  runs-on: ubuntu-latest
28831
29363
  timeout-minutes: 10
28832
29364
  steps:
28833
- - uses: actions/checkout@v4
28834
- # Only needed when checkbox.json uses "module" entries that import your dependencies:
28835
- # - run: npm ci
28836
- - uses: checkbox-site/checkbox@v1
28837
- # with:
28838
- # strict: true # also fail when a check needs review
28839
- # args: --online # URL pages via the API; needs the env below
28840
- # env:
28841
- # CHECKBOX_SECRET_KEY: \${{ secrets.CHECKBOX_SECRET_KEY }}
29365
+ ${steps.join("\n")}
28842
29366
  `;
29367
+ }
29368
+ function installCommand(root) {
29369
+ if (existsSync7(join8(root, "pnpm-lock.yaml"))) return "corepack enable && pnpm install --frozen-lockfile";
29370
+ if (existsSync7(join8(root, "yarn.lock"))) return "corepack enable && yarn install --immutable";
29371
+ if (existsSync7(join8(root, "bun.lockb")) || existsSync7(join8(root, "bun.lock"))) return "npx --yes bun install --frozen-lockfile";
29372
+ if (existsSync7(join8(root, "package-lock.json")) || existsSync7(join8(root, "npm-shrinkwrap.json"))) return "npm ci";
29373
+ return "npm install";
29374
+ }
29375
+ function runScript(root, script) {
29376
+ if (existsSync7(join8(root, "pnpm-lock.yaml"))) return `pnpm run ${script}`;
29377
+ if (existsSync7(join8(root, "yarn.lock"))) return `yarn run ${script}`;
29378
+ return `npm run ${script}`;
29379
+ }
29380
+ function notInClone(root, files) {
29381
+ if (!files.length) return [];
29382
+ let tracked = null;
29383
+ try {
29384
+ const out = execFileSync("git", ["ls-files", "-z", "--", ...files], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
29385
+ tracked = new Set(out.split("\0").filter(Boolean));
29386
+ } catch {
29387
+ tracked = null;
29388
+ }
29389
+ return files.filter((f) => tracked ? !tracked.has(f.replace(/^\.\//, "")) : !existsSync7(join8(root, f)));
29390
+ }
29391
+ function workflowNeeds(root, config) {
29392
+ const checks = config.checks ?? {};
29393
+ const list2 = (k) => Array.isArray(checks[k]) ? checks[k] : [];
29394
+ const modules = [...list2("stripe_checkout"), ...list2("emails"), ...list2("disclosures")].some((e) => typeof e.module === "string" && !!e.module);
29395
+ const pageFiles = list2("pages").flatMap((e) => typeof e.file === "string" && !e.file.startsWith("TODO") ? [e.file] : []);
29396
+ const urlPages = list2("pages").some((e) => typeof e.url === "string");
29397
+ let pkg = null;
29398
+ try {
29399
+ pkg = JSON.parse(readFileSync9(join8(root, "package.json"), "utf8"));
29400
+ } catch {
29401
+ pkg = null;
29402
+ }
29403
+ const built = notInClone(root, pageFiles);
29404
+ const buildScript = pkg?.scripts?.build ? "build" : void 0;
29405
+ const needs = { why: {} };
29406
+ if (built.length && buildScript) {
29407
+ needs.build = runScript(root, buildScript);
29408
+ needs.why.build = `checkbox.json "pages" read ${built.slice(0, 2).join(", ")}${built.length > 2 ? ", \u2026" : ""}, built here (not in git).`;
29409
+ }
29410
+ if (pkg && (modules || needs.build)) {
29411
+ needs.install = installCommand(root);
29412
+ needs.why.install = modules ? 'checkbox.json "module" entries import your code and its dependencies.' : "The build needs your dependencies.";
29413
+ }
29414
+ if (urlPages) needs.urlPages = true;
29415
+ return needs;
29416
+ }
29417
+ var WORKFLOW_PATH, WORKFLOW_YAML;
29418
+ var init_workflow = __esm({
29419
+ "src/ci/workflow.ts"() {
29420
+ "use strict";
29421
+ WORKFLOW_PATH = ".github/workflows/checkbox.yml";
29422
+ WORKFLOW_YAML = workflowYaml();
28843
29423
  }
28844
29424
  });
28845
29425
 
28846
29426
  // src/commands/ci/index.ts
28847
29427
  var ci_exports = {};
28848
29428
  __export(ci_exports, {
29429
+ attachSiteForCoupons: () => attachSiteForCoupons,
29430
+ couponIdsIn: () => couponIdsIn,
28849
29431
  default: () => ci_default,
28850
29432
  touchesConfig: () => touchesConfig,
28851
29433
  undiffableInputs: () => undiffableInputs
28852
29434
  });
28853
- import { execFileSync } from "node:child_process";
28854
- import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
29435
+ import { execFileSync as execFileSync2 } from "node:child_process";
29436
+ import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
28855
29437
  import { createInterface } from "node:readline";
28856
- import { dirname as dirname6, join as join8, relative as relative5, resolve as resolve6 } from "node:path";
29438
+ import { dirname as dirname7, join as join9, relative as relative5, resolve as resolve7 } from "node:path";
28857
29439
  function gitChanged(cwd, base) {
28858
29440
  try {
28859
- const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
28860
- const out = execFileSync("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
28861
- return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve6(root, f)) };
29441
+ const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
29442
+ const out = execFileSync2("git", ["diff", "--name-only", `${base}...HEAD`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
29443
+ return { root, files: out.split("\n").map((l) => l.trim()).filter(Boolean).map((f) => resolve7(root, f)) };
28862
29444
  } catch (err) {
28863
29445
  const msg2 = String(err.stderr || err.message).trim().split("\n")[0];
28864
29446
  return `git diff ${base}...HEAD failed (${msg2}); fetch the base branch (actions/checkout with fetch-depth: 0)`;
@@ -28868,13 +29450,13 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28868
29450
  const need = [];
28869
29451
  const out = [];
28870
29452
  for (const e of loaded.config.checks.pages ?? []) {
28871
- if (e.source) need.push({ path: resolve6(loaded.dir, e.source), label: `pages "${e.name}" source ${e.source}` });
29453
+ if (e.source) need.push({ path: resolve7(loaded.dir, e.source), label: `pages "${e.name}" source ${e.source}` });
28872
29454
  else if (e.url) out.push(`pages "${e.name}" is a URL with no "source" file to diff`);
28873
- else if (e.file) need.push({ path: resolve6(loaded.dir, e.file), label: `pages "${e.name}" file ${e.file}` });
29455
+ else if (e.file) need.push({ path: resolve7(loaded.dir, e.file), label: `pages "${e.name}" file ${e.file}` });
28874
29456
  }
28875
29457
  for (const j of jobs) {
28876
29458
  if (j.type === "page") continue;
28877
- for (const f of j.inputs) need.push({ path: resolve6(loaded.dir, f), label: `"${j.name}" reads ${f}` });
29459
+ for (const f of j.inputs) need.push({ path: resolve7(loaded.dir, f), label: `"${j.name}" reads ${f}` });
28878
29460
  }
28879
29461
  if (!need.length) return out;
28880
29462
  const real = (p) => {
@@ -28889,8 +29471,8 @@ function undiffableInputs(loaded, jobs, gitRoot) {
28889
29471
  let tracked;
28890
29472
  try {
28891
29473
  const rels = [...new Set(need.map((n) => relative5(gitRoot, n.path)))];
28892
- const listed = execFileSync("git", ["ls-files", "-z", "--", ...rels], { cwd: gitRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
28893
- tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve6(gitRoot, f)));
29474
+ const listed = execFileSync2("git", ["ls-files", "-z", "--", ...rels], { cwd: gitRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
29475
+ tracked = new Set(listed.split("\0").filter(Boolean).map((f) => resolve7(gitRoot, f)));
28894
29476
  } catch (err) {
28895
29477
  return `git ls-files failed (${String(err.stderr || err.message).trim().split("\n")[0]})`;
28896
29478
  }
@@ -28902,7 +29484,7 @@ function touchesConfig(loaded, jobs, changed) {
28902
29484
  if (set.has(loaded.path)) return true;
28903
29485
  const codeChanged = changed.some((f) => /\.(?:[cm]?[jt]sx?)$/.test(f));
28904
29486
  for (const j of jobs) {
28905
- for (const f of j.inputs) if (set.has(resolve6(loaded.dir, f))) return true;
29487
+ for (const f of j.inputs) if (set.has(resolve7(loaded.dir, f))) return true;
28906
29488
  }
28907
29489
  const hasModules = [...loaded.config.checks.stripe_checkout ?? [], ...loaded.config.checks.emails ?? []].some((e) => "module" in e && e.module);
28908
29490
  return hasModules && codeChanged;
@@ -28927,24 +29509,26 @@ async function runInit(ctx, argv) {
28927
29509
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
28928
29510
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
28929
29511
  const explicit = str(p.flags, "config");
28930
- const configPath = explicit ? resolve6(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join8(ctx.cwd, f)).find((f) => existsSync7(f)) ?? join8(ctx.cwd, CONFIG_FILES[0]);
28931
- const workflowPath = join8(ctx.cwd, WORKFLOW_PATH);
29512
+ const configPath = explicit ? resolve7(ctx.cwd, explicit) : CONFIG_FILES.map((f) => join9(ctx.cwd, f)).find((f) => existsSync8(f)) ?? join9(ctx.cwd, CONFIG_FILES[0]);
29513
+ const workflowPath = join9(ctx.cwd, WORKFLOW_PATH);
28932
29514
  const force = bool(p.flags, "force") === true;
28933
29515
  const withWorkflow = bool(p.flags, "workflow") !== false;
28934
29516
  const projects = ctx.config.load().init?.projects ?? {};
28935
29517
  let facts = {};
28936
- for (let dir = resolve6(ctx.cwd); ; dir = dirname6(dir)) {
29518
+ for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
28937
29519
  if (projects[dir]?.facts) {
28938
29520
  facts = projects[dir].facts;
28939
29521
  break;
28940
29522
  }
28941
- if (dirname6(dir) === dir) break;
29523
+ if (dirname7(dir) === dir) break;
28942
29524
  }
28943
29525
  const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
28944
- const actionFor = (f) => existsSync7(f) ? force ? "overwrite" : "keep" : "create";
29526
+ const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
28945
29527
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
28946
29528
  `, action: actionFor(configPath) }];
28947
- if (withWorkflow) files.push({ path: workflowPath, content: WORKFLOW_YAML, action: actionFor(workflowPath) });
29529
+ const needs = workflowNeeds(ctx.cwd, s2.config);
29530
+ const workflowSteps = [...needs.install ? [needs.install] : [], ...needs.build ? [needs.build] : []];
29531
+ if (withWorkflow) files.push({ path: workflowPath, content: workflowYaml(needs), action: actionFor(workflowPath) });
28948
29532
  const toWrite = files.filter((f) => f.action !== "keep");
28949
29533
  const dry = bool(p.flags, "dry-run") === true;
28950
29534
  let write = !dry && toWrite.length > 0;
@@ -28956,7 +29540,7 @@ async function runInit(ctx, argv) {
28956
29540
  }
28957
29541
  if (write) {
28958
29542
  for (const f of toWrite) {
28959
- mkdirSync2(dirname6(f.path), { recursive: true });
29543
+ mkdirSync2(dirname7(f.path), { recursive: true });
28960
29544
  writeFileSync2(f.path, f.content);
28961
29545
  }
28962
29546
  }
@@ -28967,6 +29551,7 @@ async function runInit(ctx, argv) {
28967
29551
  kept: files.filter((f) => f.action === "keep").map((f) => relName(f.path)),
28968
29552
  ...write ? {} : { would_write: toWrite.map((f) => relName(f.path)) },
28969
29553
  found: s2.found,
29554
+ ...withWorkflow ? { workflow_steps: workflowSteps } : {},
28970
29555
  todos: s2.todos,
28971
29556
  config: s2.config,
28972
29557
  next_action: s2.todos.length ? { type: "edit_config", message: `Fill in the ${s2.todos.length} TODO item(s) in ${relName(configPath)} (checkbox ci refuses TODO values), then run \`checkbox ci\` and fix what it reports.` } : { type: "run_ci", message: "Run `checkbox ci` locally, fix what it reports, then commit checkbox.json and the workflow." }
@@ -28976,6 +29561,8 @@ async function runInit(ctx, argv) {
28976
29561
  for (const f of files) lines.push(`${f.action === "keep" ? ctx.out.c.dim("kept ") : write ? ctx.out.c.green(f.action === "create" ? "created" : "wrote ") : ctx.out.c.yellow("would write")} ${relName(f.path)}${f.action === "keep" ? ctx.out.c.dim(" (exists; --force overwrites)") : ""}`);
28977
29562
  if (!write && toWrite.length && !dry) lines.push(ctx.out.c.dim("Nothing written: add --yes to write without asking."));
28978
29563
  if (s2.found.length) lines.push("", "Found:", ...s2.found.map((f) => ` - ${f}`));
29564
+ if (withWorkflow && workflowSteps.length) lines.push("", `The workflow runs ${workflowSteps.map((c) => `\`${c}\``).join(" and ")} before the checks, so they work on a fresh clone.`);
29565
+ if (needs.urlPages) lines.push("", 'Note: "url" pages are fetched by the API (checkbox ci --online), which needs a LIVE key (ck_live_\u2026) on an activated account. Without one, list the built page as "file".');
28979
29566
  if (s2.todos.length) lines.push("", "To do:", ...s2.todos.map((t) => ` - ${t}`));
28980
29567
  lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
28981
29568
  if (!write) lines.push("", ctx.out.c.dim(`${relName(configPath)}:`), ...JSON.stringify(s2.config, null, 2).split("\n").map((l) => ctx.out.c.dim(` ${l}`)));
@@ -28997,6 +29584,22 @@ async function readNotices(ctx, client, siteFlag) {
28997
29584
  return { error: `Could not read the Notice settings of ${site} (${err.message}).` };
28998
29585
  }
28999
29586
  }
29587
+ async function attachSiteForCoupons(ctx, client, jobs, siteFlag) {
29588
+ const withCoupon = jobs.filter((j) => j.type === "stripe_checkout" && j.body && couponIdsIn(j.body.params).length);
29589
+ if (!withCoupon.length) return;
29590
+ let site;
29591
+ try {
29592
+ site = await resolveSite(ctx, siteFlag, client);
29593
+ } catch (err) {
29594
+ for (const j of withCoupon) j.warnings.push(`The coupon was not read from Stripe: ${err.message} --online reads it through the site's connected Stripe key once it knows the site (--site or CHECKBOX_SITE).`);
29595
+ return;
29596
+ }
29597
+ for (const j of withCoupon) j.body = { ...j.body, site };
29598
+ }
29599
+ function couponIdsIn(params) {
29600
+ const discounts = params?.discounts;
29601
+ return (Array.isArray(discounts) ? discounts : []).flatMap((d) => d && typeof d === "object" && typeof d.coupon === "string" ? [d.coupon] : []);
29602
+ }
29000
29603
  var HELP2, NOTICE_KIND_TO_EMAIL, ci_default;
29001
29604
  var init_ci = __esm({
29002
29605
  "src/commands/ci/index.ts"() {
@@ -29017,7 +29620,8 @@ var init_ci = __esm({
29017
29620
  "",
29018
29621
  "Flags:",
29019
29622
  " --config <path> Config file (default: checkbox.json, then .checkbox.json)",
29020
- " --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages and site-aware checks;",
29623
+ " --online Use the API (CHECKBOX_SECRET_KEY / CHECKBOX_API_KEY): URL pages (these need a live-mode",
29624
+ ' key on an activated account; with a test key list the built page as "file") and site-aware checks;',
29021
29625
  ` with "notices" in checkbox.json, also checks that the site's Checkbox Notices really send them`,
29022
29626
  " --site <site_\u2026> The site for --online (default: $CHECKBOX_SITE, the site `checkbox init` saved, or the only one)",
29023
29627
  ' --strict "Needs review" fails too (exit 2)',
@@ -29033,12 +29637,14 @@ var init_ci = __esm({
29033
29637
  "",
29034
29638
  "Exit: 0 pass (or needs review without --strict) \xB7 1 a check needs a fix \xB7 2 needs review with --strict, or a",
29035
29639
  " page / disclosure with no renewal terms to judge, a checkout whose coupon / trial is not verified, or a",
29036
- ' checkout entry with "strict": true that needs review \xB7',
29640
+ ' checkout entry with "strict": true that needs review, or nothing was checked (every check skipped or',
29641
+ " not applicable, e.g. url pages without --online) \xB7",
29037
29642
  " 3 config / usage error \xB7 4 auth (--online) \xB7 5 API or network error",
29038
29643
  "",
29039
29644
  "checkbox ci init [--yes] [--force] [--dry-run] [--no-workflow]",
29040
29645
  ' Writes checkbox.json from the project (Stripe Checkout calls, plans, email templates, pages; "TODO" where it',
29041
- " cannot tell) and .github/workflows/checkbox.yml. Asks first on a terminal; --yes writes without asking (agents).",
29646
+ ' cannot tell) and .github/workflows/checkbox.yml (with the install and build steps a fresh clone needs for "module"',
29647
+ " entries and built pages). Asks first on a terminal; --yes writes without asking (agents).",
29042
29648
  "",
29043
29649
  "Docs: https://checkbox.site/docs/guides/ci"
29044
29650
  ].join("\n");
@@ -29081,7 +29687,7 @@ var init_ci = __esm({
29081
29687
  const workspace = ctx.env.GITHUB_WORKSPACE ?? ctx.cwd;
29082
29688
  const extra = (loaded.config.coverage === false ? [] : coverageResults(coverageOf(loaded, loaded.dir))).map((r) => ({
29083
29689
  ...r,
29084
- target: { ...r.target, file: relative5(workspace, join8(loaded.dir, r.target.file)).split("\\").join("/") || r.target.file }
29690
+ target: { ...r.target, file: relative5(workspace, join9(loaded.dir, r.target.file)).split("\\").join("/") || r.target.file }
29085
29691
  }));
29086
29692
  if (bool(p.flags, "changed-only")) {
29087
29693
  const base = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
@@ -29118,6 +29724,7 @@ ${message}
29118
29724
  return EXIT.auth;
29119
29725
  }
29120
29726
  }
29727
+ if (client) await attachSiteForCoupons(ctx, client, jobs, str(p.flags, "site"));
29121
29728
  const engine = client ? onlineEngine(client) : offlineEngine();
29122
29729
  const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29123
29730
  const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
@@ -29353,8 +29960,8 @@ var init_patch = __esm({
29353
29960
  });
29354
29961
 
29355
29962
  // src/commands/plan/fix.ts
29356
- import { existsSync as existsSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
29357
- import { relative as relative6, resolve as resolve7 } from "node:path";
29963
+ import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
29964
+ import { relative as relative6, resolve as resolve8 } from "node:path";
29358
29965
  function fixForTask(task, cwd, file) {
29359
29966
  const base = {
29360
29967
  object: "task_fix",
@@ -29366,12 +29973,12 @@ function fixForTask(task, cwd, file) {
29366
29973
  verify: task.verify
29367
29974
  };
29368
29975
  const verifyCmd = `checkbox verify ${task.id} --wait`;
29369
- const candidate = file ?? (task.where.file_hint && existsSync8(resolve7(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
29976
+ const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
29370
29977
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
29371
- const path = resolve7(cwd, candidate);
29978
+ const path = resolve8(cwd, candidate);
29372
29979
  let src;
29373
29980
  try {
29374
- src = readFileSync9(path, "utf8");
29981
+ src = readFileSync10(path, "utf8");
29375
29982
  } catch (err) {
29376
29983
  throw new UsageError(`Cannot read ${candidate}: ${err.code ?? err.message}`);
29377
29984
  }
@@ -29431,7 +30038,7 @@ var init_fix = __esm({
29431
30038
  const task = await ctx.client().sdk.tasks.retrieve(id);
29432
30039
  const result = fixForTask(task, ctx.cwd, str(flags, "file"));
29433
30040
  const out = str(flags, "out");
29434
- if (out && result.diff) writeFileSync3(resolve7(ctx.cwd, out), result.diff);
30041
+ if (out && result.diff) writeFileSync3(resolve8(ctx.cwd, out), result.diff);
29435
30042
  ctx.out.result({ ...result, task_object: task }, () => {
29436
30043
  const c = ctx.out.c;
29437
30044
  const lines = [`${c.bold(task.title)} ${c.dim(`(${task.priority}, ${task.id})`)}`, c.dim(`${task.requirement.citation}`), task.why, ""];
@@ -29533,30 +30140,41 @@ var init_device = __esm({
29533
30140
  });
29534
30141
 
29535
30142
  // src/commands/init/patch.ts
29536
- import { execFileSync as execFileSync2 } from "node:child_process";
29537
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "node:fs";
29538
- import { dirname as dirname7, join as join9 } from "node:path";
30143
+ import { execFileSync as execFileSync3 } from "node:child_process";
30144
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
30145
+ import { dirname as dirname8, join as join10 } from "node:path";
30146
+ function pinnedRelease(body) {
30147
+ const b = body;
30148
+ if (!b || typeof b.version !== "string" || !/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(b.version)) return void 0;
30149
+ if (typeof b.integrity !== "string" || !/^sha384-[A-Za-z0-9+/]{64}$/.test(b.integrity)) return void 0;
30150
+ return { version: b.version, integrity: b.integrity };
30151
+ }
30152
+ function snippetScriptTag(s2, jsx = false, attrs = "") {
30153
+ const pin = s2.pinned ? ` integrity="${s2.pinned.integrity}" ${jsx ? "crossOrigin" : "crossorigin"}="anonymous"` : "";
30154
+ return `<script${attrs} async src="${srcOf(s2)}"${pin} data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`;
30155
+ }
29539
30156
  function read(root, rel2) {
29540
30157
  try {
29541
- return readFileSync10(join9(root, rel2), "utf8");
30158
+ return readFileSync11(join10(root, rel2), "utf8");
29542
30159
  } catch {
29543
30160
  return null;
29544
30161
  }
29545
30162
  }
29546
30163
  function srcOf(s2) {
29547
- return `${s2.apiBase.replace(/\/+$/, "")}/s.js`;
30164
+ const api = s2.apiBase.replace(/\/+$/, "");
30165
+ return s2.pinned ? `${api}/s/${s2.pinned.version}.js` : `${api}/s.js`;
29548
30166
  }
29549
30167
  function jsxBlock(s2, indent) {
29550
30168
  return [
29551
30169
  `${indent}{/* ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet) */}`,
29552
- `${indent}<script async src="${srcOf(s2)}" data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`,
30170
+ `${indent}${snippetScriptTag(s2, true)}`,
29553
30171
  `${indent}{/* ${MARK_END} */}`
29554
30172
  ];
29555
30173
  }
29556
30174
  function htmlBlock(s2, indent, attrs = "") {
29557
30175
  return [
29558
30176
  `${indent}<!-- ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet) -->`,
29559
- `${indent}<script${attrs} async src="${srcOf(s2)}" data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`,
30177
+ `${indent}${snippetScriptTag(s2, false, attrs)}`,
29560
30178
  `${indent}<!-- ${MARK_END} -->`
29561
30179
  ];
29562
30180
  }
@@ -29615,7 +30233,7 @@ function planSnippet(d, s2) {
29615
30233
  const root = d.root;
29616
30234
  const file = d.layoutFile;
29617
30235
  const manual = `Add this tag inside <head> on every page (your root layout / template):
29618
- ${s2.scriptTag}`;
30236
+ ${s2.pinned ? snippetScriptTag(s2) : s2.scriptTag}`;
29619
30237
  if (!file) return { change: null, manual };
29620
30238
  const existing = read(root, file);
29621
30239
  const jsx = (f) => f === "nextjs-app" || f === "nextjs-pages" || f === "remix" || f === "react-router";
@@ -29631,15 +30249,15 @@ function planSnippet(d, s2) {
29631
30249
  }
29632
30250
  return { change: null, manual };
29633
30251
  }
29634
- if (existing.includes(`data-key="${s2.snippetKey}"`) && existing.includes(MARK_START)) {
29635
- return { change: { file, action: "unchanged", summary: "Snippet already installed", added: [] } };
30252
+ if (existing.includes(`data-key="${s2.snippetKey}"`) && existing.includes(MARK_START) && existing.includes(`src="${srcOf(s2)}"`) && (!s2.pinned || existing.includes(s2.pinned.integrity))) {
30253
+ return { change: { file, action: "unchanged", summary: s2.pinned ? `Snippet already installed (pinned release ${s2.pinned.version} with SRI)` : "Snippet already installed", added: [] } };
29636
30254
  }
29637
30255
  const isJsx = jsx(d.framework);
29638
30256
  const indentRe = isJsx ? d.framework === "nextjs-pages" ? /<Head\b[^>]*>/ : /<head\b[^>]*>/ : /<\/head>/i;
29639
30257
  const indent = isJsx ? indentOf2(existing, indentRe) : indentOf2(existing, /<\/head>/i, " ");
29640
30258
  const block = isJsx ? jsxBlock(s2, indent) : htmlBlock(s2, indent, d.framework === "astro" ? " is:inline" : "");
29641
30259
  const replaced = replaceMarked(existing, block);
29642
- if (replaced !== null) return { change: { file, action: "modify", summary: "Snippet tag updated (new site key)", contents: replaced, added: block } };
30260
+ if (replaced !== null) return { change: { file, action: "modify", summary: existing.includes(`data-key="${s2.snippetKey}"`) ? s2.pinned ? `Snippet tag pinned to release ${s2.pinned.version} with SRI` : "Snippet tag updated" : "Snippet tag updated (new site key)", contents: replaced, added: block } };
29643
30261
  let next = null;
29644
30262
  switch (d.framework) {
29645
30263
  case "nextjs-app": {
@@ -29670,7 +30288,7 @@ ${indentOf2(existing, /<Head\s*\/>/, "")}</Head>`) : null);
29670
30288
  }
29671
30289
  if (!next) return { change: null, manual: `Could not find <head> in ${file}. ${manual}` };
29672
30290
  if (!snippetPlacedSafely(next, isJsx)) return { change: null, manual: `Could not place the snippet safely in ${file} (its <head> is not where Checkbox can patch it without breaking the file), so ${file} was NOT changed. ${manual}` };
29673
- return { change: { file, action: "modify", summary: `Snippet added to <head> in ${file}`, contents: next, added: block } };
30291
+ return { change: { file, action: "modify", summary: `Snippet added to <head> in ${file}${s2.pinned ? ` (pinned release ${s2.pinned.version} with SRI)` : ""}`, contents: next, added: block } };
29674
30292
  }
29675
30293
  function envVarNames(d) {
29676
30294
  return {
@@ -29716,7 +30334,7 @@ function mask(v) {
29716
30334
  }
29717
30335
  function isGitTracked(root, file) {
29718
30336
  try {
29719
- execFileSync2("git", ["ls-files", "--error-unmatch", "--", file], { cwd: root, stdio: "ignore", timeout: 5e3 });
30337
+ execFileSync3("git", ["ls-files", "--error-unmatch", "--", file], { cwd: root, stdio: "ignore", timeout: 5e3 });
29720
30338
  return true;
29721
30339
  } catch {
29722
30340
  return false;
@@ -29733,7 +30351,7 @@ function planGitignore(d) {
29733
30351
  const covered = lines.some((l) => l === env2 || l === `/${env2}` || l === ".env*" || l === "*.env" || env2 === ".env.local" && (l === ".env*.local" || l === "*.local" || l === ".env.local*") || env2 === ".env" && l === ".env*");
29734
30352
  if (covered) return null;
29735
30353
  }
29736
- if (existing === null && !existsSync9(join9(d.root, ".git"))) return null;
30354
+ if (existing === null && !existsSync10(join10(d.root, ".git"))) return null;
29737
30355
  const text = `${existing ?? ""}${existing && !existing.endsWith("\n") ? "\n" : ""}# Checkbox keys (checkbox init)
29738
30356
  ${env2}
29739
30357
  `;
@@ -29749,8 +30367,8 @@ function planWellKnown(d, token) {
29749
30367
  }
29750
30368
  function applyChange(root, c) {
29751
30369
  if (c.action === "unchanged" || c.contents === void 0) return;
29752
- const abs = join9(root, c.file);
29753
- mkdirSync3(dirname7(abs), { recursive: true });
30370
+ const abs = join10(root, c.file);
30371
+ mkdirSync3(dirname8(abs), { recursive: true });
29754
30372
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
29755
30373
  }
29756
30374
  var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
@@ -29778,7 +30396,7 @@ ${jsxBlock(s2, " ").join("\n")}
29778
30396
  NUXT_PLUGIN = (s2) => `// ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet)
29779
30397
  export default defineNuxtPlugin(() => {
29780
30398
  useHead({
29781
- script: [{ src: '${srcOf(s2)}', async: true, 'data-key': '${s2.snippetKey}', 'data-api': '${s2.apiBase.replace(/\/+$/, "")}' }],
30399
+ script: [{ src: '${srcOf(s2)}', async: true,${s2.pinned ? ` integrity: '${s2.pinned.integrity}', crossorigin: 'anonymous',` : ""} 'data-key': '${s2.snippetKey}', 'data-api': '${s2.apiBase.replace(/\/+$/, "")}' }],
29782
30400
  });
29783
30401
  });
29784
30402
  // ${MARK_END}
@@ -29797,8 +30415,8 @@ __export(init_exports, {
29797
30415
  parsePlanFlag: () => parsePlanFlag,
29798
30416
  syncCheckoutConfigs: () => syncCheckoutConfigs
29799
30417
  });
29800
- import { existsSync as existsSync10 } from "node:fs";
29801
- import { join as join10 } from "node:path";
30418
+ import { existsSync as existsSync11 } from "node:fs";
30419
+ import { join as join11 } from "node:path";
29802
30420
  import { createInterface as createInterface2 } from "node:readline";
29803
30421
  function loadState(ctx, root) {
29804
30422
  const cfg = ctx.config.load().init ?? {};
@@ -29818,7 +30436,7 @@ function saveState(ctx, root, patch) {
29818
30436
  async function ask2(question) {
29819
30437
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
29820
30438
  try {
29821
- return await new Promise((resolve9) => rl.question(question, resolve9));
30439
+ return await new Promise((resolve10) => rl.question(question, resolve10));
29822
30440
  } finally {
29823
30441
  rl.close();
29824
30442
  }
@@ -29879,7 +30497,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
29879
30497
  const call = (perFile.get(s2.file) ?? 0) + 1;
29880
30498
  perFile.set(s2.file, call);
29881
30499
  if (!registrable(s2.params, s2.unresolved)) {
29882
- out.push({ file: s2.file, line: s2.line, skipped: s2.unresolved.length ? `values not readable statically: ${s2.unresolved.slice(0, 5).join(", ")}` : "not a subscription-mode Checkout Session" });
30500
+ out.push({ file: s2.file, line: s2.line, skipped: s2.unresolved.length ? `values not readable statically: ${s2.unresolved.slice(0, 5).join(", ")} (a variable, call or template string). Next: register the exact params your server sends with createStripeCheckoutConfig (POST /v1/sites/{site}/stripe_checkout_configs), or, once Stripe is connected, create one Checkout Session in Stripe test mode so Checkbox reads it` : "not a subscription-mode Checkout Session" });
29883
30501
  continue;
29884
30502
  }
29885
30503
  try {
@@ -29896,7 +30514,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
29896
30514
  const from = configFileOf(c.name);
29897
30515
  if (!from) continue;
29898
30516
  const sameFile = perFile.has(from.file);
29899
- const gone = !sameFile && !existsSync10(join10(root, from.file));
30517
+ const gone = !sameFile && !existsSync11(join11(root, from.file));
29900
30518
  const current = sameFile && from.call !== void 0 && from.call <= perFile.get(from.file) && c.name === configNameOf(from.file, from.call);
29901
30519
  if (!sameFile && !gone || current) continue;
29902
30520
  if (await client.sdk.stripeCheckoutConfigs.del(c.id).then(() => true, () => false)) {
@@ -29917,6 +30535,13 @@ function configLines(configs, now = Date.now()) {
29917
30535
  };
29918
30536
  return ["Checkout params registered:", ...configs.map((c) => c.id ? ` ${c.file}:${c.line} \u2192 ${c.id}${age(c.registered_at)}${c.replaced?.length ? ` (replaced ${c.replaced.length} older config${c.replaced.length === 1 ? "" : "s"})` : ""}` : ` ${c.file}${c.line ? `:${c.line}` : ""}: ${c.skipped}`)];
29919
30537
  }
30538
+ async function snippetRelease(client) {
30539
+ try {
30540
+ return pinnedRelease(await client.get("/s/latest.json"));
30541
+ } catch {
30542
+ return void 0;
30543
+ }
30544
+ }
29920
30545
  function registrable(params, unresolved) {
29921
30546
  if (!params) return false;
29922
30547
  if (unresolved.some((u) => /^(mode|line_items|subscription_data|custom_text|consent_collection)\b/.test(u))) return false;
@@ -29936,6 +30561,7 @@ function detectionSummary(d, siteUrl) {
29936
30561
  pay_buttons: d.payButtons.map((c) => ({ file: c.file, line: c.line })),
29937
30562
  checkout_sessions: d.checkoutSessions.map((s2) => ({ file: s2.file, line: s2.line, readable: !!s2.params, unresolved: s2.unresolved })),
29938
30563
  stripe_test_key: d.stripeTestKey ? { hint: d.stripeTestKey.hint, source: d.stripeTestKey.source, restricted: d.stripeTestKey.restricted } : null,
30564
+ ...d.stripeKeyRefused ? { stripe_key_refused: { ...d.stripeKeyRefused, message: stripeKeyRefusedMessage(d.stripeKeyRefused) } } : {},
29939
30565
  merchant_name: d.brand ?? null,
29940
30566
  plans: d.plans
29941
30567
  };
@@ -30015,7 +30641,7 @@ var init_init = __esm({
30015
30641
  init_patch2();
30016
30642
  VERSION_FLAGS = {
30017
30643
  string: ["site-url", "checkout-url", "account-url", "terms-url", "privacy-url", "refund-url", "cancellation-url", "wait-seconds", "agent", "support-email", "merchant-name", "plan"],
30018
- boolean: ["yes", "dry-run", "open", "wait", "verify", "complete", "live", "stripe", "snippet", "relogin"],
30644
+ boolean: ["yes", "dry-run", "open", "wait", "verify", "complete", "live", "stripe", "snippet", "pin", "relogin"],
30019
30645
  alias: { y: "yes" }
30020
30646
  };
30021
30647
  interactive = (ctx) => !ctx.flags.json && !!process.stdin.isTTY && !!process.stderr.isTTY;
@@ -30027,10 +30653,13 @@ var init_init = __esm({
30027
30653
  "Run it in the project root. What it does (idempotent; re-run any time):",
30028
30654
  " 1. detects the framework, billing platform, checkout code and pay buttons",
30029
30655
  ' 2. signs you in: prints ONE link ("Open \u2026 and click Approve") and opens the browser; new accounts are free',
30030
- " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it)",
30031
- " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments)",
30656
+ " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it); when that file is",
30657
+ " tracked by git, or the folder is deployed as it is, the key is not written to it and stays only in the CLI config",
30658
+ " (~/.config/checkbox/config.json), which the output says",
30659
+ " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments): the pinned release",
30660
+ " (/s/<version>.js with integrity= and crossorigin=, Subresource Integrity); --no-pin uses the unpinned /s.js",
30032
30661
  " 5. registers the site, Terms / refund / cancellation policy URLs (fetched and checked by Checkbox)",
30033
- " 6. with your permission, uses the project's Stripe TEST key for a synthetic test subscription",
30662
+ " 6. with your permission, uses the project's Stripe restricted TEST key (rk_test_\u2026; sk_test_ and live keys are refused) for a synthetic test subscription",
30034
30663
  " 7. runs the first checks and prints the next step (`checkbox plan --json`); the human gets the summary email",
30035
30664
  " when the setup is finished (every check passes, or `checkbox init --complete`)",
30036
30665
  "",
@@ -30226,7 +30855,20 @@ var init_init = __esm({
30226
30855
  }
30227
30856
  const policyFromPaths = (kind) => siteUrl && d.policyPaths[kind] ? `${siteUrl}${d.policyPaths[kind]}` : void 0;
30228
30857
  const agent = detectAgent(ctx.env, agentName);
30229
- const plans = flagPlans ?? (d.plans.length ? d.plans.map(({ source: _source, ...p }) => p) : void 0);
30858
+ let plans = flagPlans ?? (d.plans.length ? d.plans.map(({ source: _source, ...p }) => p) : void 0);
30859
+ if (plans?.length && !planFlag) {
30860
+ let current = null;
30861
+ try {
30862
+ current = await client.sdk.setupSessions.retrieve(sessionId);
30863
+ } catch {
30864
+ current = null;
30865
+ }
30866
+ const saved = Array.isArray(current?.business?.plans) ? current.business.plans : [];
30867
+ if (saved.length) {
30868
+ plans = void 0;
30869
+ out.info(`Kept the ${saved.length} plan${saved.length === 1 ? "" : "s"} already saved for this site (pass --plan to replace ${saved.length === 1 ? "it" : "them"}).`);
30870
+ }
30871
+ }
30230
30872
  const brand = merchantName ?? d.brand?.name;
30231
30873
  const update = {
30232
30874
  ...siteUrl ? { site_url: siteUrl } : {},
@@ -30261,7 +30903,9 @@ var init_init = __esm({
30261
30903
  if (session.install.script_tag && session.install.snippet_key) {
30262
30904
  if (bool(flags, "snippet") === false) manualSnippet = session.install.script_tag;
30263
30905
  else {
30264
- const plan2 = planSnippet(d, { scriptTag: session.install.script_tag, snippetKey: session.install.snippet_key, apiBase });
30906
+ const pinned = bool(flags, "pin") === false ? void 0 : await snippetRelease(client);
30907
+ if (!pinned && bool(flags, "pin") !== false) warnings.push("The pinned snippet release (/s/latest.json) could not be read, so the unpinned /s.js tag was used. Run `checkbox init` again to pin it with Subresource Integrity.");
30908
+ const plan2 = planSnippet(d, { scriptTag: session.install.script_tag, snippetKey: session.install.snippet_key, apiBase, ...pinned ? { pinned } : {} });
30265
30909
  if (plan2.change) changes.push(plan2.change);
30266
30910
  if (plan2.manual) manualSnippet = plan2.manual;
30267
30911
  }
@@ -30292,13 +30936,16 @@ var init_init = __esm({
30292
30936
  if (consent && d.stripeTestKey && !session.stripe.test_credential_stored) {
30293
30937
  try {
30294
30938
  await client.sdk.testCredentials.create(site, { type: "stripe_test", secret_key: d.stripeTestKey.key });
30295
- stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted: d.stripeTestKey.restricted };
30296
- if (!d.stripeTestKey.restricted) warnings.push(`Stored your full Stripe TEST key (${d.stripeTestKey.hint}) because no restricted key was found. Recommended: create a restricted test key (${session.stripe.restricted_key.create_url}) with ${session.stripe.restricted_key.permissions.map((p) => `${p.resource} ${p.access}`).join(", ")}, put it in STRIPE_RESTRICTED_KEY and run \`checkbox init\` again.`);
30939
+ stripe.test_credential = { stored: true, key: d.stripeTestKey.hint, restricted: true };
30297
30940
  } catch (err) {
30298
30941
  stripe.test_credential = { stored: false, error: err instanceof ApiError ? `${err.code ?? ""} ${err.message}`.trim() : String(err) };
30299
30942
  warnings.push(`Stripe test key not stored: ${stripe.test_credential.error}`);
30300
30943
  }
30301
30944
  }
30945
+ if (!d.stripeTestKey && d.stripeKeyRefused) {
30946
+ stripe.test_credential = { stored: false, refused: d.stripeKeyRefused.kind };
30947
+ warnings.push(stripeKeyRefusedMessage(d.stripeKeyRefused));
30948
+ }
30302
30949
  const configs = await syncCheckoutConfigs(client, site, d.checkoutSessions, root);
30303
30950
  if (configs.length) stripe.checkout_configs = configs;
30304
30951
  const registered = configs.find((c) => c.id);
@@ -30401,10 +31048,11 @@ var init_whoami = __esm({
30401
31048
  object: "whoami",
30402
31049
  key: creds ? maskKey(creds.apiKey) : null,
30403
31050
  key_source: creds?.source ?? null,
31051
+ ...creds?.file ? { key_file: creds.file } : {},
30404
31052
  api_base: client.apiBase,
30405
31053
  account: account.raw
30406
31054
  },
30407
- () => [describeAccount(account), ctx.out.c.dim(`key ${creds ? maskKey(creds.apiKey) : "?"} from ${creds?.source ?? "?"} \xB7 ${client.apiBase}`)]
31055
+ () => [describeAccount(account), ctx.out.c.dim(`key ${creds ? maskKey(creds.apiKey) : "?"} from ${creds ? describeKeySource(creds, ctx.env) : "?"} \xB7 ${client.apiBase}`)]
30408
31056
  );
30409
31057
  return EXIT.ok;
30410
31058
  }
@@ -30421,7 +31069,7 @@ import { createInterface as createInterface3 } from "node:readline";
30421
31069
  async function prompt(question) {
30422
31070
  const rl = createInterface3({ input: process.stdin, output: process.stderr, terminal: true });
30423
31071
  try {
30424
- return await new Promise((resolve9) => rl.question(question, resolve9));
31072
+ return await new Promise((resolve10) => rl.question(question, resolve10));
30425
31073
  } finally {
30426
31074
  rl.close();
30427
31075
  }
@@ -30555,11 +31203,11 @@ var init_plan = __esm({
30555
31203
  });
30556
31204
 
30557
31205
  // src/commands/plan/verify.ts
30558
- import { readFileSync as readFileSync11 } from "node:fs";
30559
- import { resolve as resolve8 } from "node:path";
31206
+ import { readFileSync as readFileSync12 } from "node:fs";
31207
+ import { resolve as resolve9 } from "node:path";
30560
31208
  function readJson2(ctx, file, what) {
30561
31209
  try {
30562
- const v = JSON.parse(readFileSync11(resolve8(ctx.cwd, file), "utf8"));
31210
+ const v = JSON.parse(readFileSync12(resolve9(ctx.cwd, file), "utf8"));
30563
31211
  if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
30564
31212
  return v;
30565
31213
  } catch (err) {
@@ -30605,7 +31253,7 @@ var init_verify2 = __esm({
30605
31253
  const htmlFile = str(flags, "html-file");
30606
31254
  if (htmlFile) {
30607
31255
  try {
30608
- body.html = readFileSync11(resolve8(ctx.cwd, htmlFile), "utf8");
31256
+ body.html = readFileSync12(resolve9(ctx.cwd, htmlFile), "utf8");
30609
31257
  } catch (err) {
30610
31258
  throw new UsageError(`Cannot read ${htmlFile}: ${err.code ?? err.message}`);
30611
31259
  }