@checkbox-site/cli 0.4.7 → 0.4.9

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 (2) hide show
  1. package/dist/checkbox.js +804 -208
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -144,7 +144,8 @@ function findProjectKey(cwd, env2 = {}) {
144
144
  const v = readEnvValue(text, "CHECKBOX_SECRET_KEY");
145
145
  if (v && looksLikeApiKey(v)) {
146
146
  const up = depth === 0 ? "" : "../".repeat(depth);
147
- return { apiKey: v, file: `${up}${f}` };
147
+ const base = readEnvValue(text, "CHECKBOX_API_BASE");
148
+ return { apiKey: v, file: `${up}${f}`, ...base && /^https?:\/\//.test(base) ? { apiBase: base } : {} };
148
149
  }
149
150
  }
150
151
  if (existsSync(join(dir, ".git")) || dir === home) break;
@@ -198,12 +199,12 @@ function createConfigStore(env2) {
198
199
  };
199
200
  }
200
201
  function resolveCredentials(env2, stored, flags, project) {
201
- const apiBase = (flags.apiBase || env2.CHECKBOX_API_BASE || stored.api_base || DEFAULT_API_BASE).replace(/\/+$/, "");
202
- if (flags.apiKey) return { apiKey: flags.apiKey, apiBase, source: "flag" };
203
- if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase, source: "env" };
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 };
206
- if (stored.api_key) return { apiKey: stored.api_key, apiBase, source: "config" };
202
+ const base = (own) => (flags.apiBase || env2.CHECKBOX_API_BASE || own || DEFAULT_API_BASE).replace(/\/+$/, "");
203
+ if (flags.apiKey) return { apiKey: flags.apiKey, apiBase: base(), source: "flag" };
204
+ if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase: base(), source: "env" };
205
+ if (env2.CHECKBOX_SECRET_KEY) return { apiKey: env2.CHECKBOX_SECRET_KEY, apiBase: base(), source: "env" };
206
+ if (project) return { apiKey: project.apiKey, apiBase: base(project.apiBase), source: "project", file: project.file };
207
+ if (stored.api_key) return { apiKey: stored.api_key, apiBase: base(stored.api_base), source: "config" };
207
208
  return void 0;
208
209
  }
209
210
  function describeKeySource(c, env2 = {}) {
@@ -3030,7 +3031,7 @@ var init_operations = __esm({
3030
3031
  /**
3031
3032
  * Check a subscription lifecycle email.
3032
3033
  *
3033
- * Checks the content of an acknowledgment (welcome) email, trial-ending reminder, renewal reminder, price-change notice, yearly terms reminder or cancellation confirmation against the requirements for that notice (renewal terms, amount, frequency, how to cancel, trial cancellation). The email is modelled as sent on time, so the verdict is about content; get send dates from checkNoticeSchedule. Prices are judged against what Stripe charges: with `site` (or, without it and without a price in terms, the account's one site with Stripe connected) the plan fields come from the site's plans and the email is matched to the plan it names ("Annual") first; a price that contradicts it fails, and `warnings` say which site and plan were used. Without `site`, a price you send in terms is judged as sent; when it differs from every plan of that site, the result is at most needs_review and Next is update_setup (update the site's plans first). Returns `required_by` (requirements that make the email mandatory for this plan) and a `template` that passes the checks, with {{merge_tags}}, verified by the engine. 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.
3034
+ * Checks the content of an acknowledgment (welcome) email, trial-ending reminder, renewal reminder, price-change notice, yearly terms reminder or cancellation confirmation against the requirements for that notice (renewal terms, amount, frequency, how to cancel, trial cancellation). The email is modelled as sent on time, so the verdict is about content; get send dates from checkNoticeSchedule. Prices are judged against what Stripe charges: with `site` (or, without it and without a price in terms, the account's one site with Stripe connected) the plan fields come from the site's plans and the email is matched to the plan it names ("Annual") first; a price that contradicts it fails, and `warnings` say which site and plan were used. Without `site`, a price you send in terms is judged as sent; when it differs from every plan of that site, the verdict is still judged with the terms you sent and a `site_plans_differ` warning says to update the site's plans (update_setup) once the change is deployed. Returns `required_by` (requirements that make the email mandatory for this plan) and a `template` that passes the checks, with {{merge_tags}}, verified by the engine. 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.
3034
3035
  *
3035
3036
  * @see POST /v1/checks/email (operation `checkEmail`, scope `checks:read`)
3036
3037
  */
@@ -3295,7 +3296,7 @@ var init_operations = __esm({
3295
3296
  /**
3296
3297
  * Store a billing test-mode key.
3297
3298
  *
3298
- * Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); the owner must have ticked "Let your agent store a Stripe test-mode key" on the approval page of the agent's sign-in or connection (else test_key_declined); a key whose Stripe account looks like another site's is refused with billing_account_mismatch (with confirm_account_mismatch: true the key is connected on hold until a person confirms the account in the dashboard; only a person can confirm it); secret keys (sk_test_…) are refused (test_key_not_restricted). On a test-mode site without a Stripe connection the key also becomes the site's Stripe connection: one key for checks, fixes (only after the human approves each) and test runs. The key's checklist: Customers: Write, Products: Read, Prices: Read, Coupons: Read, Subscriptions: Write, Test clocks: Write, Customer portal: Write, Checkout Sessions: Write, Payment Links: Write, Webhook Endpoints: Write. Replaces an existing key. Next: createTestRun.
3299
+ * Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); the owner must have ticked the box “Let your agent store a Stripe test-mode restricted key for this project” (agent sign-in) or “Let this app store a Stripe test-mode restricted key for your sites” (app connection) (checkbox id consent-stripe-test-key) on the approval page of the agent's sign-in or connection (else test_key_declined); a key whose Stripe account looks like another site's is refused with billing_account_mismatch (with confirm_account_mismatch: true the key is connected on hold until a person confirms the account in the dashboard; only a person can confirm it); secret keys (sk_test_…) are refused (test_key_not_restricted). On a test-mode site without a Stripe connection the key also becomes the site's Stripe connection: one key for checks, fixes (only after the human approves each) and test runs. The key's checklist: Customers: Write, Products: Read, Prices: Read, Coupons: Read, Subscriptions: Write, Test clocks: Write, Customer portal: Write, Checkout Sessions: Write, Payment Links: Write, Webhook Endpoints: Write. Replaces an existing key. Next: createTestRun.
3299
3300
  *
3300
3301
  * @param site Unique identifier of the site (site_…).
3301
3302
  * @see POST /v1/sites/{site}/test_credentials (operation `createTestCredential`, scope `test_runs:write`)
@@ -3617,7 +3618,7 @@ var SDK_VERSION;
3617
3618
  var init_version = __esm({
3618
3619
  "../sdk-node/src/version.ts"() {
3619
3620
  "use strict";
3620
- SDK_VERSION = "0.2.5";
3621
+ SDK_VERSION = "0.2.7";
3621
3622
  }
3622
3623
  });
3623
3624
 
@@ -4757,12 +4758,16 @@ var init_stripe_ids = __esm({
4757
4758
  function stripeKeyPermissionLine(filter) {
4758
4759
  return STRIPE_KEY_PERMISSIONS.filter(filter ?? (() => true)).map((p) => `${p.resource}: ${p.access}`).join(", ");
4759
4760
  }
4761
+ function testKeyBoxName(via = "either") {
4762
+ const label = via === "device" ? `\u201C${DEVICE_TEST_KEY_LABEL}\u201D` : via === "oauth" ? `\u201C${OAUTH_TEST_KEY_LABEL}\u201D` : `\u201C${DEVICE_TEST_KEY_LABEL}\u201D (agent sign-in) or \u201C${OAUTH_TEST_KEY_LABEL}\u201D (app connection)`;
4763
+ return `the box ${label} (checkbox id ${TEST_KEY_CHECKBOX_ID})`;
4764
+ }
4760
4765
  function approvalKeyAsk(opts = {}) {
4761
4766
  if (opts.stripe === false) return "";
4762
4767
  const create = `create a restricted key in Stripe Test mode (${STRIPE_TEST_API_KEYS_URL} \u2192 Create restricted key) ticking ${stripeKeyPermissionLine()}, and put it in the project's env file (never in the chat)`;
4763
- return `${opts.stripe ? "In the same message" : "If the site sells through Stripe, in the same message"}, ask them to tick \u201CLet your agent store a Stripe test-mode key\u201D on that page${opts.keyFound ? "" : ` and, if the project has no rk_test_ key yet, to ${create}`}: one approval then covers the key and the test subscription.`;
4768
+ return `${opts.stripe ? "In the same message" : "If the site sells through Stripe, in the same message"}, ask them to tick ${testKeyBoxName("device")} on that page${opts.keyFound ? "" : ` and, if the project has no rk_test_ key yet, to ${create}`}: one approval then covers the key and the test subscription.`;
4764
4769
  }
4765
- var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL;
4770
+ var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL, DEVICE_TEST_KEY_LABEL, OAUTH_TEST_KEY_LABEL, TEST_KEY_CHECKBOX_ID;
4766
4771
  var init_stripe_key = __esm({
4767
4772
  "../core/src/stripe-key.ts"() {
4768
4773
  "use strict";
@@ -4779,6 +4784,9 @@ var init_stripe_key = __esm({
4779
4784
  { resource: "Webhook Endpoints", access: "Write", why: "Registers Checkbox\u2019s webhook endpoint, so renewals and cancellations are seen as they happen.", uses: ["checks"], writeWhen: "Used once, when you connect the key, to add Checkbox\u2019s own webhook endpoint (and to remove it when you disconnect); nothing else." }
4780
4785
  ];
4781
4786
  STRIPE_TEST_API_KEYS_URL = "https://dashboard.stripe.com/test/apikeys";
4787
+ DEVICE_TEST_KEY_LABEL = "Let your agent store a Stripe test-mode restricted key for this project";
4788
+ OAUTH_TEST_KEY_LABEL = "Let this app store a Stripe test-mode restricted key for your sites";
4789
+ TEST_KEY_CHECKBOX_ID = "consent-stripe-test-key";
4782
4790
  }
4783
4791
  });
4784
4792
 
@@ -4786,16 +4794,25 @@ var init_stripe_key = __esm({
4786
4794
  function goLiveSteps(input = {}) {
4787
4795
  const out = [];
4788
4796
  if (!input.domainVerified) out.push({ key: "verify_domain", who: "person", text: "Verify your domain (a DNS record or a file on your site). Live checks start only on a verified public domain." });
4789
- if (input.termsOfServiceRequired && !input.termsOfServiceUrlSet) out.push({ key: "stripe_terms_url", who: "person", text: GO_LIVE_TERMS_URL_STEP, url: STRIPE_PUBLIC_DETAILS });
4797
+ if (input.termsOfServiceRequired && !input.termsOfServiceUrlSet)
4798
+ out.push({ key: "stripe_terms_url", who: "person", text: input.checkoutKind === "payment_link" ? GO_LIVE_TERMS_URL_STEP_PAYMENT_LINK : input.termsOfServiceConditional ? GO_LIVE_TERMS_URL_STEP_CONDITIONAL : GO_LIVE_TERMS_URL_STEP, url: STRIPE_PUBLIC_DETAILS });
4790
4799
  if (!input.liveKeyConnected)
4791
4800
  out.push({
4792
4801
  key: "live_stripe_key",
4793
4802
  who: "person",
4794
- text: "Switch the dashboard to Live mode and connect your live Stripe key (rk_live_\u2026) with the same permissions as your test key (the checklist next to the key field: Read for the checks; Write only for fixes you approve and to add Checkbox\u2019s webhook endpoint). Only a person adds live keys; an agent never does."
4803
+ text: `Switch the dashboard to Live mode and connect your live Stripe key (rk_live_\u2026) with the same permissions as your test key (the checklist next to the key field: Read for the checks; Write only for fixes you approve and to add Checkbox\u2019s webhook endpoint).${input.noAgent ? " Only a person adds live keys." : " Only a person adds live keys; an agent never does."}`
4804
+ });
4805
+ if (input.portalCancel)
4806
+ out.push({
4807
+ key: "live_portal_cancel",
4808
+ who: "person",
4809
+ text: 'Turn on cancellation in the Stripe customer portal in live mode (Stripe Dashboard in Live mode \u203A Settings \u203A Billing \u203A Customer portal: "Cancel subscriptions", with the same timing as in test mode, and the customer login link). Stripe keeps these settings separately for test and live mode, so the test-mode setting does not carry over.',
4810
+ url: STRIPE_PORTAL_SETTINGS
4795
4811
  });
4796
- if (input.checkoutKind === "payment_link")
4797
- out.push({ key: "live_checkout", who: "person", text: "Create the live Payment Link in Stripe live mode with the same settings as the test link Checkbox checked (renewal terms, consent checkbox, cancellation link); then your agent puts the live link on your site in place of the test link." });
4798
- else if (input.checkoutKind === "stripe_checkout")
4812
+ if (input.checkoutKind === "payment_link") {
4813
+ out.push({ key: "live_checkout", who: "person", text: `Create the live Payment Link in Stripe live mode with the same settings as the test link Checkbox checked (renewal terms, consent checkbox, cancellation link); then put the live link on your site in place of the test link${input.noAgent ? "" : " (or ask your agent to)"}.` });
4814
+ out.push({ key: "deactivate_old_live_link", who: "person", text: "If an older live Payment Link without these settings exists, deactivate it in Stripe live mode (Payment Links \u203A the old link \u203A Deactivate): anyone with its URL could still subscribe without seeing the terms." });
4815
+ } else if (input.checkoutKind === "stripe_checkout")
4799
4816
  out.push({ key: "live_checkout", who: "agent", text: "Ship the same Checkout Session params Checkbox checked with your live Price ids (the live Stripe secret key in your production settings is set by a person)." });
4800
4817
  out.push({
4801
4818
  key: "promote_site",
@@ -4804,12 +4821,15 @@ function goLiveSteps(input = {}) {
4804
4821
  });
4805
4822
  return out;
4806
4823
  }
4807
- var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_STEPS;
4824
+ var STRIPE_PORTAL_SETTINGS, STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_TERMS_URL_STEP_CONDITIONAL, GO_LIVE_TERMS_URL_STEP_PAYMENT_LINK, GO_LIVE_STEPS;
4808
4825
  var init_go_live = __esm({
4809
4826
  "../core/src/go-live.ts"() {
4810
4827
  "use strict";
4828
+ STRIPE_PORTAL_SETTINGS = "https://dashboard.stripe.com/settings/billing/portal";
4811
4829
  STRIPE_PUBLIC_DETAILS = "https://dashboard.stripe.com/settings/public";
4812
4830
  GO_LIVE_TERMS_URL_STEP = 'Set your Terms of Service URL in the Stripe Dashboard (Settings > Public details). Your checkout asks shoppers to accept your terms (consent_collection.terms_of_service = "required"), and Stripe refuses those Checkout Sessions until that URL is set.';
4831
+ GO_LIVE_TERMS_URL_STEP_CONDITIONAL = 'Set your Terms of Service URL in the Stripe Dashboard (Settings > Public details) before your checkout asks shoppers to accept your terms (consent_collection.terms_of_service = "required", which the checkout fix adds): Stripe refuses those Checkout Sessions until that URL is set.';
4832
+ GO_LIVE_TERMS_URL_STEP_PAYMENT_LINK = "Set your Terms of Service URL in the Stripe Dashboard (Settings > Public details). Your Payment Link asks shoppers to accept your terms (\u201CRequire customers to accept your terms of service\u201D), and Stripe only allows that once the URL is set.";
4813
4833
  GO_LIVE_STEPS = goLiveSteps().map((s2) => s2.text);
4814
4834
  }
4815
4835
  });
@@ -4853,6 +4873,13 @@ var init_secrets = __esm({
4853
4873
  }
4854
4874
  });
4855
4875
 
4876
+ // ../core/src/site-unreachable.ts
4877
+ var init_site_unreachable = __esm({
4878
+ "../core/src/site-unreachable.ts"() {
4879
+ "use strict";
4880
+ }
4881
+ });
4882
+
4856
4883
  // ../core/src/index.ts
4857
4884
  var init_src2 = __esm({
4858
4885
  "../core/src/index.ts"() {
@@ -4864,6 +4891,7 @@ var init_src2 = __esm({
4864
4891
  init_stripe_key();
4865
4892
  init_go_live();
4866
4893
  init_secrets();
4894
+ init_site_unreachable();
4867
4895
  }
4868
4896
  });
4869
4897
 
@@ -5902,7 +5930,7 @@ var init_new_york = __esm({
5902
5930
  NYC_EFFECTIVE = "2026-10-01";
5903
5931
  NYC = "6 RCNY \xA7 5-110.1";
5904
5932
  NYC_RULE_VERIFIED = "2026-10-31";
5905
- NYC_NOTE = "NYC DCWP rule (adopted July 10, 2026; effective Oct. 1, 2026), verified against the adopted text on 2026-10-31. Applies to New York City consumers; evaluated wherever US-NY applies (territorial reach: open question 9). Exempt (\xA7 5-110.3): municipal franchise holders, DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers.";
5933
+ NYC_NOTE = "NYC DCWP rule (adopted July 10, 2026; effective Oct. 1, 2026), verified against the adopted text on 2026-10-31. Applies to New York City consumers; evaluated wherever US-NY applies (how far the rule reaches outside the city is not settled yet). Exempt (\xA7 5-110.3): municipal franchise holders, DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers.";
5906
5934
  PART_W_NOTE = "Current text from L. 2025, ch. 58, Part W, effective Nov. 5, 2025.";
5907
5935
  PRE_PART_W = "Prior version: L. 2020, ch. 267 text in force Feb. 9, 2021 - Nov. 4, 2025 (sunset when Part W took effect); kept so evaluations as of earlier dates apply the law then in force.";
5908
5936
  NEW_YORK_REQUIREMENTS = [
@@ -6351,12 +6379,12 @@ var init_new_york = __esm({
6351
6379
  sourceUrl: NYC_RULE_URL,
6352
6380
  effectiveDate: NYC_EFFECTIVE,
6353
6381
  lastVerified: NYC_RULE_VERIFIED,
6354
- summary: "City rule mirroring GBL \xA7 527-a: material terms (including the deadline to act) before consent or billing information; cancellation as easy as consent, in the same medium and every consent medium, with an online option (website or email) for in-person sign-ups; no obstruction or unreasonable save offers; 15-45 day renewal notice (initial term of one year or more renewing for six months or more) and 3-21 day trial notice (trials over a month) in the consumer-selected channel; material-change notice at least 5 business days and no more than 30 days before the change (Checkbox schedules it 7-30 calendar days ahead). Violators refund every amount charged after the first cancellation attempt (\xA7 5-110.2); DCWP penalties $525 / $1,050 / $3,500. Checked together with New York State's rules.",
6382
+ summary: "City rule mirroring GBL \xA7 527-a: material terms (including the deadline to act) before consent or billing information; cancellation as easy as consent, in the same medium and every consent medium, with an online option (website or email) for in-person sign-ups; no obstruction or unreasonable save offers; 15-45 day renewal notice (initial term of one year or more renewing for six months or more) and 3-21 day trial notice (trials over a month) in the consumer-selected channel; material-change notice at least 5 business days and no more than 30 days before the change (Checkbox Notices counts the business days, skipping weekends and public holidays). Violators refund every amount charged after the first cancellation attempt (\xA7 5-110.2); DCWP penalties $525 / $1,050 / $3,500. Checked together with New York State's rules.",
6355
6383
  status: "in_force",
6356
6384
  confidence: "medium",
6357
6385
  verification: "primary",
6358
6386
  requirementIdPrefixes: ["US-NY.NYC."],
6359
- scope: "New York City consumers. Exempt (\xA7 5-110.3): municipal franchise holders (e.g. cable), DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers. Territorial reach and interaction with state AG enforcement are open question 9 (hence medium confidence).",
6387
+ scope: "New York City consumers. Exempt (\xA7 5-110.3): municipal franchise holders (e.g. cable), DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers. How far the rule reaches outside the city, and how it works alongside the Attorney General's enforcement of the state law, are not settled yet (hence medium confidence).",
6360
6388
  amendments: [
6361
6389
  { citation: "DCWP Notice of Adoption (proposed Apr. 8, 2026; hearing May 8, 2026; adopted July 10, 2026)", effectiveDate: NYC_EFFECTIVE, summary: 'Adds 6 RCNY Part 8 "Click to Cancel" (\xA7\xA7 5-110 to 5-110.3).' }
6362
6390
  ]
@@ -10222,11 +10250,11 @@ function findRecurringPrices(text, amounts, freqs) {
10222
10250
  let best = null;
10223
10251
  for (const f of freqs) {
10224
10252
  const after = f.index >= money3.index;
10225
- const gap = after ? f.index - (money3.index + money3.text.length) : money3.index - (f.index + f.text.length);
10226
- if (gap < -1) continue;
10227
- if (gap > (after ? 45 : 60)) continue;
10253
+ const gap2 = after ? f.index - (money3.index + money3.text.length) : money3.index - (f.index + f.text.length);
10254
+ if (gap2 < -1) continue;
10255
+ if (gap2 > (after ? 45 : 60)) continue;
10228
10256
  if (SENTENCE_BREAK_RE.test(between(text, money3, f))) continue;
10229
- if (!best || gap < best.gap) best = { f, gap };
10257
+ if (!best || gap2 < best.gap) best = { f, gap: gap2 };
10230
10258
  }
10231
10259
  if (best) {
10232
10260
  const start = Math.min(money3.index, best.f.index);
@@ -11505,10 +11533,10 @@ function withIntroOffers(declared, billing, offers) {
11505
11533
  }
11506
11534
  function shortPlanTerms(p) {
11507
11535
  const digits = currencyDigitsOf(p.currency);
11508
- const major = p.amount / 10 ** digits;
11536
+ const major2 = p.amount / 10 ** digits;
11509
11537
  const cur = (p.currency || "usd").toLowerCase();
11510
11538
  const sym = { usd: "$", eur: "\u20AC", gbp: "\xA3", jpy: "\xA5" }[cur];
11511
- const num3 = Number.isInteger(major) ? String(major) : major.toFixed(digits);
11539
+ const num3 = Number.isInteger(major2) ? String(major2) : major2.toFixed(digits);
11512
11540
  const money3 = sym ? `${sym}${num3}` : `${num3} ${cur.toUpperCase()}`;
11513
11541
  const every = (p.intervalCount || 1) > 1 ? ` every ${p.intervalCount} ${p.interval}s` : `/${p.interval}`;
11514
11542
  const trial = (p.trialDays ?? 0) > 0 ? ` with a ${p.trialDays}-day free trial` : "";
@@ -11778,11 +11806,11 @@ function formatMoney(amount, currency) {
11778
11806
  } catch {
11779
11807
  digits = 2;
11780
11808
  }
11781
- const major = amount / 10 ** digits;
11809
+ const major2 = amount / 10 ** digits;
11782
11810
  try {
11783
- return new Intl.NumberFormat("en-US", { style: "currency", currency: code2, minimumFractionDigits: digits }).format(major);
11811
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: code2, minimumFractionDigits: digits }).format(major2);
11784
11812
  } catch {
11785
- return `${major.toFixed(digits)} ${code2}`;
11813
+ return `${major2.toFixed(digits)} ${code2}`;
11786
11814
  }
11787
11815
  }
11788
11816
  function intervalPhrase(interval, count = 1) {
@@ -12201,18 +12229,15 @@ function checkoutSessionsForSite(sessions, captures, checkoutType) {
12201
12229
  const ownCardForm = captures.some((c) => OWN_CARD_FORM_RE.test(c.text ?? ""));
12202
12230
  if (checkoutType !== "stripe_checkout" && !linksToCheckout && ownCardForm) return [];
12203
12231
  if (checkoutType !== "stripe_checkout" && !linksToCheckout && stripePayLinksIn(captures).length) return [];
12204
- const seen = /* @__PURE__ */ new Set();
12205
- const out = [];
12206
- for (const s2 of newestFirst(sessions)) {
12207
- if (s2.paymentLink || s2.checkboxTestRun) continue;
12208
- if (!(s2.lineItems ?? []).some((li) => !!li.interval || !!li.price)) continue;
12232
+ const picked = /* @__PURE__ */ new Map();
12233
+ newestFirst(sessions).forEach((s2, i) => {
12234
+ if (s2.paymentLink || s2.checkboxTestRun) return;
12235
+ if (!(s2.lineItems ?? []).some((li) => !!li.interval || !!li.price)) return;
12209
12236
  const key = (s2.lineItems ?? []).map((li) => li.price ?? `${li.amount}|${li.interval}|${li.intervalCount}`).sort().join(",");
12210
- if (seen.has(key)) continue;
12211
- seen.add(key);
12212
- out.push(s2);
12213
- if (out.length >= MAX_CHECKOUT_SESSIONS) break;
12214
- }
12215
- return out;
12237
+ const prev = picked.get(key);
12238
+ if (!prev || prev.s.status === "expired" && s2.status !== "expired") picked.set(key, { s: s2, i });
12239
+ });
12240
+ return [...picked.values()].sort((a, b) => a.i - b.i).slice(0, MAX_CHECKOUT_SESSIONS).map((x) => x.s);
12216
12241
  }
12217
12242
  function paymentLinkParams(link, opts = {}) {
12218
12243
  const items = (link.lineItems ?? []).filter((li) => li && (li.interval || li.price));
@@ -13405,7 +13430,7 @@ function checkoutConfigViews(ctx) {
13405
13430
  return (ctx.checkoutConfigs ?? []).filter((c) => c && c.capture).map((c) => ({
13406
13431
  ...buildSignupView(c.capture),
13407
13432
  label: `${c.kind === "payment_link" ? "Stripe Payment Link" : "Stripe Checkout config"} \u201C${c.name ?? c.id}\u201D`,
13408
- checkoutConfig: { id: c.id, ...c.name ? { name: c.name } : {}, consentRequired: c.consentRequired, ...c.kind ? { kind: c.kind } : {}, ...c.url ? { url: c.url } : {} },
13433
+ checkoutConfig: { id: c.id, ...c.name ? { name: c.name } : {}, consentRequired: c.consentRequired, ...c.kind ? { kind: c.kind } : {}, ...c.url ? { url: c.url } : {}, ...typeof c.trialDays === "number" ? { trialDays: c.trialDays } : {} },
13409
13434
  // A Payment Link is read from Stripe on every billing read: it shows the link as of that read.
13410
13435
  ...c.kind === "payment_link" && readAt ? { observedAt: readAt } : {}
13411
13436
  }));
@@ -13873,7 +13898,7 @@ function platformKey(platform) {
13873
13898
  }
13874
13899
  function portalUnlinkedFix(portalUrl) {
13875
13900
  const login = portalUrl && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(portalUrl) ? portalUrl : "billing.stripe.com/p/login/\u2026";
13876
- return `Add a \u201CManage subscription\u201D link to your account page that opens your Stripe customer portal (${login}), then run a scan so Checkbox sees it.`;
13901
+ return `Add a \u201CManage subscription\u201D link that opens your Stripe customer portal (${login}) to your account page, or to your home page or footer if your site has no account page, then run a scan so Checkbox sees it.`;
13877
13902
  }
13878
13903
  function paymentLinkCancelRemediation(portalUrl, loginPageOn) {
13879
13904
  const link = portalUrl && /^https:\/\/billing\.stripe\.com\//.test(portalUrl) ? portalUrl : void 0;
@@ -14979,6 +15004,18 @@ var init_help_pages = __esm({
14979
15004
  });
14980
15005
 
14981
15006
  // ../rules/src/checks/cancel-paths.ts
15007
+ function mayOpenPortal(el, pageUrl2) {
15008
+ const href = el.href;
15009
+ if (!href) return true;
15010
+ try {
15011
+ const target = new URL(href, pageUrl2);
15012
+ const page = new URL(pageUrl2);
15013
+ if (target.host !== page.host) return true;
15014
+ return /(?:^|\/)(?:api|portal|billing|billing-portal|create-portal-session|customer-portal)(?:\/|$|[-_?])/i.test(target.pathname);
15015
+ } catch {
15016
+ return true;
15017
+ }
15018
+ }
14982
15019
  function inAppPath(input) {
14983
15020
  const flow = input.ctx.cancellation?.via === "stripe_portal" ? void 0 : input.ctx.cancellation;
14984
15021
  if (flow) {
@@ -15014,7 +15051,7 @@ function inAppPath(input) {
15014
15051
  return true;
15015
15052
  }
15016
15053
  });
15017
- if (leadsOnline.length) return { kind: "in_app", state: "offered", detail: `your own page offers a cancel option (${p.url})`, url: p.url };
15054
+ if (leadsOnline.length) return { kind: "in_app", state: "offered", detail: `your own page offers a cancel option (${p.url})`, url: p.url, ...leadsOnline.every((el) => mayOpenPortal(el, p.url)) ? { mayOpenPortal: true } : {} };
15018
15055
  }
15019
15056
  if (input.ctx.facts?.cancelsInApp === true) return { kind: "in_app", state: "unconfirmed", detail: "subscribers cancel in your own app, but Checkbox has not seen a cancellation complete there yet" };
15020
15057
  return void 0;
@@ -15692,6 +15729,16 @@ var init_cancellation = __esm({
15692
15729
  { unverified: true, source: "snippet" }
15693
15730
  );
15694
15731
  }
15732
+ if (controls.length && cfg?.selfServeOnline === false && controls.every((el) => mayOpenPortal(el, page.url))) {
15733
+ return makeResult(
15734
+ id,
15735
+ "needs_review",
15736
+ `A cancel page with an online cancel option (\u201C${quote(controls[0].text)}\u201D) was found at ${page.url}, but cancellation is off in your billing platform's customer portal: if this option opens the portal, subscribers cannot cancel there. Not yet verified by a cancellation test: run a test run, or turn cancellation on in the portal`,
15737
+ [...captureRefs(page), ...configRefs],
15738
+ 0.5,
15739
+ { inAppOffered: true, url: page.url, selfServeOnline: false, portalCancelOff: true }
15740
+ );
15741
+ }
15695
15742
  if (controls.length) {
15696
15743
  return makeResult(
15697
15744
  id,
@@ -16115,6 +16162,16 @@ var init_config2 = __esm({
16115
16162
  }
16116
16163
  if (c.selfServeOnline === false) {
16117
16164
  const flow = input.ctx.cancellation;
16165
+ if (inApp?.state === "offered" && inApp.mayOpenPortal && model.hosted?.state !== "offered" && !(model.offline && !model.offline.soft)) {
16166
+ return makeResult(
16167
+ id,
16168
+ "needs_review",
16169
+ `Self-serve cancellation is off in ${name}'s portal. Your own page offers a cancel option (${inApp.url}), but Checkbox has not followed it to a finished cancellation yet: if it opens the ${name} customer portal, subscribers cannot cancel there. Run a test run (it cancels through your page), or turn cancellation on in the portal`,
16170
+ refs(input),
16171
+ 0.6,
16172
+ { ...data, ownCancelPage: true, inAppOfferedUntested: true, inAppUnobserved: true }
16173
+ );
16174
+ }
16118
16175
  if (model.otherOnlineWorks) {
16119
16176
  const via = inApp?.state === "works" && flow ? "subscribers cancel online through your own page (a test cancellation completed; see \u201Ccancel online\u201D)" : model.hosted?.state === "offered" ? `subscribers cancel through the Checkbox hosted cancellation page (${model.hosted.url}); see \u201Ccancel online\u201D` : `your own page offers a cancel option (${inApp?.url}); online cancellation is judged on that page (see \u201Ccancel online\u201D), so the portal setting is not required`;
16120
16177
  return makeResult(
@@ -16320,8 +16377,8 @@ function introEmailFinding(rawText, plans, opts) {
16320
16377
  const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
16321
16378
  if (!usable.length) return void 0;
16322
16379
  const text = normalizeText(rawText);
16323
- const major = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16324
- const regular = [...usable.map((p2) => major(p2, p2.amount)), ...opts.extraAmounts ?? []];
16380
+ const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16381
+ const regular = [...usable.map((p2) => major2(p2, p2.amount)), ...opts.extraAmounts ?? []];
16325
16382
  const isRegular = (n) => regular.some((r) => Math.abs(r - n) < 5e-3);
16326
16383
  const offRegular = (sentence) => findAmounts(sentence).filter((m) => {
16327
16384
  if (typeof m.amount !== "number" || !(m.amount > 0) || isRegular(m.amount)) return false;
@@ -16368,7 +16425,7 @@ function introEmailFinding(rawText, plans, opts) {
16368
16425
  };
16369
16426
  }
16370
16427
  const fits = (p2) => {
16371
- const intro = major(p2, p2.introAmount);
16428
+ const intro = major2(p2, p2.introAmount);
16372
16429
  const pct = (1 - p2.introAmount / p2.amount) * 100;
16373
16430
  return statedAmounts.every((m) => Math.abs(m.amount - intro) < 5e-3) && percents.every((x) => Math.abs(x.value - pct) <= 0.5);
16374
16431
  };
@@ -16397,6 +16454,7 @@ function introEmailFinding(rawText, plans, opts) {
16397
16454
  fix: `State how long the promo really lasts: ${introTermsPhrase(p2)}.`
16398
16455
  };
16399
16456
  }
16457
+ if (statesIntroEndDate(text)) return void 0;
16400
16458
  const p = fitting[0];
16401
16459
  return {
16402
16460
  status: "needs_review",
@@ -16406,29 +16464,32 @@ function introEmailFinding(rawText, plans, opts) {
16406
16464
  fix: `State how long the promo lasts and the price after it: ${introTermsPhrase(p)}.`
16407
16465
  };
16408
16466
  }
16467
+ function statesIntroEndDate(text) {
16468
+ return INTRO_END_DATE_RES.some((re) => re.test(text));
16469
+ }
16409
16470
  function introFirstChargeFinding(rawText, plans) {
16410
16471
  const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
16411
16472
  if (!usable.length) return void 0;
16412
16473
  const text = normalizeText(rawText);
16413
16474
  const stated = findAmounts(text).filter((m) => typeof m.amount === "number" && m.amount > 0);
16414
- const major = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16475
+ const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16415
16476
  const states = (n) => stated.some((m) => Math.abs(m.amount - n) < 5e-3);
16416
- const named = usable.filter((p2) => states(major(p2, p2.amount)));
16477
+ const named = usable.filter((p2) => states(major2(p2, p2.amount)));
16417
16478
  if (!named.length || !named.every(hasIntro)) return void 0;
16418
- if (named.some((p2) => states(major(p2, p2.introAmount)))) return void 0;
16479
+ if (named.some((p2) => states(major2(p2, p2.introAmount)))) return void 0;
16419
16480
  if (new RegExp(PERCENT_OFF_RE.source, "i").test(text)) return void 0;
16420
16481
  const p = named[0];
16421
16482
  const regular = money(p.amount, p.currency);
16422
16483
  const nameOf = p.label?.trim() || (p.name?.trim() ? `plan \u201C${p.name.trim()}\u201D` : `the ${regular} ${planEvery(p)} plan`);
16423
16484
  return {
16424
16485
  status: "fail",
16425
- quote: stated.find((m) => Math.abs(m.amount - major(p, p.amount)) < 5e-3)?.text ?? regular,
16486
+ quote: stated.find((m) => Math.abs(m.amount - major2(p, p.amount)) < 5e-3)?.text ?? regular,
16426
16487
  reason: "intro_first_charge",
16427
16488
  detail: `states ${regular} as the charge after the trial, but ${nameOf} has an introductory price: the first charge after the trial is ${money(p.introAmount, p.currency)} (${introTermsPhrase(p)}) \u2014 state the intro price for that charge, then the regular price`,
16428
16489
  fix: `State the first charge after the trial and the price after it: ${introTermsPhrase(p)}.`
16429
16490
  };
16430
16491
  }
16431
- var PERCENT_OFF_RE, DURATION_NUM, DURATION_UNIT, FIRST_DURATION_RE, FOR_DURATION_RE, VAGUE_DURATION_RE, TRIAL_DURATION_AROUND_RE, BILLED_LONGER_RE, INTRO_CUE_RE, NOT_A_PRICE_AROUND_RE, TRIAL_ENDING_RE;
16492
+ var PERCENT_OFF_RE, DURATION_NUM, DURATION_UNIT, FIRST_DURATION_RE, FOR_DURATION_RE, VAGUE_DURATION_RE, TRIAL_DURATION_AROUND_RE, BILLED_LONGER_RE, INTRO_CUE_RE, NOT_A_PRICE_AROUND_RE, DATE_RE_SRC, INTRO_WORD, REGULAR_PRICE, gap, INTRO_END_DATE_RES, TRIAL_ENDING_RE;
16432
16493
  var init_intro_terms = __esm({
16433
16494
  "../rules/src/checks/intro-terms.ts"() {
16434
16495
  "use strict";
@@ -16444,6 +16505,15 @@ var init_intro_terms = __esm({
16444
16505
  BILLED_LONGER_RE = /\bbilled\s+(?:annually|yearly|every\s+(?:\d{1,2}|twelve|two|three|six)\s+(?:months|years))\b|\bsingle\s+upfront\s+charge\b/i;
16445
16506
  INTRO_CUE_RE = /\bintro(?:ductory)?\b|\bpromo(?:tional)?\b|\bdiscount(?:ed)?\b|\bspecial\s+(?:price|offer|rate)\b|\bfirst\s+\w+|\bthen\b|\bafter\s+(?:that|which)\b/i;
16446
16507
  NOT_A_PRICE_AROUND_RE = /\b(?:tax(?:es)?|vat|fees?|shipping|delivery\s+fee|credit|refund(?:ed)?|balance|deposit|tip)\b/i;
16508
+ DATE_RE_SRC = "(?:(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?(?:,?\\s+\\d{4})?|\\d{1,2}\\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\.?(?:\\s+\\d{4})?|\\d{4}-\\d{2}-\\d{2}|\\d{1,2}/\\d{1,2}/\\d{2,4})";
16509
+ INTRO_WORD = "(?:discount|coupon|promo(?:tion|tional)?(?:\\s+(?:price|rate|code))?|intro(?:ductory)?(?:\\s+(?:price|rate|offer|period))?|special\\s+(?:price|offer|rate)|offer)";
16510
+ REGULAR_PRICE = "(?:regular|full|standard|normal|usual)\\s+(?:price|rate)";
16511
+ gap = (n) => `(?:[^.\\n]|\\.(?=\\d)){0,${n}}?`;
16512
+ INTRO_END_DATE_RES = [
16513
+ new RegExp(`\\b${INTRO_WORD}\\b${gap(80)}\\b(?:ends?|ending|expires?|runs?\\s+out|lasts?\\s+(?:until|through)|is\\s+valid\\s+(?:until|through)|until|through)\\b${gap(100)}${DATE_RE_SRC}`, "i"),
16514
+ new RegExp(`\\b${REGULAR_PRICE}\\b${gap(100)}\\b(?:from|on|starting(?:\\s+on)?|beginning(?:\\s+on)?|as\\s+of|after)\\s+${DATE_RE_SRC}`, "i"),
16515
+ new RegExp(`\\b(?:starting|from|beginning|on|as\\s+of)\\s+${DATE_RE_SRC}${gap(100)}\\b${REGULAR_PRICE}\\b`, "i")
16516
+ ];
16447
16517
  TRIAL_ENDING_RE = /\btrial\b[^.\n]{0,60}?\b(?:ends?|ending|expires?|is\s+over|runs?\s+out)\b|\b(?:after|when|once)\s+(?:your|the)\s+(?:free\s+)?trial\b/i;
16448
16518
  }
16449
16519
  });
@@ -16594,7 +16664,14 @@ function noticeContent(e, opts = {}) {
16594
16664
  if (when) quotes.trial_end = quote(when);
16595
16665
  const afterTrial = opts.firstChargeAfterTrial === "always" || opts.firstChargeAfterTrial === "when_trial_mentioned" && TRIAL_ENDING_RE.test(body);
16596
16666
  const intro = wrong.amount ? void 0 : introEmailFinding(body, plans, { termsComplete: plans.length > 0 && plans.every((p) => isPreflightPlan(p)), extraAmounts: opts.extraAmounts ?? [] }) ?? (afterTrial ? introFirstChargeFinding(body, plans) : void 0);
16597
- return { present: Object.keys(quotes).filter((k) => quotes[k]), quotes, ...Object.keys(wrong).length ? { wrong } : {}, ...intro ? { intro } : {} };
16667
+ const trialLength = opts.trialLength?.(a, e);
16668
+ return {
16669
+ present: Object.keys(quotes).filter((k) => quotes[k]),
16670
+ quotes,
16671
+ ...Object.keys(wrong).length ? { wrong } : {},
16672
+ ...intro ? { intro } : {},
16673
+ ...trialLength ? { trialLength } : {}
16674
+ };
16598
16675
  }
16599
16676
  function missingElements(content, required) {
16600
16677
  return required.filter((el) => !content.present.includes(el));
@@ -16606,6 +16683,12 @@ function describeMissing(missing) {
16606
16683
  return missing.map((m) => ELEMENT_LABELS[m]).join(", ");
16607
16684
  }
16608
16685
  function contentFixLine(checkId, data) {
16686
+ const tl = data?.contentTrialLength;
16687
+ const trialFix = NOTICE_NOUN[checkId] && typeof tl?.stated === "string" && typeof tl.plan === "string" ? `The ${NOTICE_NOUN[checkId]} says ${tl.stated}, but ${tl.plan}: state the trial the subscriber really gets.` : void 0;
16688
+ const line2 = contentFixLineBase(checkId, data);
16689
+ return line2 && trialFix ? `${line2} ${trialFix}` : line2 ?? trialFix;
16690
+ }
16691
+ function contentFixLineBase(checkId, data) {
16609
16692
  if (typeof data?.contentIntro?.fix === "string" && !(Array.isArray(data?.contentMissing) && data.contentMissing.length)) return data.contentIntro.fix;
16610
16693
  if ((checkId === "signup.disclosure.price" || checkId === "confirmation.email.terms") && Array.isArray(data?.units)) {
16611
16694
  const line2 = data.units.find((x) => x && x.status !== "pass" && typeof x.introFix === "string")?.introFix;
@@ -16622,7 +16705,10 @@ function contentFixFromText(checkId, text) {
16622
16705
  const noun = NOTICE_NOUN[checkId];
16623
16706
  if (!noun) return void 0;
16624
16707
  const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
16625
- return m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16708
+ const add = m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16709
+ const t = /\bsays (“[^”]+”), but (.+?): state the trial the subscriber really gets/.exec(text);
16710
+ const trial = t ? `The ${noun} says ${t[1]}, but ${t[2]}: state the trial the subscriber really gets.` : void 0;
16711
+ return add && trial ? `${add} ${trial}` : add ?? trial;
16626
16712
  }
16627
16713
  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;
16628
16714
  var init_notice_content = __esm({
@@ -16709,6 +16795,19 @@ function offerLabel(o) {
16709
16795
  const price3 = `${(o.amount / 10 ** digits).toFixed(digits)} ${o.currency.toUpperCase()} every ${o.intervalCount > 1 ? `${o.intervalCount} ${o.interval}s` : o.interval}`;
16710
16796
  return o.name ? `${o.name} (${price3})` : price3;
16711
16797
  }
16798
+ function trialLengthFits(days, trial) {
16799
+ return days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
16800
+ }
16801
+ function statedTrialMismatch(a, p, offers) {
16802
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
16803
+ if (!stated.length) return void 0;
16804
+ const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
16805
+ if (!own.length) return void 0;
16806
+ if (stated.some((t) => own.some((o) => trialLengthFits(t.days, o.trialDays)))) return void 0;
16807
+ const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
16808
+ 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`;
16809
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
16810
+ }
16712
16811
  var DAYS;
16713
16812
  var init_email_plan = __esm({
16714
16813
  "../rules/src/checks/email-plan.ts"() {
@@ -16781,11 +16880,29 @@ var init_forwarded = __esm({
16781
16880
 
16782
16881
  // ../rules/src/checks/confirmation.ts
16783
16882
  function isPostSignupEmail(e) {
16784
- if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
16883
+ if (e.kind !== "other" || !(e.scanId || testRunAckEmail(e)) || !e.relatedSignupAt) return false;
16785
16884
  const received = toDate(e.receivedAt);
16786
16885
  const signup = toDate(e.relatedSignupAt);
16787
16886
  return !!received && !!signup && received.getTime() >= signup.getTime() - HOUR_MS;
16788
16887
  }
16888
+ function testRunAckEmail(e) {
16889
+ const run2 = e.testRun;
16890
+ return !!run2 && typeof run2 === "object" && run2.step === "acknowledgment_email";
16891
+ }
16892
+ function acknowledgmentLike(e) {
16893
+ if (e.kind !== "other" || isCheckboxSentEmail(e) || e.noticeSample) return false;
16894
+ const opening = `${e.subject ?? ""}
16895
+ ${emailBody(e).slice(0, 600)}`;
16896
+ return ACK_LIKE_RE.test(opening) && !NEWSLETTER_RE.test(`${e.subject ?? ""}
16897
+ ${emailBody(e).slice(0, 300)}`);
16898
+ }
16899
+ function acknowledgmentCandidate(e, explicit) {
16900
+ if (!acknowledgmentLike(e)) return false;
16901
+ if (isPostSignupEmail(e)) return true;
16902
+ const key = emailTemplateKey(e);
16903
+ const at = toDate(e.receivedAt)?.getTime() ?? 0;
16904
+ return explicit.some((a) => emailTemplateKey(a) === key && (toDate(a.receivedAt)?.getTime() ?? Infinity) <= at);
16905
+ }
16789
16906
  function findAcknowledgments(emails) {
16790
16907
  const cached = ackCache.get(emails);
16791
16908
  if (cached) return cached;
@@ -16795,6 +16912,7 @@ function findAcknowledgments(emails) {
16795
16912
  const acknowledged = new Set(explicit.map((e) => recipientOf(e)).filter((r) => r !== ""));
16796
16913
  for (const e of emails) {
16797
16914
  if (e.kind === "acknowledgment") acks.push(e);
16915
+ else if (acknowledgmentCandidate(e, explicit)) acks.push(e);
16798
16916
  else if (acknowledged.has(recipientOf(e))) continue;
16799
16917
  else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
16800
16918
  else if (e.kind === "receipt" || isPostSignupEmail(e)) {
@@ -16830,6 +16948,18 @@ function noAckResult(checkId, input, set) {
16830
16948
  }
16831
16949
  if (set.receipts.length) {
16832
16950
  const r = set.receipts[set.receipts.length - 1];
16951
+ const runOnly = set.receipts.filter(testRunAckEmail);
16952
+ if (runOnly.length && !noticesCover) {
16953
+ const last = runOnly[runOnly.length - 1];
16954
+ return makeResult(
16955
+ checkId,
16956
+ "fail",
16957
+ `The test run's sign-up received ${runOnly.length === 1 ? "one email" : `${runOnly.length} emails`} (e.g. ${emailLabel(last)}), and none restates the subscription terms (price, how often it renews, that it renews until canceled, and how to cancel) \u2014 send an acknowledgment with the terms right after sign-up.`,
16958
+ runOnly.flatMap(emailRefs),
16959
+ 0.75,
16960
+ { runSignupWithoutTerms: runOnly.map((e) => e.id) }
16961
+ );
16962
+ }
16833
16963
  return makeResult(
16834
16964
  checkId,
16835
16965
  "needs_review",
@@ -16920,7 +17050,7 @@ function withoutRevisedDrafts(emails) {
16920
17050
  const draft2 = (e) => !e.subscriptionRef && !e.relatedSignupAt && !e.testRun && !isCheckboxSentEmail(e);
16921
17051
  const trial = (e) => /\b(?:free\s+)?trial\b/i.test(`${e.subject ?? ""} ${emailBody(e)}`);
16922
17052
  const at = (e) => toDate(e.receivedAt)?.getTime() ?? 0;
16923
- return emails.filter((e) => !(draft2(e) && sender(e) && emails.some((o) => o !== e && draft2(o) && sender(o) === sender(e) && trial(o) === trial(e) && at(o) > at(e))));
17053
+ return emails.filter((e) => !(draft2(e) && sender(e) && emails.some((o) => o !== e && sender(o) === sender(e) && trial(o) === trial(e) && at(o) > at(e))));
16924
17054
  }
16925
17055
  function latestPerTemplateAndPlan(emails, input) {
16926
17056
  const offers = contextOffers(input.ctx);
@@ -17005,18 +17135,7 @@ function cancelBeforeTrialMatch(text) {
17005
17135
  if (byDate && TRIAL_ENDS_DATE_RE.test(text)) return byDate[0];
17006
17136
  return null;
17007
17137
  }
17008
- function statedTrialMismatch(a, p, offers) {
17009
- const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
17010
- if (!stated.length) return void 0;
17011
- const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
17012
- if (!own.length) return void 0;
17013
- const fits = (days, trial) => days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
17014
- if (stated.some((t) => own.some((o) => fits(t.days, o.trialDays)))) return void 0;
17015
- const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
17016
- 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`;
17017
- return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
17018
- }
17019
- 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;
17138
+ var ACK_MAX_DELAY_HOURS, NO_ACK_EMAIL_MESSAGE, ackCache, ACK_LIKE_RE, NEWSLETTER_RE, 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;
17020
17139
  var init_confirmation = __esm({
17021
17140
  "../rules/src/checks/confirmation.ts"() {
17022
17141
  "use strict";
@@ -17032,6 +17151,8 @@ var init_confirmation = __esm({
17032
17151
  ACK_MAX_DELAY_HOURS = 24;
17033
17152
  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.";
17034
17153
  ackCache = /* @__PURE__ */ new WeakMap();
17154
+ ACK_LIKE_RE = /\bwelcome\b|\bthanks?\s+(?:you\s+)?for\s+(?:subscribing|joining|signing\s+up|your\s+subscription|starting\s+your|choosing\s+our)\b|\byou(?:'re|’re|\s+are)\s+(?:now\s+)?(?:subscribed|a\s+member|officially\s+in)\b|\b(?:subscription|membership)\s+(?:is\s+)?(?:confirmed|active|activated|started)\b|\b(?:free\s+)?trial\s+(?:has\s+(?:started|begun)|starts\s+(?:today|now)|is\s+now\s+active)\b/i;
17155
+ NEWSLETTER_RE = /\b(?:newsletter|mailing\s+list|digest|blog\s+(?:posts?|updates?))\b/i;
17035
17156
  SIGNUP_LINK_TOLERANCE_MS = 30 * 6e4;
17036
17157
  sent = {
17037
17158
  id: "confirmation.email.sent",
@@ -17466,6 +17587,11 @@ function wholeDaysBefore(days, tie = "up") {
17466
17587
  function sentInWindow(days, w) {
17467
17588
  return wholeDaysBefore(days, "up") <= w.maxDays && days >= w.minDays - MIN_DAYS_GRACE_DAYS;
17468
17589
  }
17590
+ function countsAsWholeDays(days) {
17591
+ if (Number.isInteger(days)) return "";
17592
+ const whole = wholeDaysBefore(days, "up");
17593
+ return `, which counts as ${whole} day${whole === 1 ? "" : "s"} by the whole-day rule`;
17594
+ }
17469
17595
  function windowCloseMs(dueMs, w) {
17470
17596
  return dueMs - (w.minDays - MIN_DAYS_GRACE_DAYS) * DAY_MS;
17471
17597
  }
@@ -17829,35 +17955,64 @@ function contentGap(required, emails, opts = {}) {
17829
17955
  const content = noticeContent(email, opts);
17830
17956
  const wrongKeys = Object.keys(content.wrong ?? {});
17831
17957
  const missing = [.../* @__PURE__ */ new Set([...missingElements(content, required), ...wrongKeys])];
17832
- if (missing.length || content.intro) return { email, missing, ...content.wrong ? { wrong: content.wrong } : {}, ...content.intro ? { intro: content.intro } : {} };
17958
+ if (missing.length || content.intro || content.trialLength)
17959
+ return { email, missing, ...content.wrong ? { wrong: content.wrong } : {}, ...content.intro ? { intro: content.intro } : {}, ...content.trialLength ? { trialLength: content.trialLength } : {} };
17833
17960
  }
17834
17961
  return null;
17835
17962
  }
17836
- function contentFail(spec, required, gap, timing, refs2, data) {
17837
- const intro = gap.intro;
17838
- const sentWhat = isPreflightEmail(gap.email) ? "is a draft that" : `${timing}, but it`;
17839
- const introData = intro ? { contentIntro: { status: intro.status, reason: intro.reason, quote: intro.quote, fix: intro.fix } } : {};
17840
- if (!gap.missing.length && intro) {
17963
+ function contentFail(spec, required, gap2, timing, refs2, data) {
17964
+ const intro = gap2.intro;
17965
+ const sentWhat = isPreflightEmail(gap2.email) ? "is a draft that" : `${timing}, but it`;
17966
+ const introData = {
17967
+ ...intro ? { contentIntro: { status: intro.status, reason: intro.reason, quote: intro.quote, fix: intro.fix } } : {},
17968
+ ...gap2.trialLength ? { contentTrialLength: gap2.trialLength } : {}
17969
+ };
17970
+ const trialLine = gap2.trialLength ? `says ${gap2.trialLength.stated}, but ${gap2.trialLength.plan}: state the trial the subscriber really gets` : "";
17971
+ if (!gap2.missing.length && gap2.trialLength) {
17972
+ return makeResult(
17973
+ spec.checkId,
17974
+ "fail",
17975
+ `The ${spec.noun} (${emailLabel(gap2.email)}) ${sentWhat} ${trialLine}${intro ? `; it also ${intro.detail}` : ""}`,
17976
+ [...emailRefs(gap2.email), ...refs2],
17977
+ 0.8,
17978
+ { ...data, contentMissing: [], contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...introData }
17979
+ );
17980
+ }
17981
+ if (!gap2.missing.length && intro) {
17841
17982
  return makeResult(
17842
17983
  spec.checkId,
17843
17984
  intro.status,
17844
- `The ${spec.noun} (${emailLabel(gap.email)}) ${sentWhat} ${intro.detail}`,
17845
- [...emailRefs(gap.email), ...refs2],
17985
+ `The ${spec.noun} (${emailLabel(gap2.email)}) ${sentWhat} ${intro.detail}`,
17986
+ [...emailRefs(gap2.email), ...refs2],
17846
17987
  intro.status === "fail" ? 0.8 : 0.5,
17847
- { ...data, contentMissing: [], contentEmailId: gap.email.id, ...isCheckboxSentEmail(gap.email) ? { contentByCheckbox: true } : {}, ...introData }
17988
+ { ...data, contentMissing: [], contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...introData }
17848
17989
  );
17849
17990
  }
17850
17991
  return makeResult(
17851
17992
  spec.checkId,
17852
17993
  "fail",
17853
- `The ${spec.noun} (${emailLabel(gap.email)}) ${isPreflightEmail(gap.email) ? "is a draft that" : `${timing}, but it`} does not state ${describeMissing(gap.missing)}${// Round 20 (pilot 19 item 16, change-guard: "contradicts the plan or is not an online way to cancel"): each wrong
17994
+ `The ${spec.noun} (${emailLabel(gap2.email)}) ${isPreflightEmail(gap2.email) ? "is a draft that" : `${timing}, but it`} does not state ${describeMissing(gap2.missing)}${// Round 20 (pilot 19 item 16, change-guard: "contradicts the plan or is not an online way to cancel"): each wrong
17854
17995
  // element says its own reason.
17855
- gap.wrong ? ` correctly (it says ${Object.entries(gap.wrong).map(([k, v]) => `\u201C${v}\u201D for ${ELEMENT_LABELS[k]}, ${k === "cancellation" ? "which is not an online way to cancel" : "which contradicts the plan"}`).join("; ")})` : ""} \u2014 the notice must tell the subscriber ${describeMissing(required)}${intro ? `; it also ${intro.detail}` : ""}`,
17856
- [...emailRefs(gap.email), ...refs2],
17996
+ gap2.wrong ? ` correctly (it says ${Object.entries(gap2.wrong).map(([k, v]) => `\u201C${v}\u201D for ${ELEMENT_LABELS[k]}, ${k === "cancellation" ? "which is not an online way to cancel" : "which contradicts the plan"}`).join("; ")})` : ""} \u2014 the notice must tell the subscriber ${describeMissing(required)}${intro ? `; it also ${intro.detail}` : ""}${trialLine ? `; it also ${trialLine}` : ""}`,
17997
+ [...emailRefs(gap2.email), ...refs2],
17857
17998
  0.8,
17858
- { ...data, contentMissing: gap.missing, contentEmailId: gap.email.id, ...isCheckboxSentEmail(gap.email) ? { contentByCheckbox: true } : {}, ...gap.wrong ? { contentWrong: gap.wrong } : {}, ...introData }
17999
+ { ...data, contentMissing: gap2.missing, contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...gap2.wrong ? { contentWrong: gap2.wrong } : {}, ...introData }
17859
18000
  );
17860
18001
  }
18002
+ function reminderTrialLength(input) {
18003
+ const offers = contextOffers(input.ctx);
18004
+ return (a, e) => {
18005
+ if (isCheckboxSentEmail(e)) return void 0;
18006
+ const ref = typeof e.subscriptionRef === "string" ? e.subscriptionRef.trim() : "";
18007
+ const own = ref ? (input.ctx.events ?? []).filter((ev) => ev.subscriptionRef === ref).map((ev) => ev.trialDays ?? 0).find((d) => d > 0) : void 0;
18008
+ if (own) {
18009
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
18010
+ if (!stated.length || stated.some((t) => trialLengthFits(t.days, own))) return void 0;
18011
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: isPreflightEmail(e) ? `the plan gives a ${own}-day free trial` : `this subscriber's free trial is ${own} days` };
18012
+ }
18013
+ return statedTrialMismatch(a, emailPlan(e, input.ctx, offers), offers);
18014
+ };
18015
+ }
17861
18016
  async function runReminder(spec, input) {
17862
18017
  const id = spec.checkId;
17863
18018
  const config = input.ctx.config;
@@ -17908,7 +18063,8 @@ async function runReminder(spec, input) {
17908
18063
  const contentOpts = {
17909
18064
  productNames: productNames2,
17910
18065
  ...planOpts,
17911
- ...spec.kind === "trial_ending" ? { firstChargeAfterTrial: "always" } : spec.kind === "annual_renewal" ? { firstChargeAfterTrial: "when_trial_mentioned" } : {}
18066
+ ...spec.kind === "trial_ending" ? { firstChargeAfterTrial: "always" } : spec.kind === "annual_renewal" ? { firstChargeAfterTrial: "when_trial_mentioned" } : {},
18067
+ ...spec.kind === "trial_ending" || spec.kind === "annual_renewal" ? { trialLength: reminderTrialLength(input) } : {}
17912
18068
  };
17913
18069
  const runStep = spec.kind === "trial_ending" ? "trial_reminder" : spec.kind === "annual_renewal" ? "renewal" : null;
17914
18070
  const runStamped = runStep ? emails.filter((e) => testRunDaysBefore(e, runStep)) : [];
@@ -17955,8 +18111,8 @@ async function runReminder(spec, input) {
17955
18111
  const w = conflicting ? "within each state\u2019s window" : why(window);
17956
18112
  if (counted.length) {
17957
18113
  if (!outside.length && !missing.length && !partial.length) {
17958
- const gap = contentGap(required, matched.flatMap((j) => j.email ? [j.email] : []), contentOpts);
17959
- if (gap) return contentFail(spec, required, gap, "was sent on time", refs2(matched), data);
18114
+ const gap2 = contentGap(required, matched.flatMap((j) => j.email ? [j.email] : []), contentOpts);
18115
+ if (gap2) return contentFail(spec, required, gap2, "was sent on time", refs2(matched), data);
17960
18116
  return makeResult(
17961
18117
  id,
17962
18118
  "pass",
@@ -18047,12 +18203,12 @@ async function runReminder(spec, input) {
18047
18203
  return contentFail(spec, required, newestGap, t ? `was sent about ${t.days} days ahead (required: ${conflicting ? w : why(t.w)})` : "was captured (its timing could not be verified)", configRefs, data);
18048
18204
  }
18049
18205
  if (inWindow.length) {
18050
- const gap = contentGap(required, inWindow.map((t) => t.e), contentOpts);
18051
- if (gap) return contentFail(spec, required, gap, `was sent about ${inWindow[0].days} days ahead (required: ${conflicting ? w : why(inWindow[0].w)})`, configRefs, data);
18206
+ const gap2 = contentGap(required, inWindow.map((t) => t.e), contentOpts);
18207
+ if (gap2) return contentFail(spec, required, gap2, `was sent about ${inWindow[0].days} days ahead (required: ${conflicting ? w : why(inWindow[0].w)})`, configRefs, data);
18052
18208
  return makeResult(
18053
18209
  id,
18054
18210
  "pass",
18055
- `${spec.noun[0].toUpperCase()}${spec.noun.slice(1)} captured (${emailLabel(inWindow[0].e)}), sent about ${inWindow[0].days} days before the date it announces (required: ${conflicting ? w : why(inWindow[0].w)})`,
18211
+ `${spec.noun[0].toUpperCase()}${spec.noun.slice(1)} captured (${emailLabel(inWindow[0].e)}), sent about ${inWindow[0].days} days before the date it announces${countsAsWholeDays(inWindow[0].days)} (required: ${conflicting ? w : why(inWindow[0].w)})`,
18056
18212
  [...emailRefs(inWindow[0].e), ...configRefs],
18057
18213
  0.7,
18058
18214
  data
@@ -18060,8 +18216,8 @@ async function runReminder(spec, input) {
18060
18216
  }
18061
18217
  if (nearWindow.length) {
18062
18218
  const n = nearWindow[0];
18063
- const gap = contentGap(required, nearWindow.map((t) => t.e), contentOpts);
18064
- if (gap) return contentFail(spec, required, gap, `was sent about ${n.days} days ahead (required: ${conflicting ? w : why(n.w)})`, configRefs, data);
18219
+ const gap2 = contentGap(required, nearWindow.map((t) => t.e), contentOpts);
18220
+ if (gap2) return contentFail(spec, required, gap2, `was sent about ${n.days} days ahead (required: ${conflicting ? w : why(n.w)})`, configRefs, data);
18065
18221
  data.timingNearWindow = { days: n.days, required: conflicting ? w : why(n.w), roundingRule: DAYS_ROUNDING_RULE, ...n.range ? { range: n.range } : {} };
18066
18222
  data.timingOutsideWindow = { days: n.days, required: conflicting ? w : why(n.w) };
18067
18223
  return makeResult(
@@ -18209,6 +18365,7 @@ var init_reminder = __esm({
18209
18365
  "../rules/src/checks/reminder.ts"() {
18210
18366
  "use strict";
18211
18367
  init_email_plan();
18368
+ init_offer();
18212
18369
  init_text();
18213
18370
  init_business_days();
18214
18371
  init_context();
@@ -18915,8 +19072,21 @@ function otherPlanCard(view, input) {
18915
19072
  const known = referencePlans(input.ctx).map((p) => p.amount / 10 ** currencyDigits(p.currency));
18916
19073
  return !amounts.some((n) => known.some((k) => Math.abs(k - n) < 5e-3));
18917
19074
  }
19075
+ function boughtPlanViews(views, input) {
19076
+ if (input.ctx.facts?.boughtPlanOnly !== true) return [...views];
19077
+ const known = referencePlans(input.ctx).map((p) => p.amount / 10 ** currencyDigits(p.currency));
19078
+ if (!known.length) return [...views];
19079
+ const amountsOf = (v) => findAmounts(v.nearCtaText || v.termsText || "").map((m) => m.amount).filter((n) => typeof n === "number" && n > 0);
19080
+ const other = (v) => {
19081
+ if (v.offer === void 0) return false;
19082
+ const amounts = amountsOf(v);
19083
+ return amounts.length > 0 && !amounts.some((n) => known.some((k) => Math.abs(k - n) < 5e-3));
19084
+ };
19085
+ const kept = views.filter((v) => !other(v));
19086
+ return kept.length ? kept : [...views];
19087
+ }
18918
19088
  async function overViews(checkId, input, headlines, evaluateView) {
18919
- const views = input.data.signupViews;
19089
+ const views = boughtPlanViews(input.data.signupViews, input);
18920
19090
  if (views.length === 0) return missingData(checkId, NO_CHECKOUT_CAPTURE_MESSAGE);
18921
19091
  const outcomes = [];
18922
19092
  for (const view of views) outcomes.push({ view, unit: await evaluateView(view) });
@@ -19222,7 +19392,7 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
19222
19392
  function setOffAdvice(sizePx, contrast, weight, ref) {
19223
19393
  const refWeight = ref.fontWeight ?? 400;
19224
19394
  const larger = Math.ceil(ref.fontSizePx * SET_OFF_MIN_SIZE_RATIO);
19225
- const ways = [`${larger}px or larger (the surrounding text is about ${ref.fontSizePx}px)`, ...refWeight < 600 ? ["semibold or bold (weight 600+; the surrounding text is weight " + refWeight + ")"] : [], "a contrasting colour", "a border or shaded box (confirm by eye: borders and symbols cannot be measured from a capture)"];
19395
+ const ways = [`${larger}px or larger (the surrounding text is about ${ref.fontSizePx}px)`, ...refWeight < 600 ? ["semibold or bold (weight 600+; the surrounding text is weight " + refWeight + ")"] : [], "a contrasting colour", "a border or a shaded box around them (Checkbox measures both; symbols and italics are not measured)"];
19226
19396
  return `the renewal terms are legible (${sizePx}px, weight ${weight}${contrast !== null ? `, ${contrast}:1 contrast` : ""}) but look like the surrounding text (about ${ref.fontSizePx}px, weight ${refWeight}${refWeight >= 600 ? ": already bold, so a bolder weight cannot set them apart" : ""}, similar colour) \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border. Any one of these would: ${ways.join("; ")}`;
19227
19397
  }
19228
19398
  function trialObservedOutsideCheckout(input) {
@@ -19357,10 +19527,10 @@ function gatedByOwnBox(renewalBoxes, path) {
19357
19527
  return renewalBoxes.some((b) => b.required === true && !b.checkedByDefault && consentGate(b, path) === "gated");
19358
19528
  }
19359
19529
  function boxNearCta(box, cta) {
19360
- const gap = box.y >= cta.y + cta.height ? box.y - (cta.y + cta.height) : cta.y >= box.y + box.height ? cta.y - (box.y + box.height) : 0;
19361
- return gap <= 200;
19530
+ const gap2 = box.y >= cta.y + cta.height ? box.y - (cta.y + cta.height) : cta.y >= box.y + box.height ? cta.y - (box.y + box.height) : 0;
19531
+ return gap2 <= 200;
19362
19532
  }
19363
- var NO_CHECKOUT_CAPTURE_MESSAGE, NO_PURCHASE_BUTTON, OTHER_PLAN_CARD_NOTE, heuristicCache, PROCESSOR_LINE_NOTE, RENEWAL_CONTEXT_RE, autorenewLanguage, autorenewOverViews, FOOTNOTE_RE, NON_CHARGE_AMOUNT_RE, INTRO_PRICE_RE, RENEWAL_PRICE_RE, PRODUCT_TITLE_RE, CHARGED_NOW_RE, LATER_CHARGE_RE, price, frequency, CANCEL_LINK_RE, cancellationPolicy, TRIAL_DAYS_SLACK, trialTerms2, termLength, PLACEMENT_RANK, proximity, conspicuous, RENEWAL_CONSENT_RE, CONSENT_CHECKBOX_RE, TOS_RE, SUBSCRIPTION_CTA_RE, MARKETING_RE, RENEWAL_TERMS_RE, NOTIFICATION_OPT_IN_RE, AGREEMENT_RE, SEPARATE_CONSENT_JURISDICTIONS, EXPRESS_CONSENT_COUNSEL_QUESTION, CA_SEPARATE_CONSENT, consentAffirmative, consentOverViews, notPrechecked, SIGNUP_CHECKS;
19533
+ var NO_CHECKOUT_CAPTURE_MESSAGE, NO_PURCHASE_BUTTON, OTHER_PLAN_CARD_NOTE, heuristicCache, PROCESSOR_LINE_NOTE, RENEWAL_CONTEXT_RE, autorenewLanguage, autorenewOverViews, FOOTNOTE_RE, NON_CHARGE_AMOUNT_RE, INTRO_PRICE_RE, RENEWAL_PRICE_RE, PRODUCT_TITLE_RE, CHARGED_NOW_RE, LATER_CHARGE_RE, HOSTED_SET_OFF_NOTE, price, frequency, CANCEL_LINK_RE, cancellationPolicy, TRIAL_DAYS_SLACK, trialTerms2, termLength, PLACEMENT_RANK, proximity, conspicuous, RENEWAL_CONSENT_RE, CONSENT_CHECKBOX_RE, TOS_RE, SUBSCRIPTION_CTA_RE, MARKETING_RE, RENEWAL_TERMS_RE, NOTIFICATION_OPT_IN_RE, AGREEMENT_RE, SEPARATE_CONSENT_JURISDICTIONS, EXPRESS_CONSENT_COUNSEL_QUESTION, CA_SEPARATE_CONSENT, consentAffirmative, consentOverViews, notPrechecked, SIGNUP_CHECKS;
19364
19534
  var init_signup = __esm({
19365
19535
  "../rules/src/checks/signup.ts"() {
19366
19536
  "use strict";
@@ -19461,6 +19631,7 @@ var init_signup = __esm({
19461
19631
  PRODUCT_TITLE_RE = /^(?:[A-Z0-9][\w'&.+]*\s+){0,6}[A-Z0-9][\w'&.+]*\s*[-–—|:·]\s*[$€£]\s?\d[\d,]*(?:\.\d{1,2})?\s*$/;
19462
19632
  CHARGED_NOW_RE = /\b(?:starting|beginning|effective|from)\s+today\b|\b(?:charged|billed|charge|bill)\b(?:[^.\n]|\.(?=\d)){0,50}?\b(?:today|immediately|right\s+away)\b|\btoday\b(?:[^.\n]|\.(?=\d)){0,30}?\b(?:charged|billed)\b/i;
19463
19633
  LATER_CHARGE_RE = /\b(?:then|thereafter|afterwards?|after\s+(?:that|which|the|your|an?)|from\s+(?:the\s+)?(?:second|2nd|next)|renews?\s+(?:automatically\s+)?at)\b/i;
19634
+ HOSTED_SET_OFF_NOTE = "This was measured on the Stripe-hosted page, where Stripe sets the size and colour of the text and the merchant cannot restyle it";
19464
19635
  price = {
19465
19636
  id: "signup.disclosure.price",
19466
19637
  usesText: true,
@@ -19863,8 +20034,18 @@ var init_signup = __esm({
19863
20034
  const offers = contextOffers(input.ctx);
19864
20035
  const loose = !view.plan || view.plan.how === "only_plan";
19865
20036
  const cardOffer = loose && (view.offer || view.checkoutConfig) && offers.length > 1 ? offerForView(view, offers) : void 0;
19866
- const planTrial = cardOffer ? cardOffer.trialDays : view.plan ? offerTrialDays(offers, view.plan.plan, input.ctx.facts) : void 0;
20037
+ const paramsTrial = view.checkoutConfig?.trialDays;
20038
+ const planTrial = typeof paramsTrial === "number" ? paramsTrial : cardOffer ? cardOffer.trialDays : view.plan ? offerTrialDays(offers, view.plan.plan, input.ctx.facts) : void 0;
19867
20039
  const planName = cardOffer ? offerName(cardOffer) : view.plan?.label;
20040
+ if (mentioned && paramsTrial === 0) {
20041
+ const said = [...a.trialLengths, ...p.trialLengths][0] ?? [...a.trialMentions, ...p.trialMentions][0];
20042
+ return unit(
20043
+ "fail",
20044
+ `the text promises a free trial${said ? ` (\u201C${quote(said)}\u201D)` : ""}, but the ${view.checkoutConfig?.kind === "payment_link" ? "Payment Link" : "Checkout params"} run none (no subscription_data.trial_period_days), so Stripe charges the full price today \u2014 add the trial to subscription_data, or remove the promise from the text`,
20045
+ 0.85,
20046
+ { ...said ? { quote: quote(said) } : {}, meta: { planTrialDays: 0, paramsTrialDays: 0, trialMismatch: true } }
20047
+ );
20048
+ }
19868
20049
  if (!mentioned) {
19869
20050
  if (planTrial === 0 && offers.some((o) => o.trialDays > 0)) {
19870
20051
  const withTrial = offers.filter((o) => o.trialDays > 0).map(offerName);
@@ -19889,7 +20070,7 @@ var init_signup = __esm({
19889
20070
  const meta = {};
19890
20071
  if (length && "days" in length && length.days !== null) meta.disclosedTrialDays = length.days;
19891
20072
  if (input.facts.trialDays) meta.configuredTrialDays = input.facts.trialDays;
19892
- const ownPlanOnly = (!!view.plan || !!cardOffer) && (!!view.checkoutConfig || !!view.offer) && planTrial !== void 0;
20073
+ const ownPlanOnly = (typeof paramsTrial === "number" || !!view.plan || !!cardOffer) && (!!view.checkoutConfig || !!view.offer) && planTrial !== void 0;
19893
20074
  const trialPlans = [
19894
20075
  ...(input.ctx.config?.plans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: configPlanSource(input.ctx) })),
19895
20076
  ...(input.ctx.referencePlans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: pl.source }))
@@ -20195,16 +20376,20 @@ ${view.pageText}`;
20195
20376
  if (setOff && !setOff.differentiators.length) {
20196
20377
  return unit(
20197
20378
  "needs_review",
20198
- setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : setOffAdvice(best.a.fontSizePx, best.a.contrastRatio, best.el.style?.fontWeight ?? 400, setOff.reference),
20379
+ setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : `${setOffAdvice(best.a.fontSizePx, best.a.contrastRatio, best.el.style?.fontWeight ?? 400, setOff.reference)}${stripeHosted(view) ? `. ${HOSTED_SET_OFF_NOTE}` : ""}`,
20199
20380
  0.55,
20200
- { meta: { ...meta, ...setOffMeta }, quote: q2 }
20381
+ // Round 34 (pilot 33 item 26, nontech-founder): measured on a Stripe-hosted page, where the merchant cannot
20382
+ // restyle the text; the fix (setOffFix) then names what they can change: the product name / description.
20383
+ { meta: { ...meta, ...setOffMeta, ...stripeHosted(view) ? { hostedCheckout: "stripe" } : {} }, quote: q2 }
20201
20384
  );
20202
20385
  }
20386
+ const smallerNearButton = view.hosted ? elements.filter((o) => o !== best.el && o !== view.primaryCta && hasRenewalLanguage(analyzeTerms(o.text ?? ""))).filter((o) => typeof o.style?.fontSizePx === "number" && typeof best.a.fontSizePx === "number" && o.style.fontSizePx < best.a.fontSizePx).find((o) => !cta || nearButton(view, o)) : void 0;
20387
+ const hostedNote = smallerNearButton ? `; the renewal text next to the pay button (\u201C${quote(labelOf(smallerNearButton))}\u201D) is still smaller (${smallerNearButton.style.fontSizePx}px) \u2014 the checkout provider sets its style, so it is the ${best.a.fontSizePx}px line that stands out` : "";
20203
20388
  return unit(
20204
20389
  "pass",
20205
- `terms are ${best.a.fontSizePx}px with ${best.a.contrastRatio}:1 contrast and set off from the surrounding text (${setOff ? setOff.differentiators.join(", ").replace(/_/g, " ") : "on the purchase button"})${terms2.tier === 2 ? " (subscription terms block; no explicit renewal sentence found)" : ""}`,
20390
+ `terms are ${best.a.fontSizePx}px with ${best.a.contrastRatio}:1 contrast and set off from the surrounding text (${setOff ? setOff.differentiators.join(", ").replace(/_/g, " ") : "on the purchase button"})${terms2.tier === 2 ? " (subscription terms block; no explicit renewal sentence found)" : ""}${hostedNote}`,
20206
20391
  terms2.tier === 2 ? 0.65 : 0.85,
20207
- { meta: { ...meta, ...setOffMeta }, quote: q2 }
20392
+ { meta: { ...meta, ...setOffMeta, ...smallerNearButton ? { hostedSmallerNearButton: { fontSizePx: smallerNearButton.style.fontSizePx, quote: quote(labelOf(smallerNearButton)) } } : {} }, quote: q2 }
20208
20393
  );
20209
20394
  }
20210
20395
  if (best.a.status === "needs_review") return unit("needs_review", best.a.warnings.join("; "), 0.5, { meta, quote: q2 });
@@ -22199,6 +22384,27 @@ var init_brand_scope = __esm({
22199
22384
  }
22200
22385
  });
22201
22386
 
22387
+ // ../rules/src/set-off-fix.ts
22388
+ function setOffFix(text) {
22389
+ if (/measured on the Stripe-hosted page/.test(text)) return HOSTED_SET_OFF_FIX;
22390
+ const own = /legible \((\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
22391
+ const ref = /look like the surrounding text \(about (\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
22392
+ if (!ref) return 'Set the renewal line apart from the text around it in a way Checkbox can measure: larger type (about 15% larger), semibold (weight 600+) where the text around it is not bold, a contrasting colour, or a border (e.g. style="border:1px solid currentColor;padding:8px 12px"). Any one of these is enough.';
22393
+ const refPx = Number(ref[1]);
22394
+ const refWeight = Number(ref[2]);
22395
+ const larger = Math.ceil(refPx * 1.15);
22396
+ const measured = own ? `It is ${own[1]}px, weight ${own[2]}; the text around it is about ${refPx}px, weight ${refWeight}${refWeight >= 600 ? " (already bold, so a bolder weight cannot set it apart)" : ""}.` : `The text around it is about ${refPx}px, weight ${refWeight}.`;
22397
+ const ways = [`${larger}px or larger`, ...refWeight < 600 ? ["semibold (font-weight 600 or more)"] : [], "a contrasting colour (not grey)", 'a border around it (e.g. style="border:1px solid currentColor;padding:8px 12px")'];
22398
+ return `Set the renewal line apart in a way Checkbox can measure. ${measured} Any one of these is enough: ${ways.join("; ")}.`;
22399
+ }
22400
+ var HOSTED_SET_OFF_FIX;
22401
+ var init_set_off_fix = __esm({
22402
+ "../rules/src/set-off-fix.ts"() {
22403
+ "use strict";
22404
+ HOSTED_SET_OFF_FIX = 'This line is on the Stripe-hosted page, where Stripe sets the size and colour of your custom text: it cannot be made bigger or bolder there. What you can change: put the renewal terms in the product name or description in Stripe (Product catalog \u2192 your product \u2192 Edit), for example "Monthly box: renews monthly at $29 until you cancel". Stripe shows the product name and description in larger type in the order summary. Then check again. Also show the same line, set off, on your own page before the redirect.';
22405
+ }
22406
+ });
22407
+
22202
22408
  // ../rules/src/status.ts
22203
22409
  function tierOf(checkId) {
22204
22410
  return CHECK_TIERS[checkId] ?? "required";
@@ -22295,10 +22501,18 @@ function cancelTestProblem(raw) {
22295
22501
  }
22296
22502
  function policyUnreadableFix(text) {
22297
22503
  const n = /\((\d+) characters/.exec(text)?.[1];
22298
- if (n !== void 0 || /almost no text/.test(text)) {
22299
- return `Your Terms page has only ${n !== void 0 ? `${n} characters` : "a few words"} of text, so there is nothing for Checkbox to check yet: add sections on how the subscription renews, how to cancel and your refund policy (or, if the page is built with JavaScript, serve that text in the HTML). Checkbox reads it again on the next check.`;
22504
+ const doc = /\b(terms of service|cancellation policy|refund policy|privacy policy)\s*\((https?:\/\/[^\s),]+)/i.exec(text);
22505
+ const kind = doc?.[1]?.toLowerCase();
22506
+ const url = doc?.[2];
22507
+ const page = !kind || kind === "terms of service" ? "Terms page" : `${kind} page`;
22508
+ const named = url ? `Your ${page} (${url})` : `Your ${page}`;
22509
+ if (url && /\/p\/login\//i.test(url)) {
22510
+ return `${url} is a Stripe customer portal sign-in page, not a policy page, so there is no ${kind === "terms of service" || !kind ? "Terms" : kind} text to check there. Keep it as your cancel link (account page, emails); remove it as the ${kind === "terms of service" || !kind ? "Terms" : kind} URL (updateSetupSession ${kind === "refund policy" ? "refund_url" : kind === "cancellation policy" ? "cancellation_url" : "terms_url"}: null) or replace it with the page that states the policy. Checkbox reads it again on the next check.`;
22300
22511
  }
22301
- return "Checkbox could not read your Terms page: register a public URL that serves the policy text without a login (Site settings, or updateSite terms_url). Checkbox reads it again on the next check.";
22512
+ if (n !== void 0 || /almost no text/i.test(text)) {
22513
+ return `${named} has only ${n !== void 0 ? `${n} characters` : "a few words"} of text, so there is nothing for Checkbox to check yet: add sections on how the subscription renews, how to cancel and your refund policy (or, if the page is built with JavaScript, serve that text in the HTML). Checkbox reads it again on the next check.`;
22514
+ }
22515
+ return `Checkbox could not read ${url ? `your ${page} (${url})` : `your ${page}`}: register a public URL that serves the policy text without a login (Site settings, or updateSite terms_url). Checkbox reads it again on the next check.`;
22302
22516
  }
22303
22517
  function finish(base, reason, checkId, tier = tierOf(checkId)) {
22304
22518
  if (reason === "ny_owner_decision") return { ...base, label: NY_EMAIL_ONLY_LABEL, reason, tier };
@@ -22374,6 +22588,9 @@ function classifyRaw(input) {
22374
22588
  if (input.origin !== "preflight" && input.data?.verifiedFrom === "checkbox_hosted") {
22375
22589
  return finish({ status: "compliant", note: VERIFIED_FROM_HOSTED_NOTE }, "verified_from_checkbox_settings", checkId);
22376
22590
  }
22591
+ if (checkId === "signup.disclosure.conspicuous" && Array.isArray(input.data?.units) && input.data.units.some((u) => u && u.status === "pass" && !!u.hostedSmallerNearButton)) {
22592
+ return finish({ status: "compliant", note: HOSTED_SMALLER_NEAR_BUTTON_NOTE }, "hosted_checkout_styling", checkId);
22593
+ }
22377
22594
  if (input.origin !== "preflight" && LIVE_CHECKS.has(checkId)) {
22378
22595
  if (/on-screen only/.test(text)) return finish({ status: "pending_live_check", pending: pendingFor(checkId, "stripe was not checked", input.data), note: "Confirmed on screen only: run a proof to confirm the cancellation reaches Stripe." }, "pass_on_screen_only", checkId);
22379
22596
  return finish({ status: "verified_live" }, "verdict_pass_live", checkId);
@@ -22529,10 +22746,11 @@ function classifyFindings(findings) {
22529
22746
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22530
22747
  }));
22531
22748
  }
22532
- 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;
22749
+ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_SMALLER_NEAR_BUTTON_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, CANCEL_STEPS_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;
22533
22750
  var init_status = __esm({
22534
22751
  "../rules/src/status.ts"() {
22535
22752
  "use strict";
22753
+ init_set_off_fix();
22536
22754
  init_notice_content();
22537
22755
  init_notices_on();
22538
22756
  init_plan_terms();
@@ -22594,7 +22812,8 @@ var init_status = __esm({
22594
22812
  has = (re) => (c) => re.test(c.text);
22595
22813
  isStripeInput = (c) => (c.kind ?? "").startsWith("stripe_") || onlyStripeHostedOpen(c.data) || /checkout\.stripe\.com|buy\.stripe\.com|stripe|custom_text/.test(c.text);
22596
22814
  HOSTED_BEFORE_LINE_NOTE = 'Checked once the renewal line exists (see "renewal language"). Stripe shows custom text small and grey below the button, and you cannot change that on its page.';
22597
- HOSTED_CHECKOUT_NOTE = "Stripe shows custom text small and grey below the button, and you cannot change that. Also show the same line, set off, on your own page before the redirect.";
22815
+ HOSTED_SMALLER_NEAR_BUTTON_NOTE = "This passes on the larger line that states the renewal terms (for example the product name). The renewal text next to the pay button is still smaller, and the checkout provider sets its style. Also show the same line, set off, on your own page before the redirect.";
22816
+ HOSTED_CHECKOUT_NOTE = "Stripe shows custom text small and grey below the button, and you cannot change that. What you can change: put the renewal terms in the product name or description in Stripe (shown larger in the order summary), and show the same line, set off, on your own page before the redirect.";
22598
22817
  family = (c) => String(c.checkId).split(".")[0];
22599
22818
  isEmailCheck = (id) => id.startsWith("confirmation.") || id.startsWith("reminder.");
22600
22819
  ask = (text, recommended, why) => ({ text, recommended, why });
@@ -22605,6 +22824,7 @@ var init_status = __esm({
22605
22824
  VERIFIED_FROM_HOSTED_NOTE = "Verified from Checkbox settings: your Checkbox cancellation page is on and working, and your site links to it. Checkbox runs each cancellation there itself and confirms it on screen and by email.";
22606
22825
  HOSTED_UNLINKED_FIX = (url, hiddenOn) => `${hiddenOn ? `The link to your Checkbox cancellation page on ${hiddenOn} is hidden from visitors, so it does not count. ` : ""}Add a visible "Cancel subscription" link to your Checkbox cancellation page${url ? ` (${url})` : ""} on your site (the footer is fine), or turn on Checkbox Notices so every acknowledgment email carries it. Then re-check; no test account is needed.`;
22607
22826
  NOT_CHECKED_CANCEL_FIX = "Let Checkbox try your cancel flow: add a test subscriber account in Site settings \u2192 Test account (agents: updateSite field test_account { login_url, email, password }; the shopper signs in and cancels), or start a test run in Stripe test mode (it signs up and cancels a test subscription). If subscribers cancel in the Stripe customer portal, no test account is needed: turn on cancellation there and link its login page from your site.";
22827
+ CANCEL_STEPS_FIX = `Shorten the cancellation to 4 steps or fewer from the account page. With Stripe, make your "Cancel subscription" button create a customer portal session with flow_data: { type: "subscription_cancel", subscription_cancel: { subscription: "<the subscriber's subscription id>" } } and redirect to its url: the subscriber lands straight on Stripe's cancel confirmation. In your own flow, drop extra pages (one confirmation and at most one save offer). Then run a test run again.`;
22608
22828
  EMAIL_EXISTS = /not seen sent yet|only a template or sample|shows the template, not that|turned on in your billing platform|only been received for|captured, but its timing|from another system|not linked to a subscriber|sign-ups have no acknowledgment on record|window has not closed yet|upcoming .* observed|no price change observed yet|confirm they reach every/;
22609
22829
  RULES = [
22610
22830
  // Round 23 (pilot 22, ai-builder "soft false pass"): a test cancellation worked in the Stripe customer portal, but no
@@ -22615,6 +22835,16 @@ var init_status = __esm({
22615
22835
  when: (c) => String(c.checkId).startsWith("cancel.") && c.data?.portalUnlinked === true,
22616
22836
  build: (c) => ({ status: "needs_fix", fix: portalUnlinkedFix(typeof c.data?.portalUrl === "string" ? c.data.portalUrl : void 0) })
22617
22837
  },
22838
+ // Round 34 (pilot 33 item 16, core-discovery: a test run said "nothing to change" for a 5-step cancellation, then the
22839
+ // plan asked the owner "Is this requirement met?" with an unrelated fix): a cancellation that took more steps than
22840
+ // Checkbox's limit is a change the agent can make first (a deep link straight to the cancel confirmation), not a
22841
+ // question for the owner.
22842
+ {
22843
+ code: "cancel_too_many_steps",
22844
+ category: "content",
22845
+ when: (c) => c.checkId === "cancel.flow_friction" && c.status === "needs_review" && /cancelling took \d+ steps \(more than \d+\)/.test(c.text),
22846
+ build: () => ({ status: "needs_fix", fix: CANCEL_STEPS_FIX })
22847
+ },
22618
22848
  // Round 20 (pilot 19 item 1): an acknowledgment that does not mention a trial, on a site where only some plans have
22619
22849
  // one, and Checkbox cannot tell which plan it goes to. Not a fix and not a pass: the plan of the email is unknown.
22620
22850
  {
@@ -22821,17 +23051,30 @@ var init_status = __esm({
22821
23051
  { code: "far_from_button", category: "content", when: has(/far from the purchase button|only far from/), build: () => ({ status: "needs_fix", fix: "Move the renewal line directly above (or right below) the pay button." }) },
22822
23052
  { code: "below_button_small", category: "content", when: has(/directly below the purchase button/), build: () => ({ status: "needs_fix", fix: "Move the renewal line under the price, before the card and email fields and the pay button, at body size." }) },
22823
23053
  // Normal round 2 (nextjs, django): "looks like the surrounding text" is a measurement Checkbox cannot finish (borders,
22824
- // symbols and italics are not captured), so it is a question to confirm by eye, not "Needs a fix". Never compliant.
23054
+ // symbols and italics are not captured), so it was a question to confirm by eye. Round 33 (pilot 32 item 18,
23055
+ // core-api-only / core-ci-pr / core-discovery: the owner was asked for "a decision" an agent could make in code, and the
23056
+ // recommended "make it semibold" could never pass next to an already bold price line): a fix with the measured values
23057
+ // and the ways Checkbox can measure (larger type, semibold where the text around is not bold, a contrasting colour, a
23058
+ // border). Never compliant.
23059
+ // A disclosure text check (kind "disclosure") only knows the styling the caller describes (placement.bold / boxed):
23060
+ // there it stays the question it was, with the same measurable options.
22825
23061
  {
22826
23062
  code: "not_set_off",
23063
+ category: "content",
23064
+ when: (c) => c.kind !== "disclosure" && /look like the surrounding text/.test(c.text),
23065
+ build: (c) => ({ status: "needs_fix", fix: setOffFix(c.text) })
23066
+ },
23067
+ {
23068
+ code: "not_set_off_described",
22827
23069
  category: "legal_choice",
22828
- when: has(/look like the surrounding text/),
22829
- build: () => ({
23070
+ when: (c) => c.kind === "disclosure" && /look like the surrounding text/.test(c.text),
23071
+ build: (c) => ({
22830
23072
  status: "your_call",
23073
+ fix: setOffFix(c.text),
22831
23074
  question: ask(
22832
- "Confirm by eye: does the renewal line stand out from the text around it (semibold, a brand colour, or a border)?",
22833
- "Yes if it is semibold or in a colour next to the button; if not, make it semibold (same size, no box or banner needed).",
22834
- "Checkbox measured the same size, weight and colour as the nearby body text, but cannot see borders or symbols."
23075
+ "Confirm by eye: does the renewal line stand out from the text around it (semibold where the text around it is not bold, larger type, a contrasting colour, or a border)?",
23076
+ "If it does, check again with placement.bold (or placement.boxed) set; if not, apply one of the ways in the fix.",
23077
+ "This check only knows the styling you describe; it cannot see your page."
22835
23078
  )
22836
23079
  })
22837
23080
  },
@@ -23179,7 +23422,7 @@ var init_status = __esm({
23179
23422
  { code: "stripe_checkout_unchecked", category: "missing_evidence", when: has(/checkout session whose parameters checkbox has not checked|register the checkout session parameters/), build: (c) => ({ status: "pending_live_check", pending: pendingFor("", `${c.text} createstripecheckoutconfig`, c.data) }) }
23180
23423
  ];
23181
23424
  STATUS_RULES = RULES.map((r) => ({ code: r.code, category: r.category }));
23182
- NY_EMAIL_ONLY_LABEL = "Your decision: email only";
23425
+ NY_EMAIL_ONLY_LABEL = "Your decision: email only (not verified)";
23183
23426
  NY_EMAIL_ONLY_NOTE = 'You recorded your decision to send renewal and reminder notices by email only. New York asks for notices "in the manner the consumer selected"; whether email that nobody picked meets that is not settled, and Checkbox does not decide it. You can add an Email choice at sign-up at any time.';
23184
23427
  STRIPE_HOSTED_UNITS = /* @__PURE__ */ new Set(["checkout_config", "payment_link", "stripe_hosted"]);
23185
23428
  PLATFORM_NAMES2 = { paddle: "Paddle", shopify: "Shopify", recharge: "Recharge", chargebee: "Chargebee", recurly: "Recurly" };
@@ -23981,12 +24224,12 @@ function disclosureParts(text) {
23981
24224
  function disclosureHtml(text) {
23982
24225
  return disclosureParts(text).map((p) => "url" in p ? `<a href="${esc(p.url)}">${esc(p.label)}</a>` : esc(p.text)).join("");
23983
24226
  }
23984
- function htmlDisclosureComponent(texts, sha2562) {
24227
+ function htmlDisclosureComponent(texts, sha2562, opts = {}) {
23985
24228
  return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
23986
24229
  Paste INSIDE your sign-up <form>, directly ABOVE the subscribe / purchase button.
23987
- One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
23988
- If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead. Keep it at body size and readable contrast; do not make it smaller or lighter than the text around it. -->
23989
- <div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;">
24230
+ ${opts.boxed ? BOXED_NOTE : ` One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
24231
+ If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead.`} Keep it at body size and readable contrast; do not make it smaller or lighter than the text around it. -->
24232
+ <div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;${opts.boxed ? BOXED_STYLE : ""}">
23990
24233
  <p style="margin:0 0 6px;font-weight:600;">${disclosureHtml(texts.disclosure)}</p>
23991
24234
  <label style="display:flex;gap:8px;align-items:flex-start;">
23992
24235
  <!-- Never add "checked": consent must be an affirmative act. -->
@@ -23996,23 +24239,26 @@ function htmlDisclosureComponent(texts, sha2562) {
23996
24239
  </div>
23997
24240
  <button type="submit">${esc(texts.cta)}</button>`;
23998
24241
  }
23999
- function htmlTermsOnly(disclosure, sha2562, onStripe) {
24242
+ function htmlTermsOnly(disclosure, sha2562, onStripe, opts = {}) {
24000
24243
  const where2 = onStripe === "payment_link" ? "Payment Link" : "Stripe Checkout";
24001
24244
  return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
24002
24245
  Paste directly ABOVE your own subscribe / Join link or button. Keep that link or button as it is.
24003
24246
  No consent checkbox here: the ${where2} collects consent (consent_collection.terms_of_service = "required").
24004
- One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
24005
- If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead. -->
24006
- <div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;">
24247
+ ${opts.boxed ? BOXED_NOTE : ` One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
24248
+ If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead.`} -->
24249
+ <div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;${opts.boxed ? BOXED_STYLE : ""}">
24007
24250
  <p style="margin:0;font-weight:600;">${disclosureHtml(disclosure)}</p>
24008
24251
  </div>`;
24009
24252
  }
24010
- var esc, URL_RE2;
24253
+ var esc, URL_RE2, BOXED_STYLE, BOXED_NOTE;
24011
24254
  var init_html = __esm({
24012
24255
  "../rules/src/fixes/html.ts"() {
24013
24256
  "use strict";
24014
24257
  esc = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24015
24258
  URL_RE2 = /https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)]/g;
24259
+ BOXED_STYLE = "border:1px solid currentColor;border-radius:6px;padding:8px 12px;";
24260
+ BOXED_NOTE = ` The text next to your button is already bold, so a semibold line cannot stand out: this version is set off by a border
24261
+ (${BOXED_STYLE.replace(/;$/, "")}), which Checkbox measures. Keep the border, or use 15% larger type instead.`;
24016
24262
  }
24017
24263
  });
24018
24264
 
@@ -24095,7 +24341,7 @@ function modelAfterCapture(before, target, texts, opts) {
24095
24341
  const ctaY = cta ? cta.rect.y : 700;
24096
24342
  const ctaH = cta ? cta.rect.height : 48;
24097
24343
  const stripe = target === "stripe_checkout" || target === "stripe_payment_link";
24098
- const style2 = stripe ? STRIPE_CUSTOM_TEXT_STYLE : COMPONENT_STYLE;
24344
+ const style2 = stripe ? STRIPE_CUSTOM_TEXT_STYLE : opts.boxed ? { ...COMPONENT_STYLE, boxed: true } : COMPONENT_STYLE;
24099
24345
  const disclosureH = textHeight(texts.disclosure, width, style2.fontSizePx);
24100
24346
  const labelH = textHeight(texts.consentLabel, width - 28, style2.fontSizePx);
24101
24347
  const disclosure = {
@@ -24227,7 +24473,7 @@ async function verifyFix(input) {
24227
24473
  const rest = captures.filter((c) => !allCheckout(c));
24228
24474
  const unchangedCheckout = latestPages(captures.filter((c) => allCheckout(c) && !matching.some((m) => m.id === c.id))).filter((c) => (c.ctas?.length ?? 0) > 0);
24229
24475
  const otherCheckoutPages = new Set(unchangedCheckout.map((c) => c.url.split(/[?#]/)[0])).size;
24230
- const after = base.map((c) => modelAfterCapture(c, input.target, input.texts, { consentCheckbox: consent }));
24476
+ const after = base.map((c) => modelAfterCapture(c, input.target, input.texts, { consentCheckbox: consent, ...input.boxed ? { boxed: true } : {} }));
24231
24477
  const changedIds = new Set(objects.map((o) => o.id));
24232
24478
  const afterConfigs = input.ctx.checkoutConfigs?.map(
24233
24479
  (c) => changedIds.has(c.id) ? { ...c, capture: modelAfterCapture(c.capture, input.target, input.texts, { consentCheckbox: consent }), consentRequired: consent || c.consentRequired } : c
@@ -24384,6 +24630,9 @@ var init_model = __esm({
24384
24630
  });
24385
24631
 
24386
24632
  // ../rules/src/preflight/disclosure.ts
24633
+ function stripeConsentText(kind) {
24634
+ return kind === "payment_link" ? 'The purchase is completed on a Stripe Payment Link, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Payment Link: check it with checkStripePaymentLink (pass the retrieved link, plink_\u2026, as params). It needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text.' : "The purchase is completed on Stripe Checkout, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Checkout Session: check the params your code sends with checkStripeCheckoutSession.";
24635
+ }
24387
24636
  function resolvePlan(input, prefix = "") {
24388
24637
  const currency = parseCurrency(input.currency, `${prefix}currency`);
24389
24638
  const amount = minorAmount({ price: input.price, amount: input.amount, currency }, `${prefix}price`);
@@ -24402,6 +24651,20 @@ function resolvePlan(input, prefix = "") {
24402
24651
  throw new PreflightInputError(`${prefix}intro_percent_off must be a number above 0 and below 100 (20 for 20% off)`, `${prefix}intro_percent_off`);
24403
24652
  }
24404
24653
  const off = input.intro_amount_off;
24654
+ const majorPrice = input.amount === void 0 && input.price !== void 0 && input.price !== null ? Number(String(input.price).replace(/[^0-9.]/g, "")) : NaN;
24655
+ const looksMajor = (v) => typeof v === "number" && Number.isFinite(majorPrice) && majorPrice > 0 && v > 0 && v < 100 && v <= majorPrice;
24656
+ if (looksMajor(off)) {
24657
+ throw new PreflightInputError(
24658
+ `${prefix}intro_amount_off is in minor units (cents), like ${prefix}amount: ${off} means ${(off / 100).toFixed(2)} off, next to a ${prefix}price of ${majorPrice.toFixed(2)} in major units. For ${off.toFixed(2)} off send ${prefix}intro_amount_off: ${Math.round(off * 100)}, or ${prefix}intro_price: ${Math.max(0, majorPrice - off).toFixed(2)} (the first-period price in major units, like ${prefix}price)`,
24659
+ `${prefix}intro_amount_off`
24660
+ );
24661
+ }
24662
+ if (input.intro_price === void 0 && looksMajor(input.intro_amount)) {
24663
+ throw new PreflightInputError(
24664
+ `${prefix}intro_amount is in minor units (cents), like ${prefix}amount: ${input.intro_amount} means ${(input.intro_amount / 100).toFixed(2)}, next to a ${prefix}price of ${majorPrice.toFixed(2)} in major units. Send ${prefix}intro_price: ${input.intro_amount} (major units, like ${prefix}price) or ${prefix}intro_amount: ${Math.round(input.intro_amount * 100)}`,
24665
+ `${prefix}intro_amount`
24666
+ );
24667
+ }
24405
24668
  if (off !== void 0 && off !== null && !(typeof off === "number" && Number.isInteger(off) && off > 0 && off < amount)) {
24406
24669
  throw new PreflightInputError(`${prefix}intro_amount_off must be a whole number of minor units above 0 and below the recurring price (200 for $2.00 off)`, `${prefix}intro_amount_off`);
24407
24670
  }
@@ -24492,13 +24755,18 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24492
24755
  const verifyTexts = lockedTexts(verifyTerms);
24493
24756
  const page = opts.onStripe && opts.page && (opts.page.ctas?.length ?? 0) > 0 ? [{ ...opts.page, pageKind: "checkout", source: "scanner" }] : [];
24494
24757
  const ctx = modelContext(jurisdictions, asOf, planFacts(plan2), { config: planConfig(plan2, asOf), ...page.length ? { captures: page } : {}, ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24495
- const v = await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, ...opts.onStripe ? { consentCheckbox: false } : {} });
24758
+ const v0 = await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, ...opts.boxed ? { boxed: true } : {}, ...opts.onStripe ? { consentCheckbox: false } : {} });
24759
+ const tryBoxed = !opts.boxed && page.length > 0 && !v0.passes && v0.remaining.includes("signup.disclosure.conspicuous");
24760
+ const vb = tryBoxed ? await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, boxed: true, ...opts.onStripe ? { consentCheckbox: false } : {} }) : void 0;
24761
+ const boxedOnPage = !!vb && !vb.remaining.includes("signup.disclosure.conspicuous") && vb.remaining.length < v0.remaining.length;
24762
+ const boxed = opts.boxed === true || boxedOnPage;
24763
+ const v = boxedOnPage ? vb : v0;
24496
24764
  const sha = textsSha256(shown);
24497
24765
  const placeholders = plan2.cancelUrl ? [] : [CANCEL_URL_PLACEHOLDER];
24498
24766
  let short;
24499
24767
  if (plan2.cancelUrl) {
24500
24768
  const shortText = shortDisclosureText(verifyTerms);
24501
- const sv = await verifyFix({ ctx, target: "html", texts: { ...verifyTexts, disclosure: shortText }, terms: verifyTerms, now, ...opts.onStripe ? { consentCheckbox: false } : {} });
24769
+ const sv = await verifyFix({ ctx, target: "html", texts: { ...verifyTexts, disclosure: shortText }, terms: verifyTerms, now, ...boxed ? { boxed: true } : {}, ...opts.onStripe ? { consentCheckbox: false } : {} });
24502
24770
  if (sv.passes && v.passes && shortText !== shown.disclosure) short = shortText;
24503
24771
  }
24504
24772
  if (opts.onStripe) {
@@ -24509,9 +24777,9 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24509
24777
  ...short ? { short_disclosure_text: short } : {},
24510
24778
  consent_label: shown.consentLabel,
24511
24779
  button_text: shown.cta,
24512
- html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe),
24780
+ html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe, boxed ? { boxed: true } : {}),
24513
24781
  consent_on: opts.onStripe,
24514
- note: `html is the renewal terms only (no checkbox, no button): paste it directly above your own purchase link and keep that link. Consent is collected on ${where2}: it needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text (check it with ${check2}). consent_label and button_text are for a page that collects consent itself.`,
24782
+ note: `html is the renewal terms only (no checkbox, no button): paste it directly above your own purchase link and keep that link. Consent is collected on ${where2}: it needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text (check it with ${check2}). consent_label and button_text are for a page that collects consent itself.${boxed ? " The text next to your button is already bold, so a semibold renewal line cannot stand out: this html sets it off with a border, which Checkbox measures (or use type 15% larger than the text around it)." : ""}`,
24515
24783
  placeholders,
24516
24784
  verified: v.passes,
24517
24785
  remaining_checks: [.../* @__PURE__ */ new Set([...v.remaining, "signup.consent.affirmative"])],
@@ -24523,7 +24791,7 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24523
24791
  ...short ? { short_disclosure_text: short } : {},
24524
24792
  consent_label: shown.consentLabel,
24525
24793
  button_text: shown.cta,
24526
- html: htmlDisclosureComponent(shown, sha),
24794
+ html: htmlDisclosureComponent(shown, sha, boxed ? { boxed: true } : {}),
24527
24795
  placeholders,
24528
24796
  verified: v.passes,
24529
24797
  remaining_checks: v.remaining,
@@ -24615,15 +24883,20 @@ function modelDisclosureCheckout(input) {
24615
24883
  return { captures, viewport: VIEWPORT };
24616
24884
  }
24617
24885
  function nextActionFor(checks, replacement, input) {
24618
- const failing = checks.filter((c) => c.status === "fail");
24619
- const review2 = checks.filter((c) => c.status === "needs_review");
24886
+ const own = input.onStripe ? checks.filter((c) => c.check_id !== "signup.consent.affirmative") : checks;
24887
+ const failing = own.filter((c) => c.status === "fail");
24888
+ const review2 = own.filter((c) => c.status === "needs_review");
24620
24889
  const fill2 = replacement.placeholders.length ? ` Replace ${replacement.placeholders.join(", ")} with your real cancellation URL (or pass cancel_url).` : "";
24890
+ const stripeNote = input.onStripe ? ` ${stripeConsentText(input.onStripe)}` : "";
24621
24891
  if (failing.length) {
24622
24892
  return {
24623
24893
  type: "replace_text",
24624
- message: `Failing: ${openChecks(checks)}. Replace the disclosure with ${replacement.short_disclosure_text ? "replacement.short_disclosure_text (one line; replacement.disclosure_text is the fuller version)" : "replacement.disclosure_text"}, show it under the price, before the card and email fields and the purchase button (semibold or boxed, at least your body text size), add an unchecked required checkbox labelled replacement.consent_label (or paste replacement.html), then call this check again.${fill2}`
24894
+ message: input.onStripe ? `Failing: ${openChecks(own)}. Replace the text with ${replacement.short_disclosure_text ? "replacement.short_disclosure_text (one line; replacement.disclosure_text is the fuller version)" : "replacement.disclosure_text"} (or paste replacement.html, the terms only), show it directly above your purchase link (semibold or boxed, at least your body text size), then call this check again.${stripeNote}${fill2}` : `Failing: ${openChecks(checks)}. Replace the disclosure with ${replacement.short_disclosure_text ? "replacement.short_disclosure_text (one line; replacement.disclosure_text is the fuller version)" : "replacement.disclosure_text"}, show it under the price, before the card and email fields and the purchase button (semibold or boxed, at least your body text size), add an unchecked required checkbox labelled replacement.consent_label (or paste replacement.html), then call this check again.${fill2}`
24625
24895
  };
24626
24896
  }
24897
+ if (input.onStripe && !review2.length) {
24898
+ return { type: "fix_and_recheck", message: `The text passes every check that can be judged here. Keep it directly above your purchase link.${stripeNote}${fill2}` };
24899
+ }
24627
24900
  if (review2.length && review2.every((c) => c.check_id === "signup.disclosure.conspicuous")) {
24628
24901
  const same = norm(input.text) === norm(replacement.disclosure_text);
24629
24902
  if (!input.bold && !input.boxed) {
@@ -24672,20 +24945,24 @@ async function checkDisclosure(input, opts = {}) {
24672
24945
  label: str2(c.label, 1e3, "consent.label") ?? DEFAULT_CONSENT_LABEL,
24673
24946
  required: bool2(c.required, true, "consent.required")
24674
24947
  } : void 0;
24948
+ const checkoutType = input.checkout_type;
24949
+ if (checkoutType !== void 0 && checkoutType !== "own_page" && checkoutType !== "stripe_checkout" && checkoutType !== "payment_link") throw new PreflightInputError('checkout_type must be "own_page", "stripe_checkout" or "payment_link"', "checkout_type");
24950
+ const onStripe = checkoutType === "stripe_checkout" || checkoutType === "payment_link" ? checkoutType : void 0;
24951
+ const facts = { ...planFacts(plan2), ...checkoutType ? { checkoutType } : {} };
24675
24952
  const warnings = [];
24676
- if (!c) warnings.push({ code: "consent_unspecified", message: "consent was not given; the check assumes there is no consent checkbox.", param: "consent" });
24953
+ if (!c && !onStripe) warnings.push({ code: "consent_unspecified", message: "consent was not given; the check assumes there is no consent checkbox.", param: "consent" });
24677
24954
  if (placement.contrast_ratio !== void 0 && placement.contrast_ratio < CONSPICUOUS_MIN_CONTRAST) {
24678
24955
  warnings.push({ code: "low_contrast", message: `contrast_ratio ${placement.contrast_ratio} is below ${CONSPICUOUS_MIN_CONTRAST}:1.`, param: "placement.contrast_ratio" });
24679
24956
  }
24680
24957
  const buttonText = str2(input.button_text, 200, "button_text") ?? (plan2.trialDays ? "Start free trial" : "Subscribe");
24681
24958
  const orderSummary = str2(input.order_summary, 1e3, "order_summary");
24682
24959
  const { captures } = modelDisclosureCheckout({ text, buttonText, ...orderSummary ? { orderSummary } : {}, placement, ...consent ? { consent } : {}, asOf });
24683
- const ctx = modelContext(jurisdictions, asOf, planFacts(plan2), { captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24960
+ const ctx = modelContext(jurisdictions, asOf, facts, { captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24684
24961
  const run2 = await runChecks(ctx, SIGNUP_FIX_CHECKS, now);
24685
24962
  let checks = shapeChecks(run2.findings);
24686
24963
  if (!placement.same_page_as_payment) {
24687
24964
  const onPage = modelDisclosureCheckout({ text, buttonText, ...orderSummary ? { orderSummary } : {}, placement: { ...placement, same_page_as_payment: true }, ...consent ? { consent } : {}, asOf });
24688
- const textCtx = modelContext(jurisdictions, asOf, planFacts(plan2), { captures: onPage.captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24965
+ const textCtx = modelContext(jurisdictions, asOf, facts, { captures: onPage.captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24689
24966
  const byText = new Map(shapeChecks((await runChecks(textCtx, SIGNUP_FIX_CHECKS, now)).findings).map((v) => [v.check_id, v]));
24690
24967
  checks = checks.map((v) => {
24691
24968
  const t = byText.get(v.check_id);
@@ -24701,13 +24978,17 @@ async function checkDisclosure(input, opts = {}) {
24701
24978
  };
24702
24979
  });
24703
24980
  }
24704
- const replacement = await buildReplacement(plan2, jurisdictions, asOf, now);
24981
+ if (onStripe) {
24982
+ const said = stripeConsentText(onStripe);
24983
+ checks = checks.map((v) => v.check_id === "signup.consent.affirmative" && v.status !== "pass" && v.status !== "not_applicable" ? { ...v, status: "needs_review", explanation: said, fix: said } : v);
24984
+ }
24985
+ const replacement = await buildReplacement(plan2, jurisdictions, asOf, now, onStripe ? { onStripe } : {});
24705
24986
  const ownButton = str2(input.button_text, 200, "button_text");
24706
- if (ownButton && buttonFitsPlan(ownButton, plan2)) {
24987
+ if (ownButton && buttonFitsPlan(ownButton, plan2) && !onStripe) {
24707
24988
  replacement.button_text = ownButton;
24708
- replacement.html = htmlDisclosureComponent({ disclosure: replacement.disclosure_text, consentLabel: replacement.consent_label, cta: ownButton }, replacement.sha256);
24989
+ replacement.html = htmlDisclosureComponent({ disclosure: replacement.disclosure_text, consentLabel: replacement.consent_label, cta: ownButton }, replacement.sha256, /data-checkbox-terms="[^"]*" style="[^"]*border:1px solid currentColor/.test(replacement.html) ? { boxed: true } : {});
24709
24990
  }
24710
- return { ...baseReport("disclosure", checks, jurisdictions, asOf, warnings, nextActionFor(checks, replacement, { text, bold: placement.bold, boxed: placement.boxed }), `${input.text}
24991
+ return { ...baseReport("disclosure", checks, jurisdictions, asOf, warnings, nextActionFor(checks, replacement, { text, bold: placement.bold, boxed: placement.boxed, ...onStripe ? { onStripe } : {} }), `${input.text}
24711
24992
  ${input.consent?.label ?? ""}`, plan2), kind: "disclosure", replacement };
24712
24993
  }
24713
24994
  var MAX_DISCLOSURE_CHARS, PREFLIGHT_PLAN_ID, CANCEL_URL_PLACEHOLDER, SAMPLE_CANCEL_URL, DEFAULT_CONSENT_LABEL, norm, TEXT_CONTENT_CHECKS;
@@ -24814,6 +25095,17 @@ var init_price_hint = __esm({
24814
25095
  });
24815
25096
 
24816
25097
  // ../rules/src/preflight/stripe.ts
25098
+ function trialPromise(texts) {
25099
+ for (const [field, text] of texts) {
25100
+ if (!text) continue;
25101
+ const a = analyzeTerms(text);
25102
+ if (!a.trialMentions.length && !a.trialLengths.length) continue;
25103
+ const days = a.trialLengths.map((t) => "days" in t ? t.days : null).filter((d) => typeof d === "number" && d > 0);
25104
+ const span = a.trialLengths[0] ?? a.trialMentions[0];
25105
+ return { field, quote: span.text.trim(), days };
25106
+ }
25107
+ return void 0;
25108
+ }
24817
25109
  function clone(v) {
24818
25110
  return JSON.parse(JSON.stringify(v));
24819
25111
  }
@@ -24905,6 +25197,25 @@ function readParams(params, merchant, kind, asOf) {
24905
25197
  if (trial !== void 0 && trial > 0 && custom && /[$€£]\s?\d|\b(?:charged?|billed)\b/i.test(custom) && !/\b(?:trial|free|days?)\b/i.test(custom)) {
24906
25198
  caveats.push({ checks: ["signup.disclosure.trial_terms"], status: "fail", reason: `there is a ${trial}-day free trial, but your custom_text.submit states the charge without mentioning it, so a shopper reads it as charged today (Stripe's summary alone shows the trial) \u2014 state the ${trial}-day free trial and what is charged when it ends`, param: "params.custom_text.submit" });
24907
25199
  }
25200
+ const promisedTrial = trialPromise([["custom_text.submit", msg(ctAll.submit)], ["custom_text.terms_of_service_acceptance", isObj(params.consent_collection) && params.consent_collection.terms_of_service === "required" ? msg(ctAll.terms_of_service_acceptance) : void 0], ["product description", description]]);
25201
+ if (promisedTrial && !(trial !== void 0 && trial > 0)) {
25202
+ caveats.push({
25203
+ checks: ["signup.disclosure.trial_terms"],
25204
+ status: "fail",
25205
+ reason: `the ${promisedTrial.field} promises a free trial (\u201C${promisedTrial.quote}\u201D), but the params run none (no subscription_data.trial_period_days or trial_end), so Stripe charges the full price today \u2014 add the trial to subscription_data, or remove the promise from the text`,
25206
+ param: promisedTrial.field === "product description" ? "params.line_items" : `params.${promisedTrial.field}.message`
25207
+ });
25208
+ } else if (promisedTrial && trial !== void 0 && trial > 0) {
25209
+ const wrongDays = promisedTrial.days.find((d) => Math.abs(d - trial) > TRIAL_DAYS_SLACK);
25210
+ if (wrongDays !== void 0) {
25211
+ caveats.push({
25212
+ checks: ["signup.disclosure.trial_terms"],
25213
+ status: "fail",
25214
+ reason: `the ${promisedTrial.field} promises a ${wrongDays}-day free trial (\u201C${promisedTrial.quote}\u201D), but the params run a ${trial}-day trial \u2014 state the ${trial}-day free trial subscribers get, or change subscription_data.trial_period_days`,
25215
+ param: promisedTrial.field === "product description" ? "params.line_items" : `params.${promisedTrial.field}.message`
25216
+ });
25217
+ }
25218
+ }
24908
25219
  const uiMode = typeof params.ui_mode === "string" ? params.ui_mode : void 0;
24909
25220
  if (uiMode === "custom" || uiMode === "elements") {
24910
25221
  caveats.push({
@@ -25155,7 +25466,7 @@ function planAsMerchant(shape, merchant) {
25155
25466
  }
25156
25467
  function correct(params, shape, checks, forLink, style2 = "full") {
25157
25468
  const byId = new Map(checks.map((c) => [c.check_id, c]));
25158
- const needText = TEXT_CHECKS.some((id) => open(byId.get(id)));
25469
+ const needText = TEXT_CHECKS.some((id) => open(byId.get(id)) && !isHostedStylingNote(byId.get(id), checks, "stripe_checkout_session"));
25159
25470
  const needConsent = CONSENT_CHECKS2.some((id) => open(byId.get(id))) || !shape.consentRequired && needText;
25160
25471
  const terms2 = merchantTerms(shape.plan, false);
25161
25472
  const full = lockedTexts(terms2);
@@ -25182,6 +25493,7 @@ function correct(params, shape, checks, forLink, style2 = "full") {
25182
25493
  out.custom_text = set;
25183
25494
  } else {
25184
25495
  for (const [k, v] of Object.entries(set)) {
25496
+ if (JSON.stringify(ct[k]) === JSON.stringify(v)) continue;
25185
25497
  patch.push({ op: k in ct && ct[k] !== null ? "replace" : "add", path: `/custom_text/${k}`, value: v });
25186
25498
  ct[k] = v;
25187
25499
  }
@@ -25275,6 +25587,11 @@ function withResolvedPrices(input, knownPrices, amountSource = "merchant.price")
25275
25587
  const m = input.merchant ?? {};
25276
25588
  const stated = typeof m.amount === "number" ? m.amount : typeof m.price === "number" ? Math.round(m.price * 10 ** currencyDigitsOf(r.resolved.currency)) : void 0;
25277
25589
  const warnings = [];
25590
+ const sd = isObj(input.params.subscription_data) ? input.params.subscription_data : {};
25591
+ const paramsTrial = typeof sd.trial_period_days === "number" ? sd.trial_period_days : void 0;
25592
+ const priceTrial = r.resolved.trialDays && r.resolved.trialDays > 0 ? r.resolved.trialDays : void 0;
25593
+ const trialNote = priceTrial ? paramsTrial !== void 0 ? paramsTrial === priceTrial ? `, with a ${priceTrial}-day trial on the Price (the same as subscription_data.trial_period_days)` : `, with a ${priceTrial}-day trial on the Price; these params set subscription_data.trial_period_days to ${paramsTrial}, which is the trial this Checkout gives` : `, with a ${priceTrial}-day trial on the Price (trial_period_days); these params set no trial of their own` : "";
25594
+ warnings.push({ code: "price_read", message: `Stripe Price ${r.resolved.id} was read from your connected Stripe account: ${formatMoney(r.resolved.amount, r.resolved.currency)} per ${intervalPhrase(r.resolved.interval, r.resolved.intervalCount)}${trialNote}.`, param: "params.line_items" });
25278
25595
  if (stated !== void 0 && stated !== r.resolved.amount) {
25279
25596
  warnings.push({ code: "merchant_price_contradicts_price", message: `merchant.price says ${formatMoney(stated, r.resolved.currency)}, but ${r.resolved.id} charges ${formatMoney(r.resolved.amount, r.resolved.currency)} per ${intervalPhrase(r.resolved.interval, r.resolved.intervalCount)} in your connected Stripe account; the Price wins and the corrected custom_text states it.`, param: "merchant.price" });
25280
25597
  }
@@ -25449,7 +25766,10 @@ function modelCheckoutConfig(params, opts) {
25449
25766
  // travels with its plan, so the site's checks and copy know the promo without a separate declaration.
25450
25767
  ...plan2.introAmount !== void 0 && plan2.introAmount >= 0 && plan2.introAmount < plan2.amount ? { introAmount: plan2.introAmount, ...plan2.introPeriods && plan2.introPeriods > 1 ? { introPeriods: plan2.introPeriods } : {} } : {}
25451
25768
  },
25452
- consentRequired: shape.consentRequired
25769
+ consentRequired: shape.consentRequired,
25770
+ // Round 33: the trial the params themselves run (subscription_data), 0 when none; a trial taken only from a Price
25771
+ // or merchant terms is not one Stripe runs for these params.
25772
+ trialDays: plan2.trialDays ?? 0
25453
25773
  };
25454
25774
  }
25455
25775
  function noticeChannelField(params) {
@@ -25476,6 +25796,7 @@ var init_stripe2 = __esm({
25476
25796
  init_compact();
25477
25797
  init_plan_terms();
25478
25798
  init_text();
25799
+ init_signup();
25479
25800
  init_verify();
25480
25801
  init_stripe();
25481
25802
  init_common();
@@ -25991,9 +26312,6 @@ function stripeButtonsKind(capture) {
25991
26312
  const [only] = [...kinds];
25992
26313
  return kinds.size === 1 && only ? only : void 0;
25993
26314
  }
25994
- function stripeConsentText(kind) {
25995
- return kind === "payment_link" ? 'The purchase is completed on a Stripe Payment Link, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Payment Link: check it with checkStripePaymentLink (pass the retrieved link, plink_\u2026, as params). It needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text.' : "The purchase is completed on Stripe Checkout, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Checkout Session: check the params your code sends with checkStripeCheckoutSession.";
25996
- }
25997
26315
  function hasPrice(t) {
25998
26316
  return !!t && (t.price !== void 0 && t.price !== null && t.price !== "" || t.amount !== void 0 && t.amount !== null);
25999
26317
  }
@@ -26183,7 +26501,8 @@ async function checkPageHtml(input, opts = {}) {
26183
26501
  });
26184
26502
  }
26185
26503
  const consentOnStripe = onStripe ?? (checkoutType === "payment_link" || checkoutType === "stripe_checkout" ? checkoutType : checkoutType ? void 0 : stripeButtonsKind(capture));
26186
- const repOpts = consentOnStripe ? { onStripe: consentOnStripe } : {};
26504
+ const boldAround = checks.some((c) => c.check_id === "signup.disclosure.conspicuous" && /already bold, so a bolder weight cannot set them apart/.test(c.explanation ?? ""));
26505
+ const repOpts = { ...consentOnStripe ? { onStripe: consentOnStripe } : {}, ...boldAround ? { boxed: true } : {} };
26187
26506
  const built = plan2 ? await buildReplacement(plan2, jurisdictions, asOf, now, { ...repOpts, page: capture }) : void 0;
26188
26507
  const differs = plan2 && !multi ? pagePriceDiffers(run2.findings) : [];
26189
26508
  if (plan2 && differs.length) {
@@ -26266,7 +26585,8 @@ function signupStripeObjects(input) {
26266
26585
  capture: { ...m.capture, siteId: input.siteId },
26267
26586
  consentRequired: m.consentRequired,
26268
26587
  kind: "checkout_config",
26269
- ...m.plan ? { planId: m.plan.id } : {}
26588
+ ...m.plan ? { planId: m.plan.id } : {},
26589
+ ...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
26270
26590
  });
26271
26591
  }
26272
26592
  if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", ...row.name ? { label: row.name } : {} });
@@ -26299,7 +26619,8 @@ function signupStripeObjects(input) {
26299
26619
  consentRequired: m.consentRequired,
26300
26620
  kind: "payment_link",
26301
26621
  ...link.url ? { url: link.url } : {},
26302
- ...m.plan ? { planId: m.plan.id } : {}
26622
+ ...m.plan ? { planId: m.plan.id } : {},
26623
+ ...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
26303
26624
  });
26304
26625
  }
26305
26626
  if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", label: `Payment Link \u201C${name}\u201D` });
@@ -26358,6 +26679,7 @@ __export(preflight_exports, {
26358
26679
  stripeParamsVerdict: () => stripeParamsVerdict,
26359
26680
  supportContactLine: () => supportContactLine,
26360
26681
  testRunSessionIds: () => testRunSessionIds,
26682
+ trialPromise: () => trialPromise,
26361
26683
  withIntroOffers: () => withIntroOffers,
26362
26684
  withTestRunSessions: () => withTestRunSessions
26363
26685
  });
@@ -27005,7 +27327,7 @@ function parseObjectLiteral(source, env2 = {}, consts = {}) {
27005
27327
  const p = new Parser(source, env2, consts);
27006
27328
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
27007
27329
  const v = p.object("");
27008
- return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
27330
+ return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {}, ...p.spreads.length ? { spreads: p.spreads } : {} };
27009
27331
  }
27010
27332
  var UNKNOWN, Parser;
27011
27333
  var init_literal = __esm({
@@ -27021,6 +27343,7 @@ var init_literal = __esm({
27021
27343
  i = 0;
27022
27344
  unresolved = [];
27023
27345
  fromEnv = {};
27346
+ spreads = [];
27024
27347
  ws() {
27025
27348
  for (; ; ) {
27026
27349
  while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
@@ -27157,7 +27480,9 @@ var init_literal = __esm({
27157
27480
  if (!ch) return UNKNOWN;
27158
27481
  if (this.s.startsWith("...", this.i)) {
27159
27482
  this.i += 3;
27483
+ const from = this.i;
27160
27484
  this.skipExpr();
27485
+ if (!path) this.spreads.push(this.s.slice(from, this.i).trim());
27161
27486
  this.unresolved.push(`${path ? `${path}.` : ""}...`);
27162
27487
  } else {
27163
27488
  let key = null;
@@ -27585,13 +27910,19 @@ function detectProject(root, processEnv = {}) {
27585
27910
  const payButtons = [];
27586
27911
  const checkoutSessions = [];
27587
27912
  const policyPaths = {};
27913
+ const linkedPolicies = {};
27588
27914
  walk2(root, (abs, rel2) => {
27589
27915
  const route = routeOf(rel2);
27916
+ const served = route && /\.html?$/i.test(rel2) && !/(?:^|\/)index\.html?$/i.test(rel2) ? `/${route}${/\.htm$/i.test(rel2) ? ".htm" : ".html"}` : `/${route}`;
27590
27917
  if (route) {
27591
- for (const p of POLICY_ROUTES) if (!policyPaths[p.kind] && p.re.test(route)) policyPaths[p.kind] = `/${route}`;
27918
+ for (const p of POLICY_ROUTES) if (!policyPaths[p.kind] && p.re.test(route)) policyPaths[p.kind] = served;
27592
27919
  }
27593
27920
  const text = readText(abs);
27594
27921
  if (!text) return;
27922
+ for (const m of text.matchAll(/\bhref=\{?\s*["'`](\/[A-Za-z0-9._~\/-]*)["'`]/g)) {
27923
+ const path = m[1].slice(1).replace(/\/$/, "").replace(/\.html?$/i, "").toLowerCase();
27924
+ for (const p of POLICY_ROUTES) if (!linkedPolicies[p.kind] && p.re.test(path)) linkedPolicies[p.kind] = m[1].replace(/(.)\/$/, "$1");
27925
+ }
27595
27926
  const lines = text.split("\n");
27596
27927
  lines.forEach((line2, i) => {
27597
27928
  for (const p of CHECKOUT_PATTERNS) {
@@ -27611,6 +27942,7 @@ function detectProject(root, processEnv = {}) {
27611
27942
  }
27612
27943
  });
27613
27944
  if (billingPlatform === "custom" && checkoutCode.some((c) => c.kind.startsWith("stripe_"))) billingPlatform = "stripe";
27945
+ Object.assign(policyPaths, linkedPolicies);
27614
27946
  const site = detectSiteUrl(root, env2, pkg);
27615
27947
  const stripeKey = findStripeKey(env2, processEnv, envFile);
27616
27948
  const brand = detectBrand(root, layoutFile, typeof pkg?.name === "string" ? pkg.name : void 0);
@@ -27756,8 +28088,8 @@ function plansFromLiterals(text, file) {
27756
28088
  if (!interval) continue;
27757
28089
  const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
27758
28090
  const minor = /\b(?:amount|unit_amount|price_cents|cents)\s*:\s*(\d+)\b/.exec(body)?.[1];
27759
- const major = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
27760
- if (major === void 0 || !(major > 0)) continue;
28091
+ const major2 = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
28092
+ if (major2 === void 0 || !(major2 > 0)) continue;
27761
28093
  const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
27762
28094
  if (!name) continue;
27763
28095
  const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
@@ -27766,7 +28098,7 @@ function plansFromLiterals(text, file) {
27766
28098
  if (out.some((p) => p.name === name)) continue;
27767
28099
  const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
27768
28100
  const ids = [...new Set([...body.matchAll(/['"`](price_[A-Za-z0-9_]+)['"`]/g)].map((x) => x[1]))];
27769
- out.push({ name, ...key ? { key } : {}, price: major, currency, interval, ...count && Number(count) > 1 ? { interval_count: Number(count) } : {}, ...trial && Number(trial) > 0 ? { trial_days: Number(trial) } : {}, ...ids.length ? { price_ids: ids } : {}, source: file });
28101
+ out.push({ name, ...key ? { key } : {}, price: major2, currency, interval, ...count && Number(count) > 1 ? { interval_count: Number(count) } : {}, ...trial && Number(trial) > 0 ? { trial_days: Number(trial) } : {}, ...ids.length ? { price_ids: ids } : {}, source: file });
27770
28102
  }
27771
28103
  return out;
27772
28104
  }
@@ -27890,6 +28222,10 @@ function pairCall(root, s2, plans, priceIds, calls) {
27890
28222
  }
27891
28223
  return { ref: literalIds[0] ?? exprs[0] };
27892
28224
  }
28225
+ function supportAddressIn(text) {
28226
+ const all = [...text.matchAll(EMAIL_ADDRESS)].map((m) => m[1]).filter((a) => !/^(?:no-?reply|do-?not-?reply|mailer-daemon|bounce)/i.test(a) && !/@(?:example\.(?:com|org|net)|email\.com|domain\.com)$/i.test(a));
28227
+ return all[0];
28228
+ }
27893
28229
  function scaffoldConfig(root, env2 = {}, facts = {}) {
27894
28230
  const det = detectProject(root, env2);
27895
28231
  const todos = [];
@@ -28026,6 +28362,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28026
28362
  } else stripe.push(entry);
28027
28363
  }
28028
28364
  const emails = [];
28365
+ const supportSeen = /* @__PURE__ */ new Map();
28029
28366
  const pages = [];
28030
28367
  const disclosures = [];
28031
28368
  walk3(root, (rel2) => {
@@ -28042,6 +28379,8 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28042
28379
  const hint = intervalHintOf(basename(rel2).replace(/\.[^.]+$/, ""));
28043
28380
  const byName = hint && plans.length > 1 ? plans.filter((p) => p.interval === hint && (Number(p.interval_count ?? 1) || 1) === 1) : [];
28044
28381
  const plan2 = plans.length > 1 && planTagged ? { plan: "all" } : byName.length === 1 ? { plan: String(byName[0].name) } : planFor();
28382
+ const contact2 = supportAddressIn(text);
28383
+ if (contact2 && !supportSeen.has(contact2)) supportSeen.set(contact2, rel2);
28045
28384
  emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...plan2, ...Object.keys(tags).length ? { merge_tags: tags } : {} });
28046
28385
  found.push(`${rel2}: ${kind} email${Object.keys(tags).length ? ` (its merge tags ${Object.keys(tags).map((t) => `{{${t}}}`).join(", ")} are filled from checkbox.json: see "merge_tags")` : ""}`);
28047
28386
  return;
@@ -28088,6 +28427,11 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28088
28427
  if (!disclosures.length && det.payButtons.length && !det.payButtons.some((b) => inPages(b.file))) {
28089
28428
  notes2.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`);
28090
28429
  }
28430
+ const [contact] = [...supportSeen.entries()];
28431
+ if (!merchant.support_contact && contact) {
28432
+ merchant.support_contact = contact[0];
28433
+ found.push(`support contact ${contact[0]} (from ${contact[1]}; check it is the address subscribers should write to)`);
28434
+ }
28091
28435
  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)');
28092
28436
  const checks = {};
28093
28437
  if (stripe.length) checks.stripe_checkout = stripe;
@@ -28106,7 +28450,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28106
28450
  todoValues: countTodoValues({ merchant, plans, checks })
28107
28451
  };
28108
28452
  }
28109
- var PAGE_ROUTE, IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
28453
+ var PAGE_ROUTE, IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE, EMAIL_ADDRESS;
28110
28454
  var init_scaffold = __esm({
28111
28455
  "src/ci/scaffold.ts"() {
28112
28456
  "use strict";
@@ -28135,6 +28479,7 @@ var init_scaffold = __esm({
28135
28479
  return n;
28136
28480
  };
28137
28481
  PLAN_FILE = /(?:^|\/)(?:plans?|pricing|prices|tiers|subscriptions?|billing|products?)(?:\.config)?\.(?:[cm]?[jt]sx?|json)$/i;
28482
+ EMAIL_ADDRESS = /(?:mailto:)?([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})/g;
28138
28483
  }
28139
28484
  });
28140
28485
 
@@ -28304,8 +28649,8 @@ function loaderFlags(file, dir, nodeVersion = process.versions.node, features =
28304
28649
  if (hasTsx(dir)) return ["--import", "tsx"];
28305
28650
  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`;
28306
28651
  if (features.typescript) return [];
28307
- const [major, minor] = nodeVersion.split(".").map(Number);
28308
- if (major > 22 || major === 22 && minor >= 6) return ["--experimental-strip-types"];
28652
+ const [major2, minor] = nodeVersion.split(".").map(Number);
28653
+ if (major2 > 22 || major2 === 22 && minor >= 6) return ["--experimental-strip-types"];
28309
28654
  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`;
28310
28655
  }
28311
28656
  function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
@@ -28375,7 +28720,23 @@ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
28375
28720
  if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
28376
28721
  const parsed = parseObjectLiteral(lit, env2, consts);
28377
28722
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
28378
- return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
28723
+ const unresolved = [...parsed.unresolved, ...spreadUnread(parsed.value, parsed.spreads ?? [], consts)];
28724
+ return { params: parsed.value, line: line2, unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
28725
+ }
28726
+ function spreadUnread(params, spreads, consts = {}) {
28727
+ const out = [];
28728
+ for (const expr of spreads) {
28729
+ const name = expr.replace(/\s+/g, "").replace(/\?\./g, ".");
28730
+ const seen = Object.prototype.hasOwnProperty.call(consts, name) ? consts[name] : expr;
28731
+ const visible = /[{]/.test(seen);
28732
+ for (const k of SPREAD_VERDICT_KEYS) {
28733
+ if (params[k] !== void 0) continue;
28734
+ if (visible && !new RegExp(`\\b${k}\\b`).test(seen)) continue;
28735
+ const entry = `${k} (may come from ...${expr.length > 40 ? `${expr.slice(0, 37)}\u2026` : expr})`;
28736
+ if (!out.includes(entry)) out.push(entry);
28737
+ }
28738
+ }
28739
+ return out;
28379
28740
  }
28380
28741
  function lineOfAny(text, needles) {
28381
28742
  const lower2 = text.toLowerCase();
@@ -28486,7 +28847,8 @@ async function stripeJob(loaded, e, o, root) {
28486
28847
  if (r.unresolved.length) {
28487
28848
  job.partial = true;
28488
28849
  job.unresolved = r.unresolved;
28489
- job.warnings.push(`Read statically; ${r.unresolved.length} value(s) are expressions Checkbox cannot evaluate (${r.unresolved.slice(0, 4).join(", ")}${r.unresolved.length > 4 ? ", \u2026" : ""}). Give the price in "plans" (and "plan" here), or use "module" to return the exact params.`);
28850
+ const listed = r.unresolved.some((p) => / \(may come from \.\.\./.test(p)) ? r.unresolved.filter((p) => p !== "...") : r.unresolved;
28851
+ job.warnings.push(`Read statically; ${listed.length} value(s) are expressions Checkbox cannot evaluate (${listed.slice(0, 4).join(", ")}${listed.length > 4 ? ", \u2026" : ""}). Give the price in "plans" (and "plan" here), or use "module" to return the exact params.`);
28490
28852
  }
28491
28853
  }
28492
28854
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
@@ -28584,7 +28946,8 @@ function pageJob(loaded, e, o, root) {
28584
28946
  const { merchant_name: _m, cancel_url: _c, ...own } = planTerms(config, pickPlan(config, n));
28585
28947
  return own;
28586
28948
  });
28587
- const common3 = { ...e.kind ? { page_kind: e.kind } : {}, ...Object.keys(terms2).length ? { terms: terms2 } : {}, ...every ? { plans: every } : {}, ...jurisdictionsOf(config) };
28949
+ const viaCheckout = e.kind !== "checkout" && (config.checks.stripe_checkout?.length ?? 0) > 0;
28950
+ const common3 = { ...e.kind ? { page_kind: e.kind } : {}, ...viaCheckout ? { checkout_type: "stripe_checkout" } : {}, ...Object.keys(terms2).length ? { terms: terms2 } : {}, ...every ? { plans: every } : {}, ...jurisdictionsOf(config) };
28588
28951
  if (e.source) {
28589
28952
  job.target = { file: rel(loaded, root, e.source), line: 1 };
28590
28953
  job.inputs.push(e.source);
@@ -28622,12 +28985,12 @@ function pageJob(loaded, e, o, root) {
28622
28985
  function planMoney(plan2) {
28623
28986
  if (!plan2) return void 0;
28624
28987
  const cur = (plan2.currency ?? "usd").toUpperCase();
28625
- const major = typeof plan2.amount === "number" ? plan2.amount / 100 : typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
28626
- if (major === void 0) return void 0;
28988
+ const major2 = typeof plan2.amount === "number" ? plan2.amount / 100 : typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
28989
+ if (major2 === void 0) return void 0;
28627
28990
  try {
28628
- return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major);
28991
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major2);
28629
28992
  } catch {
28630
- return `${major.toFixed(2)} ${cur}`;
28993
+ return `${major2.toFixed(2)} ${cur}`;
28631
28994
  }
28632
28995
  }
28633
28996
  function fillMergeTags(msg2, tags, config, plan2) {
@@ -28753,7 +29116,7 @@ async function planJobs(loaded, o, root) {
28753
29116
  }
28754
29117
  return jobs;
28755
29118
  }
28756
- var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
29119
+ var InputError, NOT_INSTALLED, CHILD, SPREAD_VERDICT_KEYS, jurisdictionsOf;
28757
29120
  var init_inputs = __esm({
28758
29121
  "src/ci/inputs.ts"() {
28759
29122
  "use strict";
@@ -28781,6 +29144,7 @@ if (typeof v === 'function') v = await v(...JSON.parse(argsJson));
28781
29144
  process.stdout.write('\\n__CHECKBOX_JSON__' + JSON.stringify(v === undefined ? null : v) + '\\n');
28782
29145
  process.exit(0);
28783
29146
  `;
29147
+ SPREAD_VERDICT_KEYS = ["mode", "consent_collection", "custom_text", "subscription_data", "discounts"];
28784
29148
  jurisdictionsOf = (config) => config.jurisdictions ? { jurisdictions: config.jurisdictions } : {};
28785
29149
  }
28786
29150
  });
@@ -28899,6 +29263,7 @@ var init_src3 = __esm({
28899
29263
  init_checkout_plans();
28900
29264
  init_notices_on();
28901
29265
  init_stripe_account();
29266
+ init_intro_terms();
28902
29267
  CATALOG2 = [...CATALOG];
28903
29268
  }
28904
29269
  });
@@ -29312,6 +29677,17 @@ function operationFor2(kind) {
29312
29677
  function onlineEngine(client) {
29313
29678
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
29314
29679
  }
29680
+ function withStripeNotChecked(run2, strict) {
29681
+ const strictFailed = strict && run2.exit_code === EXIT.ok;
29682
+ const exit = strictFailed ? EXIT.needs_review : run2.exit_code;
29683
+ return {
29684
+ ...run2,
29685
+ offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE,
29686
+ stripe_not_checked: { reason: STRIPE_OBJECTS_OFFLINE_NOTICE, secret: "CHECKBOX_SECRET_KEY", workflow_diff: STRIPE_ONLINE_WORKFLOW_DIFF, strict_failed: strictFailed },
29687
+ exit_code: exit,
29688
+ result: strictFailed ? `failed: Stripe was NOT checked (an offline run on a trial, price or interval change) and --strict is on (exit ${exit})` : resultOf(run2.status, exit, strict)
29689
+ };
29690
+ }
29315
29691
  function resultOf(status, exit, strict) {
29316
29692
  if (exit === 0) return status === "needs_review" ? "passed with items to review (they do not fail this run)" : "passed";
29317
29693
  if (status === "needs_review") return `failed: items need review and ${strict ? "--strict is on" : 'a "strict" entry or a blocking check makes them fail the run'} (exit ${exit})`;
@@ -29322,7 +29698,31 @@ function resultOf(status, exit, strict) {
29322
29698
  function ciVocabulary(text) {
29323
29699
  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(/to confirm, check the email again with sample values in their place \(keep the merge tags in your code\)\./g, `give each tag its value under "merge_tags" in checkbox.json (on this entry, or top level for every email), e.g. "merge_tags": { "price": "plan.price", "interval": "plan.interval" }: Checkbox fills them from the entry's plan before checking, and your template keeps its tags.`).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).');
29324
29700
  }
29325
- function pricingPageFixes(fixes) {
29701
+ function pricingPageFixes(fixes, replacements = []) {
29702
+ return concreteCardFixes(pricingPageFixesRaw(fixes), replacements);
29703
+ }
29704
+ function concreteCardFixes(fixes, replacements) {
29705
+ if (!replacements.length) return fixes;
29706
+ const lineFor = (card) => {
29707
+ const name = card.split(", ")[0].trim().toLowerCase();
29708
+ const r = replacements.find((x) => x.plan.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase() === name);
29709
+ return r?.short_disclosure_text || r?.disclosure_text || void 0;
29710
+ };
29711
+ const covered = /* @__PURE__ */ new Set();
29712
+ const out = fixes.map((f) => {
29713
+ const m = /^Plan card “([^”]+)”: /.exec(f);
29714
+ if (!m || !/"<price> every <interval>[^"]*"/.test(f)) return f;
29715
+ const line2 = lineFor(m[1]);
29716
+ if (!line2) return f;
29717
+ if (/cancel anytime online/i.test(line2)) covered.add(m[1]);
29718
+ return f.replace(/"<price> every <interval>[^"]*"/, `"${line2}"`);
29719
+ });
29720
+ return out.filter((f) => {
29721
+ const m = /^Plan card “([^”]+)”: Add "Cancel anytime online" to the renewal line/.exec(f);
29722
+ return !(m && covered.has(m[1]));
29723
+ });
29724
+ }
29725
+ function pricingPageFixesRaw(fixes) {
29326
29726
  const reworded = fixes.map(
29327
29727
  (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")
29328
29728
  );
@@ -29371,8 +29771,10 @@ function fixesOf(r, onlyNeedsFix = false) {
29371
29771
  const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
29372
29772
  if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
29373
29773
  cards = cards.filter((x) => !plain.includes(x));
29374
- const standOut = `Plan card ${plain.map((x) => `\u201C${x}\u201D`).join(", ")}: ${STAND_OUT_DECISION}`;
29375
- if (!onlyNeedsFix && !out.includes(standOut)) out.push(standOut);
29774
+ for (const x of cardClauses(c.explanation).filter((y) => plain.includes(y.card))) {
29775
+ const standOut = `Plan card \u201C${x.card}\u201D: ${setOffFix(x.clause)}`;
29776
+ if (!out.includes(standOut)) out.push(standOut);
29777
+ }
29376
29778
  }
29377
29779
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
29378
29780
  if (text && !out.includes(text)) out.push(text);
@@ -29407,17 +29809,22 @@ function correctedOf(type, r, partial = false, placeholders, pricingPage = false
29407
29809
  if (type === "stripe_checkout" && r.correction?.patch?.length) {
29408
29810
  return { format: "json", title: `Corrected params${r.correction.verification?.passes ? " (verified by the rules engine)" : ""}${placeholders ? "; keep your own values where it says {{\u2026}}" : ""}`, body: JSON.stringify(withPlaceholders(r.correction.corrected_params, placeholders), null, 2) };
29409
29811
  }
29410
- const perPlan = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
29411
- if (type === "page" && perPlan.length >= 2 && ciStatusOf(r) !== "pass") {
29812
+ const allPlans = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
29813
+ if (type === "page" && allPlans.length >= 2 && ciStatusOf(r) !== "pass") {
29814
+ const named = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status))).flatMap((c) => failingCards(c.explanation)).map((x) => x.split(", ")[0].trim().toLowerCase());
29815
+ const failing = allPlans.filter((p) => named.includes(p.plan.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase()));
29816
+ const perPlan = failing.length ? failing : allPlans;
29412
29817
  const all = perPlan.every((p) => p.verified);
29413
29818
  return {
29414
29819
  format: "text",
29415
- title: `Suggested replacement per plan, each directly above its own plan's button${all ? " (each passes the checks, verified by the rules engine)" : ""}`,
29820
+ title: perPlan.length === 1 ? `Suggested replacement for ${perPlan[0].plan.replace(/\s*\([^)]*\)\s*$/, "")}, directly above its button${all ? " (passes the checks, verified by the rules engine)" : ""}` : `Suggested replacement per plan, each directly above its own plan's button${all ? " (each passes the checks, verified by the rules engine)" : ""}`,
29416
29821
  // Round 32 (pilot 31, ci-pr-agent): a pricing page links to the checkout, where consent is asked (Stripe Checkout
29417
29822
  // shows its own checkbox), so no consent checkbox label is suggested for it.
29418
- body: perPlan.map((p) => `${p.plan}:
29823
+ body: `${perPlan.map((p) => `${p.plan}:
29419
29824
  ${p.disclosure_text}${p.consent_label && !pricingPage ? `
29420
- Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
29825
+ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")}${perPlan.some((p) => p.html && BOXED_HTML.test(p.html)) ? `
29826
+
29827
+ ${BOXED_LINE}` : ""}`
29421
29828
  };
29422
29829
  }
29423
29830
  if ((type === "disclosure" || type === "page") && r.replacement?.disclosure_text && ciStatusOf(r) !== "pass") {
@@ -29429,7 +29836,9 @@ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
29429
29836
 
29430
29837
  Fuller version: ${r.replacement.disclosure_text}` : r.replacement.disclosure_text}${r.replacement.consent_label ? `
29431
29838
 
29432
- Consent checkbox label: ${r.replacement.consent_label}` : ""}`
29839
+ Consent checkbox label: ${r.replacement.consent_label}` : ""}${r.replacement.html && BOXED_HTML.test(r.replacement.html) ? `
29840
+
29841
+ ${BOXED_LINE}` : ""}`
29433
29842
  };
29434
29843
  }
29435
29844
  if (type === "email" && r.template && ciStatusOf(r) !== "pass" && r.verdict !== "not_applicable") {
@@ -29505,6 +29914,33 @@ function unverifiedOffer(r) {
29505
29914
  if (coupon && open2.length) return coupon.message;
29506
29915
  return void 0;
29507
29916
  }
29917
+ function consentOnStripeCovered(report) {
29918
+ const c = (report.checks ?? []).find((x) => x.check_id === "signup.consent.affirmative" && x.status === "needs_review" && !(x.compliance_status && OK_STATUSES.has(x.compliance_status)));
29919
+ if (!c) return report;
29920
+ const was = c.compliance_status;
29921
+ const checks = (report.checks ?? []).map((x) => x === c ? { ...x, status: "not_applicable", compliance_status: "not_applicable", explanation: CONSENT_COVERED, fix: CONSENT_COVERED, compliance: { ...x.compliance ?? {}, fix: null, question: null } } : x);
29922
+ const compliance = report.compliance ?? {};
29923
+ const counts = { ...compliance.counts ?? {} };
29924
+ if (was && typeof counts[was] === "number" && counts[was] > 0) counts[was] = counts[was] - 1;
29925
+ const open2 = OPEN_COUNTS.some((k) => (counts[k] ?? 0) > 0) || checks.some((x) => x.status === "fail" || x.status === "needs_review" && !(x.compliance_status && OK_STATUSES.has(x.compliance_status)));
29926
+ if (!open2 && report.verdict !== "fail") {
29927
+ const pending = counts.pending_live_check ?? 0;
29928
+ return { ...report, checks, verdict: "pass", compliance_status: pending ? "pending_live_check" : "compliant", compliance: { ...compliance, counts, status: pending ? "pending_live_check" : "compliant", label: pending ? `Checks pass \xB7 ${pending} live check${pending === 1 ? "" : "s"} pending` : "Checks pass" } };
29929
+ }
29930
+ const words = {
29931
+ not_checked: [/(\d+) not checked yet/, (n) => `${n} not checked yet`],
29932
+ your_call: [/(\d+) decisions? for you/, (n) => `${n} decision${n === 1 ? "" : "s"} for you`]
29933
+ };
29934
+ let label = compliance.label ?? "";
29935
+ const w = was ? words[was] : void 0;
29936
+ if (w) label = label.replace(w[0], (_m, n) => Number(n) > 1 ? w[1](Number(n) - 1) : "").replace(/^ · | · (?= · )| · $/g, "").replace(/ · · /g, " \xB7 ");
29937
+ return { ...report, checks, compliance: { ...compliance, counts, label } };
29938
+ }
29939
+ function sitePlansDifferInCi(message) {
29940
+ const m = /\(you sent (.+?); (site_\w+) has (.+?)\)(?:, then check again|\. This result is judged)/.exec(message);
29941
+ if (!m) return message;
29942
+ return `The site's plans in Checkbox (${m[3]}) are older than checkbox.json (${m[1]}). This run checks against checkbox.json, what this code sells. Once this change is deployed, update the site's plans (updateSetupSession plans[], or \`checkbox init --plan \u2026\`) so the site's own checks and its compliance plan use them; until then the plan's tasks may quote the old terms.`;
29943
+ }
29508
29944
  async function runJob(job, engine, config, opts = {}) {
29509
29945
  const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
29510
29946
  if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
@@ -29525,11 +29961,21 @@ async function runJob(job, engine, config, opts = {}) {
29525
29961
  }
29526
29962
  const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
29527
29963
  for (const w of report.warnings ?? []) {
29528
- const text = ciVocabulary(w.message);
29964
+ if (w.code === "price_read") {
29965
+ Object.assign(base, { stripe: [...base.stripe ?? [], w.message] });
29966
+ continue;
29967
+ }
29968
+ const text = w.code === "site_plans_differ" ? sitePlansDifferInCi(w.message) : ciVocabulary(w.message);
29529
29969
  base.warnings.push(text);
29530
29970
  if (ALERT_CODES.has(w.code)) alerts.push(text);
29531
29971
  }
29532
29972
  if (alerts.length) Object.assign(base, { alerts });
29973
+ const onlyPlansDiffer = (report.warnings ?? []).some((w) => w.code === "site_plans_differ") && report.verdict !== "fail" && report.compliance_status !== "needs_fix" && (report.checks ?? []).length > 0 && (report.checks ?? []).every((c) => c.status === "pass" || c.status === "not_applicable" || !!c.compliance_status && OK_STATUSES.has(c.compliance_status));
29974
+ if (onlyPlansDiffer) {
29975
+ const pending = Number(report.compliance?.counts?.pending_live_check) || 0;
29976
+ report = { ...report, verdict: "pass", compliance_status: pending ? "pending_live_check" : "compliant", compliance: { ...report.compliance ?? {}, label: pending ? `Checks pass \xB7 ${pending} live check${pending === 1 ? "" : "s"} pending` : "Checks pass" } };
29977
+ }
29978
+ if (job.type === "page" && job.body?.checkout_type === "stripe_checkout") report = consentOnStripeCovered(report);
29533
29979
  const status = ciStatusOf(report);
29534
29980
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
29535
29981
  let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
@@ -29540,11 +29986,14 @@ async function runJob(job, engine, config, opts = {}) {
29540
29986
  fixes.unshift(`Checkbox cannot read the terms behind ${tags.join(", ")}. Add "merge_tags": { ${sample.join(", ")} } to this entry in checkbox.json (plan.price, plan.interval, \u2026 take the entry's plan), then run checkbox ci again; no render module is needed.`);
29541
29987
  }
29542
29988
  const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
29543
- if (pricingPage) fixes = pricingPageFixes(fixes);
29544
- const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
29545
- 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)));
29989
+ const cardLines = (report.plan_replacements ?? []).filter((p) => p.disclosure_text);
29990
+ if (pricingPage) fixes = pricingPageFixes(fixes, cardLines);
29991
+ const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b|^mode\b/.test(p));
29992
+ const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review" || status === "not_applicable" && unreadText.some((p) => /^mode\b/.test(p)) || status === "pass" && unreadText.some((p) => /^(?:custom_text|consent_collection|discounts|subscription_data(?:\.trial| \(may come from))/.test(p)));
29993
+ const spreadOnly = unreadText.length > 0 && unreadText.every((p) => / \(may come from \.\.\./.test(p));
29546
29994
  if (unread) {
29547
- fixes.splice(0, fixes.length, `${unreadText.slice(0, 3).join(", ")} ${unreadText.length === 1 ? "is an expression" : "are expressions"} Checkbox cannot read statically (e.g. a ternary or PLANS.monthly.*), so the checks that depend on ${unreadText.length === 1 ? "it were" : "them were"} not checked (no fix is given for a value Checkbox did not read). Use "module" (a function returning the exact params for each plan) or "file" instead of "source".`);
29995
+ const what = spreadOnly ? `${unreadText.slice(0, 3).map((p) => p.replace(/ \((may come from [^)]*)\)$/, " $1")).join(", ")}: Checkbox does not follow a spread statically` : `${unreadText.slice(0, 3).join(", ")} ${unreadText.length === 1 ? "is an expression" : "are expressions"} Checkbox cannot read statically (e.g. a ternary or PLANS.monthly.*)`;
29996
+ fixes.splice(0, fixes.length, `${what}, so the checks that depend on ${unreadText.length === 1 ? "it were" : "them were"} not checked (no fix is given for a value Checkbox did not read). Use "module" (a function returning the exact params for each plan) or "file" instead of "source"${spreadOnly ? ", or write these keys in the params literal" : ""}.`);
29548
29997
  }
29549
29998
  const literal = [];
29550
29999
  if (unread) {
@@ -29595,7 +30044,7 @@ async function runJob(job, engine, config, opts = {}) {
29595
30044
  ...base,
29596
30045
  status: finalStatus,
29597
30046
  ...blocking ? { blocking: true } : {},
29598
- 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),
30047
+ label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${spreadOnly ? unreadText[0].replace(/ \((may come from [^)]*)\)$/, " $1") : `${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), cardLines) : void 0),
29599
30048
  ...fixes[0] ? { fix: fixes[0] } : {},
29600
30049
  fixes,
29601
30050
  ...corrected ? { corrected } : {},
@@ -29667,7 +30116,7 @@ async function runCi(jobs, engine, opts) {
29667
30116
  result: resultOf(status, exit, opts.strict)
29668
30117
  };
29669
30118
  }
29670
- var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, CI_DISCLAIMER, RANK2, OK_STATUSES, STAND_OUT_DECISION, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
30119
+ var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, STRIPE_ONLINE_WORKFLOW_DIFF, CI_DISCLAIMER, RANK2, OK_STATUSES, BOXED_HTML, BOXED_LINE, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, CONSENT_COVERED, OPEN_COUNTS, ALERT_CODES;
29671
30120
  var init_runner = __esm({
29672
30121
  "src/ci/runner.ts"() {
29673
30122
  "use strict";
@@ -29676,6 +30125,7 @@ var init_runner = __esm({
29676
30125
  init_src();
29677
30126
  init_types2();
29678
30127
  init_stripe_verdict();
30128
+ init_set_off_fix();
29679
30129
  init_config3();
29680
30130
  ENGINE_KIND = {
29681
30131
  stripe_checkout: "stripe_checkout_session",
@@ -29684,11 +30134,24 @@ var init_runner = __esm({
29684
30134
  email: "email",
29685
30135
  notice_schedule: "notice_schedule"
29686
30136
  };
29687
- STRIPE_OBJECTS_OFFLINE_NOTICE = "Stripe objects (Payment Links, and the trial or price set on a Stripe Price) are not checked offline: this change touches a trial, price or interval, so run checkbox ci --online (CHECKBOX_SECRET_KEY, with Stripe connected on the site) to check that Stripe sells the same terms.";
30137
+ STRIPE_OBJECTS_OFFLINE_NOTICE = "Stripe NOT checked: this change touches a trial, price or interval, and this run was offline, so Checkbox did not check that Stripe (the Price, Payment Links) sells the same terms. Run checkbox ci --online with CHECKBOX_SECRET_KEY set (and Stripe connected on the site in Checkbox). In GitHub Actions: add the repository secret CHECKBOX_SECRET_KEY and the --online args shown below.";
30138
+ STRIPE_ONLINE_WORKFLOW_DIFF = [
30139
+ "--- a/.github/workflows/checkbox.yml",
30140
+ "+++ b/.github/workflows/checkbox.yml",
30141
+ "@@",
30142
+ " - uses: checkbox-site/checkbox@v1",
30143
+ "- # with:",
30144
+ "- # strict: true # also fail when a check needs review",
30145
+ "+ with:",
30146
+ "+ args: --online",
30147
+ "+ env:",
30148
+ "+ CHECKBOX_SECRET_KEY: ${{ secrets.CHECKBOX_SECRET_KEY }}"
30149
+ ].join("\n");
29688
30150
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
29689
30151
  RANK2 = { fail: 0, needs_review: 1 };
29690
30152
  OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
29691
- STAND_OUT_DECISION = "Decision for you (not a fix): its renewal line is there and legible but looks like the text around it. California asks for renewal terms that stand out, so consider larger type, bold (weight 600 or more), a contrasting colour, or a border.";
30153
+ BOXED_HTML = /border:1px solid currentColor/;
30154
+ BOXED_LINE = 'Style: the text next to the buttons is already bold, so a semibold line cannot stand out. Put each line in a border (style="border:1px solid currentColor;border-radius:6px;padding:8px 12px"), or make it 15% larger than the text around it: Checkbox measures both.';
29692
30155
  NOTICE_TO_EMAIL = {
29693
30156
  acknowledgment: "acknowledgment",
29694
30157
  trial_ending: "trial_reminder",
@@ -29707,6 +30170,8 @@ var init_runner = __esm({
29707
30170
  cancellation_confirmation: "cancellation confirmation"
29708
30171
  };
29709
30172
  DEFAULT_LABEL = { pass: "Checks pass", fail: "Fixes needed", needs_review: "Needs review", not_applicable: "Not required for this plan" };
30173
+ CONSENT_COVERED = "Consent is collected on Stripe Checkout, not on this page: this run checks it in checkbox.json's stripe_checkout entries (consent_collection.terms_of_service and the renewal terms in custom_text). No checkbox is needed on this page.";
30174
+ OPEN_COUNTS = ["needs_fix", "not_set_up", "your_call", "not_checked"];
29710
30175
  ALERT_CODES = /* @__PURE__ */ new Set(["coupon_unverified", "coupon_read", "merchant_trial_ignored", "merchant_price_ignored", "price_not_in_site_plans", "price_from_setup_plan", "plan_terms_conflict", "trial_mismatch", "intro_mismatch"]);
29711
30176
  }
29712
30177
  });
@@ -29740,9 +30205,13 @@ function renderHuman(run2, c, opts = {}) {
29740
30205
  lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
29741
30206
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
29742
30207
  if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
29743
- if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(/^Plan card [^:]+: Decision for you\b/.test(f) ? ` decision: ${wrap(f.replace(/Decision for you \(not a fix\): /, ""), 110, " ")}` : ` fix: ${wrap(f, 110, " ")}`);
30208
+ const restore = restoreStylingLine(x);
30209
+ if (restore) lines.push(` fix: ${wrap(restore, 110, " ")}`);
30210
+ for (const f of x.fixes.slice(1, opts.verbose ? void 0 : 4)) lines.push(/^Plan card [^:]+: Decision for you\b/.test(f) ? ` decision: ${wrap(f.replace(/Decision for you \(not a fix\): /, ""), 110, " ")}` : ` fix: ${wrap(f, 110, " ")}`);
30211
+ if (!opts.verbose && x.fixes.length > 4) lines.push(c.dim(` (+${x.fixes.length - 4} more with --verbose)`));
29744
30212
  const alerts = x.alerts ?? [];
29745
30213
  for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
30214
+ for (const st of x.stripe ?? []) lines.push(` stripe: ${wrap(st, 110, " ")}`);
29746
30215
  for (const w of x.warnings.filter((w2) => !alerts.includes(w2) && !PRICE_UNREAD.test(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
29747
30216
  for (const w of x.warnings) {
29748
30217
  const id = PRICE_UNREAD.exec(w)?.[1];
@@ -29753,12 +30222,35 @@ function renderHuman(run2, c, opts = {}) {
29753
30222
  if (unreadPrices.length) lines.push("", c.dim(wrap(`note: ${unreadPrices.length === 1 ? `Price ${unreadPrices[0]} was` : `Prices ${unreadPrices.join(", ")} were`} not read (no connected Stripe account has ${unreadPrices.length === 1 ? "it" : "them"}), so the amounts checked come from "plans" in checkbox.json. To check them against Stripe, connect Stripe on the site in Checkbox and run checkbox ci --online.`, 110, " ")));
29754
30223
  const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
29755
30224
  if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
29756
- if (run2.offline_notice) lines.push("", c.yellow(wrap(`Not checked offline: ${run2.offline_notice}`, 110, " ")));
30225
+ if (run2.stripe_not_checked) {
30226
+ lines.push("", (run2.stripe_not_checked.strict_failed ? c.red : c.yellow)(wrap(run2.stripe_not_checked.reason, 110, "")));
30227
+ lines.push(c.dim("Workflow change (.github/workflows/checkbox.yml), plus the repository secret CHECKBOX_SECRET_KEY:"), ...run2.stripe_not_checked.workflow_diff.split("\n").map((l) => c.dim(` ${l}`)));
30228
+ } else if (run2.offline_notice) lines.push("", c.yellow(wrap(run2.offline_notice, 110, " ")));
29757
30229
  lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}${run2.result ? ` (${run2.result.replace(/ \(exit \d+\)$/, "")})` : ""}`);
29758
30230
  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."));
29759
30231
  lines.push(c.dim(run2.disclaimer));
29760
30232
  return lines;
29761
30233
  }
30234
+ function removedStyling(removed) {
30235
+ const parts = [];
30236
+ for (const l of removed) {
30237
+ const jsx = /\bstyle=\{\{([^}]*)\}\}/.exec(l);
30238
+ if (jsx) parts.push(`style={{${jsx[1]}}}`);
30239
+ const html = /\bstyle=(["'])([^"']*)\1/.exec(l);
30240
+ if (html && html[2].trim()) parts.push(`style="${html[2].trim()}"`);
30241
+ const cls = /\b(className|class)=(["'])([^"']*)\2/.exec(l);
30242
+ if (cls && cls[3].trim()) parts.push(`${cls[1]}="${cls[3].trim()}"`);
30243
+ const tag = /<(strong|b)[\s>]/i.exec(l);
30244
+ if (tag) parts.push(`<${tag[1].toLowerCase()}>`);
30245
+ if (parts.length) break;
30246
+ }
30247
+ return parts.length ? parts.join(" ") : void 0;
30248
+ }
30249
+ function restoreStylingLine(x) {
30250
+ if (x.type !== "page" && x.type !== "disclosure") return void 0;
30251
+ const style2 = x.anchored?.removed?.length ? removedStyling(x.anchored.removed) : void 0;
30252
+ return style2 ? `Restore the line with its styling (${style2}), not as plain text: Checkbox checks that the renewal terms stand out next to the button, not only that they are there.` : void 0;
30253
+ }
29762
30254
  function annotations(run2) {
29763
30255
  const out = [];
29764
30256
  for (const x of run2.checks) {
@@ -29766,8 +30258,11 @@ function annotations(run2) {
29766
30258
  const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
29767
30259
  if (!level) continue;
29768
30260
  const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
30261
+ const removed = x.anchored?.removed?.length ? `This change removed ${x.anchored.removed.length === 1 ? "a line" : `${x.anchored.removed.length} lines`} here: ${x.anchored.removed.slice(0, 2).map((l) => `"${l.trim().slice(0, 120)}"`).join(", ")}${x.anchored.removed.length > 2 ? ", \u2026" : ""}` : "";
30262
+ const corrected = !x.corrected ? "" : x.corrected.format === "text" && x.corrected.body.length <= 1500 ? `${x.corrected.title}:
30263
+ ${x.corrected.body}` : `${x.corrected.title}: see the job summary.`;
29769
30264
  const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
29770
- ${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");
30265
+ ${x.fix}` : `Could not run: ${x.message ?? ""}` : [removed, restoreStylingLine(x) ?? "", x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), corrected].filter(Boolean).join("\n");
29771
30266
  out.push(`::${level} ${props.join(",")}::${escData(body)}`);
29772
30267
  }
29773
30268
  if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
@@ -29780,16 +30275,19 @@ function stepSummary(run2) {
29780
30275
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
29781
30276
  if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
29782
30277
  if (run2.result) lines.push(`Result: ${run2.result}.`, "");
29783
- if (run2.offline_notice) lines.push(`\u26A0\uFE0F ${run2.offline_notice}`, "");
30278
+ if (run2.stripe_not_checked) {
30279
+ lines.push(`${run2.stripe_not_checked.strict_failed ? "\u274C" : "\u26A0\uFE0F"} **Stripe NOT checked.** ${run2.stripe_not_checked.reason.replace(/^Stripe NOT checked: /, "")}`, "");
30280
+ lines.push("<details><summary>Workflow change that checks Stripe (plus the repository secret <code>CHECKBOX_SECRET_KEY</code>)</summary>", "", "```diff", run2.stripe_not_checked.workflow_diff, "```", "", "</details>", "");
30281
+ } else if (run2.offline_notice) lines.push(`\u26A0\uFE0F ${run2.offline_notice}`, "");
29784
30282
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
29785
30283
  for (const x of run2.checks) {
29786
30284
  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 : "";
29787
- const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
30285
+ const fix = [restoreStylingLine(x) ?? "", fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`), ...(x.stripe ?? []).map((st) => `Stripe: ${st}`)].filter(Boolean).join("\n");
29788
30286
  lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
29789
30287
  }
29790
30288
  const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
29791
30289
  for (const x of detailed) {
29792
- lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
30290
+ lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.charAt(0).toLowerCase() + x.corrected.title.slice(1) : `${x.fixes.length} fixes`)}</summary>`, "");
29793
30291
  if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
29794
30292
  if (x.corrected) {
29795
30293
  const fence = x.corrected.body.includes("```") ? "````" : "```";
@@ -29862,6 +30360,7 @@ function renderPlan(p, c, opts = {}) {
29862
30360
  else if (t.status === "manual" && t.question) lines.push(` ${c.yellow("ask the human:")} ${t.question.text} ${c.dim(`\u2192 checkbox answer ${t.id} <${t.question.options?.join("|") ?? "yes|no"}>`)}`);
29863
30361
  else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
29864
30362
  else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
30363
+ else if ((t.status === "open" || t.status === "in_progress") && opts.stale?.(t)) lines.push(` ${c.yellow("stale, re-verify after the site's plans are updated:")} ${opts.stale(t)}`);
29865
30364
  else if (t.status === "open" || t.status === "in_progress") lines.push(c.dim(` ${t.where.file_hint ?? t.where.url ?? t.where.stripe_object ?? t.where.kind} \xB7 ${t.change.type} \xB7 checkbox fix ${t.id}`));
29866
30365
  }
29867
30366
  const hidden = p.tasks.length - tasks.length;
@@ -29873,7 +30372,7 @@ function statusColor(s2, c) {
29873
30372
  return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
29874
30373
  }
29875
30374
  function renderVerification(v, c) {
29876
- const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : c.yellow("pending");
30375
+ const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : v.status === "needs_review" ? c.yellow("needs review (nothing failed; a check is still undecided: see next step)") : v.status === "superseded" ? c.yellow("superseded (newer evidence settled the task)") : c.yellow("pending");
29877
30376
  const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
29878
30377
  const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
29879
30378
  if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
@@ -29901,7 +30400,7 @@ function workflowYaml(needs = {}) {
29901
30400
  const node = !!(needs.install || needs.build);
29902
30401
  const steps = [" - uses: actions/checkout@v4"];
29903
30402
  if (node) {
29904
- steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 20");
30403
+ steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 22");
29905
30404
  if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
29906
30405
  if (needs.build) steps.push(` # ${needs.why?.build ?? 'checkbox.json "pages" read built HTML, which a fresh clone does not have.'}`, ` - run: ${needs.build}`);
29907
30406
  } else {
@@ -29997,9 +30496,11 @@ var init_workflow = __esm({
29997
30496
  // src/commands/ci/index.ts
29998
30497
  var ci_exports = {};
29999
30498
  __export(ci_exports, {
30499
+ anchorToChanges: () => anchorToChanges,
30000
30500
  attachSiteForCoupons: () => attachSiteForCoupons,
30001
30501
  couponIdsIn: () => couponIdsIn,
30002
30502
  default: () => ci_default,
30503
+ diffHunks: () => diffHunks,
30003
30504
  siteFactsFor: () => siteFactsFor,
30004
30505
  termsChanged: () => termsChanged,
30005
30506
  touchesConfig: () => touchesConfig,
@@ -30019,6 +30520,38 @@ function termsChanged(cwd, base, jobs) {
30019
30520
  return false;
30020
30521
  }
30021
30522
  }
30523
+ function diffHunks(cwd, base, file) {
30524
+ let out;
30525
+ try {
30526
+ out = execFileSync2("git", ["diff", "-U0", "--no-color", "--no-ext-diff", `${base}...HEAD`, "--", file], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
30527
+ } catch {
30528
+ return [];
30529
+ }
30530
+ const hunks = [];
30531
+ for (const l of out.split("\n")) {
30532
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(l);
30533
+ if (h) hunks.push({ start: Number(h[1]), count: h[2] === void 0 ? 1 : Number(h[2]), removed: [] });
30534
+ else if (hunks.length && l.startsWith("-") && !l.startsWith("---")) hunks[hunks.length - 1].removed.push(l.slice(1));
30535
+ }
30536
+ return hunks;
30537
+ }
30538
+ function anchorToChanges(checks, cwd, workspace, base) {
30539
+ const cache2 = /* @__PURE__ */ new Map();
30540
+ for (const x of checks) {
30541
+ if (!(x.status === "fail" || x.status === "needs_review" || x.status === "error") || !x.target.line || x.failure) continue;
30542
+ const file = resolve7(workspace, x.target.file);
30543
+ if (!cache2.has(file)) cache2.set(file, diffHunks(cwd, base, file));
30544
+ const hunks = cache2.get(file);
30545
+ if (!hunks.length) continue;
30546
+ const line2 = x.target.line;
30547
+ if (hunks.some((h) => h.count > 0 && line2 >= h.start && line2 < h.start + h.count)) continue;
30548
+ const dist = (h) => h.count > 0 ? Math.min(Math.abs(line2 - h.start), Math.abs(line2 - (h.start + h.count - 1))) : Math.abs(line2 - h.start);
30549
+ const near2 = [...hunks].sort((a, b) => dist(a) - dist(b))[0];
30550
+ if (dist(near2) > 40) continue;
30551
+ x.anchored = { from: line2, ...near2.removed.length && near2.count === 0 ? { removed: near2.removed } : {} };
30552
+ x.target = { ...x.target, line: Math.max(1, near2.count > 0 ? near2.start : near2.start + 1) };
30553
+ }
30554
+ }
30022
30555
  function gitChanged(cwd, base) {
30023
30556
  try {
30024
30557
  const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -30360,16 +30893,18 @@ ${message}
30360
30893
  const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
30361
30894
  const run0 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra, ...notices ? { notices, noticesTarget: { file: relative5(workspace, path).split("\\").join("/") || configName, line: noticesLine } } : {} });
30362
30895
  const baseRef = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
30363
- const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? { ...run0, offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE } : run0;
30896
+ if (baseRef) anchorToChanges(run0.checks, ctx.cwd, workspace, baseRef);
30897
+ const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? withStripeNotChecked(run0, strict) : run0;
30364
30898
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
30365
30899
  if (gha) {
30366
30900
  for (const a of annotations(run2)) emit(a);
30367
30901
  writeGithubFile(ctx.env.GITHUB_STEP_SUMMARY, stepSummary(run2));
30368
- if (run2.offline_notice) emit(`::notice title=Checkbox%3A Stripe not checked offline::${run2.offline_notice}`);
30902
+ if (run2.stripe_not_checked) emit(`::${run2.stripe_not_checked.strict_failed ? "error" : "warning"} title=Checkbox%3A Stripe NOT checked::${`${run2.stripe_not_checked.reason}
30903
+ ${run2.stripe_not_checked.workflow_diff}`.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")}`);
30369
30904
  writeGithubFile(ctx.env.GITHUB_OUTPUT, `status=${run2.status}
30370
30905
  exit_code=${run2.exit_code}
30371
30906
  result=${run2.result}
30372
- `);
30907
+ ${run2.stripe_not_checked ? "stripe_checked=false\n" : ""}`);
30373
30908
  }
30374
30909
  return run2.exit_code;
30375
30910
  }
@@ -30558,12 +31093,20 @@ function diffLines(a, b) {
30558
31093
  ops.push(...a.slice(a.length - suf).map((line2) => ({ t: " ", line: line2 })));
30559
31094
  return ops;
30560
31095
  }
31096
+ function gitLines(text) {
31097
+ if (text === "") return [];
31098
+ const lines = text.split("\n");
31099
+ if (text.endsWith("\n")) lines.pop();
31100
+ else lines[lines.length - 1] += NO_EOL;
31101
+ return lines;
31102
+ }
30561
31103
  function unifiedDiff(before, after, path, context = 3) {
30562
31104
  if (before === after) return "";
30563
- const a = before.split("\n");
30564
- const b = after.split("\n");
31105
+ const a = gitLines(before);
31106
+ const b = gitLines(after);
30565
31107
  const ops = diffLines(a, b);
30566
31108
  const out = [`--- a/${path}`, `+++ b/${path}`];
31109
+ const show2 = (o) => o.line.endsWith(NO_EOL) ? [`${o.t}${o.line.slice(0, -NO_EOL.length)}`, NO_EOL_MARKER] : [`${o.t}${o.line}`];
30567
31110
  let i = 0;
30568
31111
  while (i < ops.length) {
30569
31112
  if (ops[i].t === " ") {
@@ -30585,26 +31128,50 @@ function unifiedDiff(before, after, path, context = 3) {
30585
31128
  const bStart = ops.slice(0, start).filter((o) => o.t !== "-").length + 1;
30586
31129
  const aLen = slice.filter((o) => o.t !== "+").length;
30587
31130
  const bLen = slice.filter((o) => o.t !== "-").length;
30588
- out.push(`@@ -${aLen ? aStart : aStart - 1},${aLen} +${bLen ? bStart : bStart - 1},${bLen} @@`, ...slice.map((o) => `${o.t}${o.line}`));
31131
+ out.push(`@@ -${aLen ? aStart : aStart - 1},${aLen} +${bLen ? bStart : bStart - 1},${bLen} @@`, ...slice.flatMap(show2));
30589
31132
  i = stop;
30590
31133
  start = stop;
30591
31134
  }
30592
31135
  return `${out.join("\n")}
30593
31136
  `;
30594
31137
  }
30595
- var escapeRe2, VOID_TAGS;
31138
+ var escapeRe2, VOID_TAGS, NO_EOL, NO_EOL_MARKER;
30596
31139
  var init_patch = __esm({
30597
31140
  "src/commands/plan/patch.ts"() {
30598
31141
  "use strict";
30599
31142
  escapeRe2 = (s2) => s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30600
31143
  VOID_TAGS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"];
31144
+ NO_EOL = "\0no-eol";
31145
+ NO_EOL_MARKER = "\";
30601
31146
  }
30602
31147
  });
30603
31148
 
30604
31149
  // src/commands/plan/fix.ts
30605
31150
  import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
30606
31151
  import { relative as relative6, resolve as resolve8 } from "node:path";
30607
- function fixForTask(task, cwd, file) {
31152
+ function declaredPlans(cwd) {
31153
+ const path = findConfig(cwd);
31154
+ if (!path) return [];
31155
+ try {
31156
+ return loadConfig(path).config.plans ?? [];
31157
+ } catch {
31158
+ return [];
31159
+ }
31160
+ }
31161
+ function staleAgainstDeclared(content, plans) {
31162
+ if (!plans.length || !content) return void 0;
31163
+ const text = content.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
31164
+ const said = [];
31165
+ for (const m of text.matchAll(/(\d{1,3})[- ]days? free,? then \$([\d,]+(?:\.\d{1,2})?) (?:every|per|a|\/) ?(month|year|week|day)/gi)) {
31166
+ const trial = Number(m[1]);
31167
+ const price3 = Number(m[2].replace(/,/g, ""));
31168
+ const interval = m[3].toLowerCase();
31169
+ const same = plans.filter((p) => p.interval === interval && major(p) !== void 0 && Math.abs(major(p) - price3) < 5e-3);
31170
+ if (same.length && !same.some((p) => (p.trial_days ?? 0) === trial)) said.push(`it says \u201C${m[0]}\u201D, but checkbox.json declares ${same.map((p) => `"${p.name}" with ${p.trial_days ? `a ${p.trial_days}-day trial` : "no trial"}`).join(", ")}`);
31171
+ }
31172
+ return said.length ? said.join("; ") : void 0;
31173
+ }
31174
+ function fixForTask(task, cwd, file, plans = declaredPlans(cwd)) {
30608
31175
  const base = {
30609
31176
  object: "task_fix",
30610
31177
  task: task.id,
@@ -30615,6 +31182,22 @@ function fixForTask(task, cwd, file) {
30615
31182
  verify: task.verify
30616
31183
  };
30617
31184
  const verifyCmd = `checkbox verify ${task.id} --wait`;
31185
+ const stale = staleAgainstDeclared(`${task.change.content}
31186
+ ${task.change.edits.map((e) => e.content ?? "").join("\n")}`, plans);
31187
+ if (stale) {
31188
+ return {
31189
+ ...base,
31190
+ file: null,
31191
+ diff: null,
31192
+ applied: [],
31193
+ unmatched: [],
31194
+ next_action: {
31195
+ type: "refresh_plan",
31196
+ description: `Not applied: this task was built from the site's plans in Checkbox, which are older than checkbox.json (${stale}). Applying it would put the old terms back. Leave this file as it is; once this change is deployed, update the site's plans (updateSetupSession plans[], or \`checkbox init --plan \u2026\`), then run \`checkbox plan\` again for a fix built from the new plans.`,
31197
+ command: "checkbox plan"
31198
+ }
31199
+ };
31200
+ }
30618
31201
  const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
30619
31202
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
30620
31203
  const path = resolve8(cwd, candidate);
@@ -30652,14 +31235,16 @@ function fixForTask(task, cwd, file) {
30652
31235
  const type = task.change.type === "manual" ? "answer_question" : task.change.type === "needs_facts" ? "provide_facts" : "apply_change";
30653
31236
  return { ...base, file: null, diff: null, applied: [], unmatched: [], next_action: { type, description: how[task.change.type] ?? "Apply the change, then verify.", command: task.change.type === "manual" ? `checkbox answer ${task.id}` : task.change.type === "needs_facts" ? `checkbox fix ${task.id}` : verifyCmd } };
30654
31237
  }
30655
- var TASK_RE2, fix_default;
31238
+ var TASK_RE2, major, fix_default;
30656
31239
  var init_fix = __esm({
30657
31240
  "src/commands/plan/fix.ts"() {
30658
31241
  "use strict";
30659
31242
  init_args();
30660
31243
  init_types2();
31244
+ init_config3();
30661
31245
  init_patch();
30662
31246
  TASK_RE2 = /^task_[0-9a-f]{32}$/;
31247
+ major = (p) => p.price !== void 0 ? Number(p.price) : p.amount !== void 0 ? p.amount / 100 : void 0;
30663
31248
  fix_default = defineCommand({
30664
31249
  name: "fix",
30665
31250
  summary: "Show the change for a plan task (exact diff of your file for code tasks)",
@@ -30950,7 +31535,8 @@ function planEnv(d, v) {
30950
31535
  [names.secret, v.secretKey],
30951
31536
  [names.publishable, v.publishableKey],
30952
31537
  ...v.liveSecretKey ? [[names.liveSecret, v.liveSecretKey]] : [],
30953
- ...v.livePublishableKey ? [[names.livePublishable, v.livePublishableKey]] : []
31538
+ ...v.livePublishableKey ? [[names.livePublishable, v.livePublishableKey]] : [],
31539
+ ...v.apiBase ? [["CHECKBOX_API_BASE", v.apiBase]] : []
30954
31540
  ];
30955
31541
  const existing = read(d.root, d.envFile);
30956
31542
  let text = existing ?? "";
@@ -30961,7 +31547,7 @@ function planEnv(d, v) {
30961
31547
  if (re.test(text)) {
30962
31548
  if (!text.match(re)[0].endsWith(`=${val}`)) {
30963
31549
  text = text.replace(re, `${k}=${val}`);
30964
- added.push(`${k}=${mask(val)}`);
31550
+ added.push(`${k}=${k === "CHECKBOX_API_BASE" ? val : mask(val)}`);
30965
31551
  }
30966
31552
  } else missing.push([k, val]);
30967
31553
  }
@@ -30969,7 +31555,7 @@ function planEnv(d, v) {
30969
31555
  const block = ["# Checkbox (subscription compliance) \u2014 written by `checkbox init`. Secret: never commit this file.", ...missing.map(([k, val]) => `${k}=${val}`)];
30970
31556
  text = `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${block.join("\n")}
30971
31557
  `;
30972
- added.push(...missing.map(([k, val]) => `${k}=${mask(val)}`));
31558
+ added.push(...missing.map(([k, val]) => `${k}=${k === "CHECKBOX_API_BASE" ? val : mask(val)}`));
30973
31559
  }
30974
31560
  if (existing === text) return { file: d.envFile, action: "unchanged", summary: `${d.envFile} already has the Checkbox keys`, added: [] };
30975
31561
  return { file: d.envFile, action: existing === null ? "create" : "modify", summary: `Keys written to ${d.envFile} (${entries.map(([k]) => k).join(", ")})`, contents: text, added };
@@ -31095,7 +31681,8 @@ function saveState(ctx, root, patch) {
31095
31681
  }
31096
31682
  function withEvaluatedAt(c) {
31097
31683
  const t = c.evaluated;
31098
- return typeof t === "number" && t > 0 ? { evaluated_at: new Date(t * 1e3).toISOString(), ...c } : c;
31684
+ const at = typeof t === "number" && t > 0 ? new Date(t * 1e3).toISOString() : typeof c.evaluated_at === "string" && c.evaluated_at ? c.evaluated_at : null;
31685
+ return { ...c, evaluated: !!at, evaluated_at: at };
31099
31686
  }
31100
31687
  async function ask2(question) {
31101
31688
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
@@ -31270,8 +31857,8 @@ function resultOf2(status, d, session, changes, extra, apiBase) {
31270
31857
  install: session.install,
31271
31858
  stripe: { ...session.stripe, ...extra.stripe ?? {} },
31272
31859
  ...extra.live && Object.keys(extra.live).length ? { live: extra.live } : {},
31273
- // Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp): the CLI says
31274
- // when, as evaluated_at (ISO 8601), next to the API's field.
31860
+ // Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp); round 34:
31861
+ // evaluated is a boolean, evaluated_at (ISO 8601) says when.
31275
31862
  compliance: session.compliance ? withEvaluatedAt(session.compliance) : session.compliance,
31276
31863
  ...session.go_live ? { go_live: session.go_live } : {},
31277
31864
  steps: session.steps.map((s2) => ({ key: s2.key, status: s2.status, summary: s2.summary })),
@@ -31371,7 +31958,7 @@ var init_init = __esm({
31371
31958
  " --no-stripe Do not offer the Stripe test key / test subscription",
31372
31959
  " --live Also request live-mode keys and write the domain-verification file",
31373
31960
  " --no-wait Start the login, print the link and exit (run `checkbox init` again to continue)",
31374
- " --wait-seconds <n> How long to wait for the approval (default 900 on a terminal, 100 otherwise)",
31961
+ " --wait-seconds <n> How long to wait for the approval (default 900 on a terminal, 40 with --json, 100 otherwise)",
31375
31962
  " --no-open Do not open the browser",
31376
31963
  " --verify Re-check the site now (after deploying) and print the updated checklist",
31377
31964
  " --complete Finish the setup and email the human the summary (sent automatically the first time every",
@@ -31391,7 +31978,7 @@ var init_init = __esm({
31391
31978
  const dryRun = bool(flags, "dry-run") === true;
31392
31979
  const yes = bool(flags, "yes") === true || ctx.flags.json;
31393
31980
  const out = ctx.out;
31394
- const apiBase = (ctx.flags.apiBase ?? ctx.env.CHECKBOX_API_BASE ?? ctx.config.load().api_base ?? DEFAULT_API_BASE).replace(/\/+$/, "");
31981
+ const apiBase = (ctx.credentials()?.apiBase ?? ctx.flags.apiBase ?? ctx.env.CHECKBOX_API_BASE ?? ctx.config.load().api_base ?? DEFAULT_API_BASE).replace(/\/+$/, "");
31395
31982
  const d = detectProject(root, ctx.env);
31396
31983
  const state = loadState(ctx, root);
31397
31984
  const remembered = state.facts ?? {};
@@ -31500,7 +32087,7 @@ var init_init = __esm({
31500
32087
  if (bool(flags, "open") !== false) openBrowser(pending.verification_uri_complete, ctx.env);
31501
32088
  const tty = !!process.stdin.isTTY && !!process.stderr.isTTY;
31502
32089
  const waitArg = str(flags, "wait-seconds");
31503
- const waitSeconds = bool(flags, "wait") === false ? 0 : waitArg !== void 0 ? Number(waitArg) : tty ? 900 : 100;
32090
+ const waitSeconds = bool(flags, "wait") === false ? 0 : waitArg !== void 0 ? Number(waitArg) : tty ? 900 : out.json ? 40 : 100;
31504
32091
  if (!Number.isFinite(waitSeconds) || waitSeconds < 0) throw new UsageError("--wait-seconds must be a number of seconds");
31505
32092
  const polled = await pollDeviceLogin(anon, pending, waitSeconds * 1e3, ctx);
31506
32093
  if (polled.status === "timeout") {
@@ -31589,7 +32176,7 @@ var init_init = __esm({
31589
32176
  if (token) warnings.push(`This folder is deployed as it is (${d.framework}), so the secret key was not written to a file in it: it is saved in the Checkbox CLI config (\`checkbox plan\`, \`checkbox verify\` use it). Server code that calls the API should read it from your host's secret settings (CHECKBOX_SECRET_KEY).`);
31590
32177
  } else {
31591
32178
  if (token && envTracked) warnings.push(trackedEnvWarning(d.envFile));
31592
- else if (token) changes.push(planEnv(d, { secretKey: token.secret_key, publishableKey: token.publishable_key, ...token.live_secret_key ? { liveSecretKey: token.live_secret_key } : {}, ...token.live_publishable_key ? { livePublishableKey: token.live_publishable_key } : {} }));
32179
+ else if (token) changes.push(planEnv(d, { secretKey: token.secret_key, publishableKey: token.publishable_key, ...token.live_secret_key ? { liveSecretKey: token.live_secret_key } : {}, ...token.live_publishable_key ? { livePublishableKey: token.live_publishable_key } : {}, ...apiBase !== DEFAULT_API_BASE ? { apiBase } : {} }));
31593
32180
  const gi = planGitignore(d);
31594
32181
  if (gi) changes.push(gi);
31595
32182
  }
@@ -31873,6 +32460,7 @@ var init_plan = __esm({
31873
32460
  init_args();
31874
32461
  init_types2();
31875
32462
  init_lib();
32463
+ init_fix();
31876
32464
  plan_default = defineCommand({
31877
32465
  name: "plan",
31878
32466
  summary: "Show the compliance plan: ordered tasks to apply, then verify",
@@ -31893,7 +32481,15 @@ var init_plan = __esm({
31893
32481
  const client = ctx.client();
31894
32482
  const site = await resolveSite(ctx, str(flags, "site"), client);
31895
32483
  const plan2 = await client.sdk.plans.retrieve(site);
31896
- ctx.out.result(plan2, () => renderPlan(plan2, ctx.out.c, { all: !!bool(flags, "all") }));
32484
+ const declared = declaredPlans(ctx.cwd);
32485
+ const staleOf = (t) => staleAgainstDeclared(`${t.change.content}
32486
+ ${t.change.edits.map((e) => e.content ?? "").join("\n")}`, declared);
32487
+ const stale = plan2.tasks.filter((t) => (t.status === "open" || t.status === "in_progress") && staleOf(t)).map((t) => t.id);
32488
+ ctx.out.result(stale.length ? { ...plan2, stale_tasks: stale } : plan2, () => {
32489
+ const lines = renderPlan(plan2, ctx.out.c, { all: !!bool(flags, "all"), stale: staleOf });
32490
+ if (stale.length) lines.push("", ctx.out.c.yellow(`${stale.length} task(s) were built from the site's plans in Checkbox, which are older than checkbox.json: do not apply them. Once this change is deployed, update the site's plans (updateSetupSession plans[], or \`checkbox init --plan \u2026\`), then run \`checkbox plan\` again.`));
32491
+ return lines;
32492
+ });
31897
32493
  return bool(flags, "exit-code") && plan2.status !== "compliant" ? EXIT.fail : EXIT.ok;
31898
32494
  }
31899
32495
  });