@checkbox-site/cli 0.4.7 → 0.4.8

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 +449 -125
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -3295,7 +3295,7 @@ var init_operations = __esm({
3295
3295
  /**
3296
3296
  * Store a billing test-mode key.
3297
3297
  *
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.
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 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
3299
  *
3300
3300
  * @param site Unique identifier of the site (site_…).
3301
3301
  * @see POST /v1/sites/{site}/test_credentials (operation `createTestCredential`, scope `test_runs:write`)
@@ -3617,7 +3617,7 @@ var SDK_VERSION;
3617
3617
  var init_version = __esm({
3618
3618
  "../sdk-node/src/version.ts"() {
3619
3619
  "use strict";
3620
- SDK_VERSION = "0.2.5";
3620
+ SDK_VERSION = "0.2.6";
3621
3621
  }
3622
3622
  });
3623
3623
 
@@ -4757,12 +4757,16 @@ var init_stripe_ids = __esm({
4757
4757
  function stripeKeyPermissionLine(filter) {
4758
4758
  return STRIPE_KEY_PERMISSIONS.filter(filter ?? (() => true)).map((p) => `${p.resource}: ${p.access}`).join(", ");
4759
4759
  }
4760
+ function testKeyBoxName(via = "either") {
4761
+ 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)`;
4762
+ return `the box ${label} (checkbox id ${TEST_KEY_CHECKBOX_ID})`;
4763
+ }
4760
4764
  function approvalKeyAsk(opts = {}) {
4761
4765
  if (opts.stripe === false) return "";
4762
4766
  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.`;
4767
+ 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
4768
  }
4765
- var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL;
4769
+ var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL, DEVICE_TEST_KEY_LABEL, OAUTH_TEST_KEY_LABEL, TEST_KEY_CHECKBOX_ID;
4766
4770
  var init_stripe_key = __esm({
4767
4771
  "../core/src/stripe-key.ts"() {
4768
4772
  "use strict";
@@ -4779,6 +4783,9 @@ var init_stripe_key = __esm({
4779
4783
  { 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
4784
  ];
4781
4785
  STRIPE_TEST_API_KEYS_URL = "https://dashboard.stripe.com/test/apikeys";
4786
+ DEVICE_TEST_KEY_LABEL = "Let your agent store a Stripe test-mode restricted key for this project";
4787
+ OAUTH_TEST_KEY_LABEL = "Let this app store a Stripe test-mode restricted key for your sites";
4788
+ TEST_KEY_CHECKBOX_ID = "consent-stripe-test-key";
4782
4789
  }
4783
4790
  });
4784
4791
 
@@ -4786,7 +4793,7 @@ var init_stripe_key = __esm({
4786
4793
  function goLiveSteps(input = {}) {
4787
4794
  const out = [];
4788
4795
  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 });
4796
+ if (input.termsOfServiceRequired && !input.termsOfServiceUrlSet) out.push({ key: "stripe_terms_url", who: "person", text: input.termsOfServiceConditional ? GO_LIVE_TERMS_URL_STEP_CONDITIONAL : GO_LIVE_TERMS_URL_STEP, url: STRIPE_PUBLIC_DETAILS });
4790
4797
  if (!input.liveKeyConnected)
4791
4798
  out.push({
4792
4799
  key: "live_stripe_key",
@@ -4804,12 +4811,13 @@ function goLiveSteps(input = {}) {
4804
4811
  });
4805
4812
  return out;
4806
4813
  }
4807
- var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_STEPS;
4814
+ var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_TERMS_URL_STEP_CONDITIONAL, GO_LIVE_STEPS;
4808
4815
  var init_go_live = __esm({
4809
4816
  "../core/src/go-live.ts"() {
4810
4817
  "use strict";
4811
4818
  STRIPE_PUBLIC_DETAILS = "https://dashboard.stripe.com/settings/public";
4812
4819
  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.';
4820
+ 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.';
4813
4821
  GO_LIVE_STEPS = goLiveSteps().map((s2) => s2.text);
4814
4822
  }
4815
4823
  });
@@ -4853,6 +4861,13 @@ var init_secrets = __esm({
4853
4861
  }
4854
4862
  });
4855
4863
 
4864
+ // ../core/src/site-unreachable.ts
4865
+ var init_site_unreachable = __esm({
4866
+ "../core/src/site-unreachable.ts"() {
4867
+ "use strict";
4868
+ }
4869
+ });
4870
+
4856
4871
  // ../core/src/index.ts
4857
4872
  var init_src2 = __esm({
4858
4873
  "../core/src/index.ts"() {
@@ -4864,6 +4879,7 @@ var init_src2 = __esm({
4864
4879
  init_stripe_key();
4865
4880
  init_go_live();
4866
4881
  init_secrets();
4882
+ init_site_unreachable();
4867
4883
  }
4868
4884
  });
4869
4885
 
@@ -5902,7 +5918,7 @@ var init_new_york = __esm({
5902
5918
  NYC_EFFECTIVE = "2026-10-01";
5903
5919
  NYC = "6 RCNY \xA7 5-110.1";
5904
5920
  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.";
5921
+ 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
5922
  PART_W_NOTE = "Current text from L. 2025, ch. 58, Part W, effective Nov. 5, 2025.";
5907
5923
  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
5924
  NEW_YORK_REQUIREMENTS = [
@@ -6351,12 +6367,12 @@ var init_new_york = __esm({
6351
6367
  sourceUrl: NYC_RULE_URL,
6352
6368
  effectiveDate: NYC_EFFECTIVE,
6353
6369
  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.",
6370
+ 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
6371
  status: "in_force",
6356
6372
  confidence: "medium",
6357
6373
  verification: "primary",
6358
6374
  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).",
6375
+ 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
6376
  amendments: [
6361
6377
  { 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
6378
  ]
@@ -10222,11 +10238,11 @@ function findRecurringPrices(text, amounts, freqs) {
10222
10238
  let best = null;
10223
10239
  for (const f of freqs) {
10224
10240
  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;
10241
+ const gap2 = after ? f.index - (money3.index + money3.text.length) : money3.index - (f.index + f.text.length);
10242
+ if (gap2 < -1) continue;
10243
+ if (gap2 > (after ? 45 : 60)) continue;
10228
10244
  if (SENTENCE_BREAK_RE.test(between(text, money3, f))) continue;
10229
- if (!best || gap < best.gap) best = { f, gap };
10245
+ if (!best || gap2 < best.gap) best = { f, gap: gap2 };
10230
10246
  }
10231
10247
  if (best) {
10232
10248
  const start = Math.min(money3.index, best.f.index);
@@ -11505,10 +11521,10 @@ function withIntroOffers(declared, billing, offers) {
11505
11521
  }
11506
11522
  function shortPlanTerms(p) {
11507
11523
  const digits = currencyDigitsOf(p.currency);
11508
- const major = p.amount / 10 ** digits;
11524
+ const major2 = p.amount / 10 ** digits;
11509
11525
  const cur = (p.currency || "usd").toLowerCase();
11510
11526
  const sym = { usd: "$", eur: "\u20AC", gbp: "\xA3", jpy: "\xA5" }[cur];
11511
- const num3 = Number.isInteger(major) ? String(major) : major.toFixed(digits);
11527
+ const num3 = Number.isInteger(major2) ? String(major2) : major2.toFixed(digits);
11512
11528
  const money3 = sym ? `${sym}${num3}` : `${num3} ${cur.toUpperCase()}`;
11513
11529
  const every = (p.intervalCount || 1) > 1 ? ` every ${p.intervalCount} ${p.interval}s` : `/${p.interval}`;
11514
11530
  const trial = (p.trialDays ?? 0) > 0 ? ` with a ${p.trialDays}-day free trial` : "";
@@ -11778,11 +11794,11 @@ function formatMoney(amount, currency) {
11778
11794
  } catch {
11779
11795
  digits = 2;
11780
11796
  }
11781
- const major = amount / 10 ** digits;
11797
+ const major2 = amount / 10 ** digits;
11782
11798
  try {
11783
- return new Intl.NumberFormat("en-US", { style: "currency", currency: code2, minimumFractionDigits: digits }).format(major);
11799
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: code2, minimumFractionDigits: digits }).format(major2);
11784
11800
  } catch {
11785
- return `${major.toFixed(digits)} ${code2}`;
11801
+ return `${major2.toFixed(digits)} ${code2}`;
11786
11802
  }
11787
11803
  }
11788
11804
  function intervalPhrase(interval, count = 1) {
@@ -13405,7 +13421,7 @@ function checkoutConfigViews(ctx) {
13405
13421
  return (ctx.checkoutConfigs ?? []).filter((c) => c && c.capture).map((c) => ({
13406
13422
  ...buildSignupView(c.capture),
13407
13423
  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 } : {} },
13424
+ 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
13425
  // A Payment Link is read from Stripe on every billing read: it shows the link as of that read.
13410
13426
  ...c.kind === "payment_link" && readAt ? { observedAt: readAt } : {}
13411
13427
  }));
@@ -13873,7 +13889,7 @@ function platformKey(platform) {
13873
13889
  }
13874
13890
  function portalUnlinkedFix(portalUrl) {
13875
13891
  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.`;
13892
+ 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
13893
  }
13878
13894
  function paymentLinkCancelRemediation(portalUrl, loginPageOn) {
13879
13895
  const link = portalUrl && /^https:\/\/billing\.stripe\.com\//.test(portalUrl) ? portalUrl : void 0;
@@ -14979,6 +14995,18 @@ var init_help_pages = __esm({
14979
14995
  });
14980
14996
 
14981
14997
  // ../rules/src/checks/cancel-paths.ts
14998
+ function mayOpenPortal(el, pageUrl2) {
14999
+ const href = el.href;
15000
+ if (!href) return true;
15001
+ try {
15002
+ const target = new URL(href, pageUrl2);
15003
+ const page = new URL(pageUrl2);
15004
+ if (target.host !== page.host) return true;
15005
+ return /(?:^|\/)(?:api|portal|billing|billing-portal|create-portal-session|customer-portal)(?:\/|$|[-_?])/i.test(target.pathname);
15006
+ } catch {
15007
+ return true;
15008
+ }
15009
+ }
14982
15010
  function inAppPath(input) {
14983
15011
  const flow = input.ctx.cancellation?.via === "stripe_portal" ? void 0 : input.ctx.cancellation;
14984
15012
  if (flow) {
@@ -15014,7 +15042,7 @@ function inAppPath(input) {
15014
15042
  return true;
15015
15043
  }
15016
15044
  });
15017
- if (leadsOnline.length) return { kind: "in_app", state: "offered", detail: `your own page offers a cancel option (${p.url})`, url: p.url };
15045
+ 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
15046
  }
15019
15047
  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
15048
  return void 0;
@@ -15692,6 +15720,16 @@ var init_cancellation = __esm({
15692
15720
  { unverified: true, source: "snippet" }
15693
15721
  );
15694
15722
  }
15723
+ if (controls.length && cfg?.selfServeOnline === false && controls.every((el) => mayOpenPortal(el, page.url))) {
15724
+ return makeResult(
15725
+ id,
15726
+ "needs_review",
15727
+ `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`,
15728
+ [...captureRefs(page), ...configRefs],
15729
+ 0.5,
15730
+ { inAppOffered: true, url: page.url, selfServeOnline: false, portalCancelOff: true }
15731
+ );
15732
+ }
15695
15733
  if (controls.length) {
15696
15734
  return makeResult(
15697
15735
  id,
@@ -16115,6 +16153,16 @@ var init_config2 = __esm({
16115
16153
  }
16116
16154
  if (c.selfServeOnline === false) {
16117
16155
  const flow = input.ctx.cancellation;
16156
+ if (inApp?.state === "offered" && inApp.mayOpenPortal && model.hosted?.state !== "offered" && !(model.offline && !model.offline.soft)) {
16157
+ return makeResult(
16158
+ id,
16159
+ "needs_review",
16160
+ `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`,
16161
+ refs(input),
16162
+ 0.6,
16163
+ { ...data, ownCancelPage: true, inAppOfferedUntested: true, inAppUnobserved: true }
16164
+ );
16165
+ }
16118
16166
  if (model.otherOnlineWorks) {
16119
16167
  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
16168
  return makeResult(
@@ -16320,8 +16368,8 @@ function introEmailFinding(rawText, plans, opts) {
16320
16368
  const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
16321
16369
  if (!usable.length) return void 0;
16322
16370
  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 ?? []];
16371
+ const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16372
+ const regular = [...usable.map((p2) => major2(p2, p2.amount)), ...opts.extraAmounts ?? []];
16325
16373
  const isRegular = (n) => regular.some((r) => Math.abs(r - n) < 5e-3);
16326
16374
  const offRegular = (sentence) => findAmounts(sentence).filter((m) => {
16327
16375
  if (typeof m.amount !== "number" || !(m.amount > 0) || isRegular(m.amount)) return false;
@@ -16368,7 +16416,7 @@ function introEmailFinding(rawText, plans, opts) {
16368
16416
  };
16369
16417
  }
16370
16418
  const fits = (p2) => {
16371
- const intro = major(p2, p2.introAmount);
16419
+ const intro = major2(p2, p2.introAmount);
16372
16420
  const pct = (1 - p2.introAmount / p2.amount) * 100;
16373
16421
  return statedAmounts.every((m) => Math.abs(m.amount - intro) < 5e-3) && percents.every((x) => Math.abs(x.value - pct) <= 0.5);
16374
16422
  };
@@ -16397,6 +16445,7 @@ function introEmailFinding(rawText, plans, opts) {
16397
16445
  fix: `State how long the promo really lasts: ${introTermsPhrase(p2)}.`
16398
16446
  };
16399
16447
  }
16448
+ if (statesIntroEndDate(text)) return void 0;
16400
16449
  const p = fitting[0];
16401
16450
  return {
16402
16451
  status: "needs_review",
@@ -16406,29 +16455,32 @@ function introEmailFinding(rawText, plans, opts) {
16406
16455
  fix: `State how long the promo lasts and the price after it: ${introTermsPhrase(p)}.`
16407
16456
  };
16408
16457
  }
16458
+ function statesIntroEndDate(text) {
16459
+ return INTRO_END_DATE_RES.some((re) => re.test(text));
16460
+ }
16409
16461
  function introFirstChargeFinding(rawText, plans) {
16410
16462
  const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
16411
16463
  if (!usable.length) return void 0;
16412
16464
  const text = normalizeText(rawText);
16413
16465
  const stated = findAmounts(text).filter((m) => typeof m.amount === "number" && m.amount > 0);
16414
- const major = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16466
+ const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16415
16467
  const states = (n) => stated.some((m) => Math.abs(m.amount - n) < 5e-3);
16416
- const named = usable.filter((p2) => states(major(p2, p2.amount)));
16468
+ const named = usable.filter((p2) => states(major2(p2, p2.amount)));
16417
16469
  if (!named.length || !named.every(hasIntro)) return void 0;
16418
- if (named.some((p2) => states(major(p2, p2.introAmount)))) return void 0;
16470
+ if (named.some((p2) => states(major2(p2, p2.introAmount)))) return void 0;
16419
16471
  if (new RegExp(PERCENT_OFF_RE.source, "i").test(text)) return void 0;
16420
16472
  const p = named[0];
16421
16473
  const regular = money(p.amount, p.currency);
16422
16474
  const nameOf = p.label?.trim() || (p.name?.trim() ? `plan \u201C${p.name.trim()}\u201D` : `the ${regular} ${planEvery(p)} plan`);
16423
16475
  return {
16424
16476
  status: "fail",
16425
- quote: stated.find((m) => Math.abs(m.amount - major(p, p.amount)) < 5e-3)?.text ?? regular,
16477
+ quote: stated.find((m) => Math.abs(m.amount - major2(p, p.amount)) < 5e-3)?.text ?? regular,
16426
16478
  reason: "intro_first_charge",
16427
16479
  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
16480
  fix: `State the first charge after the trial and the price after it: ${introTermsPhrase(p)}.`
16429
16481
  };
16430
16482
  }
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;
16483
+ 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
16484
  var init_intro_terms = __esm({
16433
16485
  "../rules/src/checks/intro-terms.ts"() {
16434
16486
  "use strict";
@@ -16444,6 +16496,15 @@ var init_intro_terms = __esm({
16444
16496
  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
16497
  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
16498
  NOT_A_PRICE_AROUND_RE = /\b(?:tax(?:es)?|vat|fees?|shipping|delivery\s+fee|credit|refund(?:ed)?|balance|deposit|tip)\b/i;
16499
+ 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})";
16500
+ INTRO_WORD = "(?:discount|coupon|promo(?:tion|tional)?(?:\\s+(?:price|rate|code))?|intro(?:ductory)?(?:\\s+(?:price|rate|offer|period))?|special\\s+(?:price|offer|rate)|offer)";
16501
+ REGULAR_PRICE = "(?:regular|full|standard|normal|usual)\\s+(?:price|rate)";
16502
+ gap = (n) => `(?:[^.\\n]|\\.(?=\\d)){0,${n}}?`;
16503
+ INTRO_END_DATE_RES = [
16504
+ 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"),
16505
+ 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"),
16506
+ new RegExp(`\\b(?:starting|from|beginning|on|as\\s+of)\\s+${DATE_RE_SRC}${gap(100)}\\b${REGULAR_PRICE}\\b`, "i")
16507
+ ];
16447
16508
  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
16509
  }
16449
16510
  });
@@ -16920,7 +16981,7 @@ function withoutRevisedDrafts(emails) {
16920
16981
  const draft2 = (e) => !e.subscriptionRef && !e.relatedSignupAt && !e.testRun && !isCheckboxSentEmail(e);
16921
16982
  const trial = (e) => /\b(?:free\s+)?trial\b/i.test(`${e.subject ?? ""} ${emailBody(e)}`);
16922
16983
  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))));
16984
+ 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
16985
  }
16925
16986
  function latestPerTemplateAndPlan(emails, input) {
16926
16987
  const offers = contextOffers(input.ctx);
@@ -17833,29 +17894,29 @@ function contentGap(required, emails, opts = {}) {
17833
17894
  }
17834
17895
  return null;
17835
17896
  }
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`;
17897
+ function contentFail(spec, required, gap2, timing, refs2, data) {
17898
+ const intro = gap2.intro;
17899
+ const sentWhat = isPreflightEmail(gap2.email) ? "is a draft that" : `${timing}, but it`;
17839
17900
  const introData = intro ? { contentIntro: { status: intro.status, reason: intro.reason, quote: intro.quote, fix: intro.fix } } : {};
17840
- if (!gap.missing.length && intro) {
17901
+ if (!gap2.missing.length && intro) {
17841
17902
  return makeResult(
17842
17903
  spec.checkId,
17843
17904
  intro.status,
17844
- `The ${spec.noun} (${emailLabel(gap.email)}) ${sentWhat} ${intro.detail}`,
17845
- [...emailRefs(gap.email), ...refs2],
17905
+ `The ${spec.noun} (${emailLabel(gap2.email)}) ${sentWhat} ${intro.detail}`,
17906
+ [...emailRefs(gap2.email), ...refs2],
17846
17907
  intro.status === "fail" ? 0.8 : 0.5,
17847
- { ...data, contentMissing: [], contentEmailId: gap.email.id, ...isCheckboxSentEmail(gap.email) ? { contentByCheckbox: true } : {}, ...introData }
17908
+ { ...data, contentMissing: [], contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...introData }
17848
17909
  );
17849
17910
  }
17850
17911
  return makeResult(
17851
17912
  spec.checkId,
17852
17913
  "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
17914
+ `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
17915
  // 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],
17916
+ 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}` : ""}`,
17917
+ [...emailRefs(gap2.email), ...refs2],
17857
17918
  0.8,
17858
- { ...data, contentMissing: gap.missing, contentEmailId: gap.email.id, ...isCheckboxSentEmail(gap.email) ? { contentByCheckbox: true } : {}, ...gap.wrong ? { contentWrong: gap.wrong } : {}, ...introData }
17919
+ { ...data, contentMissing: gap2.missing, contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...gap2.wrong ? { contentWrong: gap2.wrong } : {}, ...introData }
17859
17920
  );
17860
17921
  }
17861
17922
  async function runReminder(spec, input) {
@@ -17955,8 +18016,8 @@ async function runReminder(spec, input) {
17955
18016
  const w = conflicting ? "within each state\u2019s window" : why(window);
17956
18017
  if (counted.length) {
17957
18018
  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);
18019
+ const gap2 = contentGap(required, matched.flatMap((j) => j.email ? [j.email] : []), contentOpts);
18020
+ if (gap2) return contentFail(spec, required, gap2, "was sent on time", refs2(matched), data);
17960
18021
  return makeResult(
17961
18022
  id,
17962
18023
  "pass",
@@ -18047,8 +18108,8 @@ async function runReminder(spec, input) {
18047
18108
  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
18109
  }
18049
18110
  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);
18111
+ const gap2 = contentGap(required, inWindow.map((t) => t.e), contentOpts);
18112
+ 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
18113
  return makeResult(
18053
18114
  id,
18054
18115
  "pass",
@@ -18060,8 +18121,8 @@ async function runReminder(spec, input) {
18060
18121
  }
18061
18122
  if (nearWindow.length) {
18062
18123
  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);
18124
+ const gap2 = contentGap(required, nearWindow.map((t) => t.e), contentOpts);
18125
+ if (gap2) return contentFail(spec, required, gap2, `was sent about ${n.days} days ahead (required: ${conflicting ? w : why(n.w)})`, configRefs, data);
18065
18126
  data.timingNearWindow = { days: n.days, required: conflicting ? w : why(n.w), roundingRule: DAYS_ROUNDING_RULE, ...n.range ? { range: n.range } : {} };
18066
18127
  data.timingOutsideWindow = { days: n.days, required: conflicting ? w : why(n.w) };
18067
18128
  return makeResult(
@@ -19357,8 +19418,8 @@ function gatedByOwnBox(renewalBoxes, path) {
19357
19418
  return renewalBoxes.some((b) => b.required === true && !b.checkedByDefault && consentGate(b, path) === "gated");
19358
19419
  }
19359
19420
  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;
19421
+ 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;
19422
+ return gap2 <= 200;
19362
19423
  }
19363
19424
  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;
19364
19425
  var init_signup = __esm({
@@ -19863,8 +19924,18 @@ var init_signup = __esm({
19863
19924
  const offers = contextOffers(input.ctx);
19864
19925
  const loose = !view.plan || view.plan.how === "only_plan";
19865
19926
  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;
19927
+ const paramsTrial = view.checkoutConfig?.trialDays;
19928
+ const planTrial = typeof paramsTrial === "number" ? paramsTrial : cardOffer ? cardOffer.trialDays : view.plan ? offerTrialDays(offers, view.plan.plan, input.ctx.facts) : void 0;
19867
19929
  const planName = cardOffer ? offerName(cardOffer) : view.plan?.label;
19930
+ if (mentioned && paramsTrial === 0) {
19931
+ const said = [...a.trialLengths, ...p.trialLengths][0] ?? [...a.trialMentions, ...p.trialMentions][0];
19932
+ return unit(
19933
+ "fail",
19934
+ `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`,
19935
+ 0.85,
19936
+ { ...said ? { quote: quote(said) } : {}, meta: { planTrialDays: 0, paramsTrialDays: 0, trialMismatch: true } }
19937
+ );
19938
+ }
19868
19939
  if (!mentioned) {
19869
19940
  if (planTrial === 0 && offers.some((o) => o.trialDays > 0)) {
19870
19941
  const withTrial = offers.filter((o) => o.trialDays > 0).map(offerName);
@@ -19889,7 +19960,7 @@ var init_signup = __esm({
19889
19960
  const meta = {};
19890
19961
  if (length && "days" in length && length.days !== null) meta.disclosedTrialDays = length.days;
19891
19962
  if (input.facts.trialDays) meta.configuredTrialDays = input.facts.trialDays;
19892
- const ownPlanOnly = (!!view.plan || !!cardOffer) && (!!view.checkoutConfig || !!view.offer) && planTrial !== void 0;
19963
+ const ownPlanOnly = (typeof paramsTrial === "number" || !!view.plan || !!cardOffer) && (!!view.checkoutConfig || !!view.offer) && planTrial !== void 0;
19893
19964
  const trialPlans = [
19894
19965
  ...(input.ctx.config?.plans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: configPlanSource(input.ctx) })),
19895
19966
  ...(input.ctx.referencePlans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: pl.source }))
@@ -22199,6 +22270,24 @@ var init_brand_scope = __esm({
22199
22270
  }
22200
22271
  });
22201
22272
 
22273
+ // ../rules/src/set-off-fix.ts
22274
+ function setOffFix(text) {
22275
+ const own = /legible \((\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
22276
+ const ref = /look like the surrounding text \(about (\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
22277
+ 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.';
22278
+ const refPx = Number(ref[1]);
22279
+ const refWeight = Number(ref[2]);
22280
+ const larger = Math.ceil(refPx * 1.15);
22281
+ 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}.`;
22282
+ 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")'];
22283
+ return `Set the renewal line apart in a way Checkbox can measure. ${measured} Any one of these is enough: ${ways.join("; ")}.`;
22284
+ }
22285
+ var init_set_off_fix = __esm({
22286
+ "../rules/src/set-off-fix.ts"() {
22287
+ "use strict";
22288
+ }
22289
+ });
22290
+
22202
22291
  // ../rules/src/status.ts
22203
22292
  function tierOf(checkId) {
22204
22293
  return CHECK_TIERS[checkId] ?? "required";
@@ -22295,10 +22384,18 @@ function cancelTestProblem(raw) {
22295
22384
  }
22296
22385
  function policyUnreadableFix(text) {
22297
22386
  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.`;
22387
+ const doc = /\b(terms of service|cancellation policy|refund policy|privacy policy)\s*\((https?:\/\/[^\s),]+)/i.exec(text);
22388
+ const kind = doc?.[1]?.toLowerCase();
22389
+ const url = doc?.[2];
22390
+ const page = !kind || kind === "terms of service" ? "Terms page" : `${kind} page`;
22391
+ const named = url ? `Your ${page} (${url})` : `Your ${page}`;
22392
+ if (url && /\/p\/login\//i.test(url)) {
22393
+ 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.`;
22394
+ }
22395
+ if (n !== void 0 || /almost no text/i.test(text)) {
22396
+ 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.`;
22300
22397
  }
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.";
22398
+ 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
22399
  }
22303
22400
  function finish(base, reason, checkId, tier = tierOf(checkId)) {
22304
22401
  if (reason === "ny_owner_decision") return { ...base, label: NY_EMAIL_ONLY_LABEL, reason, tier };
@@ -22533,6 +22630,7 @@ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_L
22533
22630
  var init_status = __esm({
22534
22631
  "../rules/src/status.ts"() {
22535
22632
  "use strict";
22633
+ init_set_off_fix();
22536
22634
  init_notice_content();
22537
22635
  init_notices_on();
22538
22636
  init_plan_terms();
@@ -22821,17 +22919,30 @@ var init_status = __esm({
22821
22919
  { 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
22920
  { 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
22921
  // 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.
22922
+ // symbols and italics are not captured), so it was a question to confirm by eye. Round 33 (pilot 32 item 18,
22923
+ // core-api-only / core-ci-pr / core-discovery: the owner was asked for "a decision" an agent could make in code, and the
22924
+ // recommended "make it semibold" could never pass next to an already bold price line): a fix with the measured values
22925
+ // and the ways Checkbox can measure (larger type, semibold where the text around is not bold, a contrasting colour, a
22926
+ // border). Never compliant.
22927
+ // A disclosure text check (kind "disclosure") only knows the styling the caller describes (placement.bold / boxed):
22928
+ // there it stays the question it was, with the same measurable options.
22825
22929
  {
22826
22930
  code: "not_set_off",
22931
+ category: "content",
22932
+ when: (c) => c.kind !== "disclosure" && /look like the surrounding text/.test(c.text),
22933
+ build: (c) => ({ status: "needs_fix", fix: setOffFix(c.text) })
22934
+ },
22935
+ {
22936
+ code: "not_set_off_described",
22827
22937
  category: "legal_choice",
22828
- when: has(/look like the surrounding text/),
22829
- build: () => ({
22938
+ when: (c) => c.kind === "disclosure" && /look like the surrounding text/.test(c.text),
22939
+ build: (c) => ({
22830
22940
  status: "your_call",
22941
+ fix: setOffFix(c.text),
22831
22942
  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."
22943
+ "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)?",
22944
+ "If it does, check again with placement.bold (or placement.boxed) set; if not, apply one of the ways in the fix.",
22945
+ "This check only knows the styling you describe; it cannot see your page."
22835
22946
  )
22836
22947
  })
22837
22948
  },
@@ -23179,7 +23290,7 @@ var init_status = __esm({
23179
23290
  { 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
23291
  ];
23181
23292
  STATUS_RULES = RULES.map((r) => ({ code: r.code, category: r.category }));
23182
- NY_EMAIL_ONLY_LABEL = "Your decision: email only";
23293
+ NY_EMAIL_ONLY_LABEL = "Your decision: email only (not verified)";
23183
23294
  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
23295
  STRIPE_HOSTED_UNITS = /* @__PURE__ */ new Set(["checkout_config", "payment_link", "stripe_hosted"]);
23185
23296
  PLATFORM_NAMES2 = { paddle: "Paddle", shopify: "Shopify", recharge: "Recharge", chargebee: "Chargebee", recurly: "Recurly" };
@@ -23981,12 +24092,12 @@ function disclosureParts(text) {
23981
24092
  function disclosureHtml(text) {
23982
24093
  return disclosureParts(text).map((p) => "url" in p ? `<a href="${esc(p.url)}">${esc(p.label)}</a>` : esc(p.text)).join("");
23983
24094
  }
23984
- function htmlDisclosureComponent(texts, sha2562) {
24095
+ function htmlDisclosureComponent(texts, sha2562, opts = {}) {
23985
24096
  return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
23986
24097
  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;">
24098
+ ${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).
24099
+ 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. -->
24100
+ <div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;${opts.boxed ? BOXED_STYLE : ""}">
23990
24101
  <p style="margin:0 0 6px;font-weight:600;">${disclosureHtml(texts.disclosure)}</p>
23991
24102
  <label style="display:flex;gap:8px;align-items:flex-start;">
23992
24103
  <!-- Never add "checked": consent must be an affirmative act. -->
@@ -23996,23 +24107,26 @@ function htmlDisclosureComponent(texts, sha2562) {
23996
24107
  </div>
23997
24108
  <button type="submit">${esc(texts.cta)}</button>`;
23998
24109
  }
23999
- function htmlTermsOnly(disclosure, sha2562, onStripe) {
24110
+ function htmlTermsOnly(disclosure, sha2562, onStripe, opts = {}) {
24000
24111
  const where2 = onStripe === "payment_link" ? "Payment Link" : "Stripe Checkout";
24001
24112
  return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
24002
24113
  Paste directly ABOVE your own subscribe / Join link or button. Keep that link or button as it is.
24003
24114
  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;">
24115
+ ${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).
24116
+ If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead.`} -->
24117
+ <div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;${opts.boxed ? BOXED_STYLE : ""}">
24007
24118
  <p style="margin:0;font-weight:600;">${disclosureHtml(disclosure)}</p>
24008
24119
  </div>`;
24009
24120
  }
24010
- var esc, URL_RE2;
24121
+ var esc, URL_RE2, BOXED_STYLE, BOXED_NOTE;
24011
24122
  var init_html = __esm({
24012
24123
  "../rules/src/fixes/html.ts"() {
24013
24124
  "use strict";
24014
24125
  esc = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24015
24126
  URL_RE2 = /https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)]/g;
24127
+ BOXED_STYLE = "border:1px solid currentColor;border-radius:6px;padding:8px 12px;";
24128
+ 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
24129
+ (${BOXED_STYLE.replace(/;$/, "")}), which Checkbox measures. Keep the border, or use 15% larger type instead.`;
24016
24130
  }
24017
24131
  });
24018
24132
 
@@ -24095,7 +24209,7 @@ function modelAfterCapture(before, target, texts, opts) {
24095
24209
  const ctaY = cta ? cta.rect.y : 700;
24096
24210
  const ctaH = cta ? cta.rect.height : 48;
24097
24211
  const stripe = target === "stripe_checkout" || target === "stripe_payment_link";
24098
- const style2 = stripe ? STRIPE_CUSTOM_TEXT_STYLE : COMPONENT_STYLE;
24212
+ const style2 = stripe ? STRIPE_CUSTOM_TEXT_STYLE : opts.boxed ? { ...COMPONENT_STYLE, boxed: true } : COMPONENT_STYLE;
24099
24213
  const disclosureH = textHeight(texts.disclosure, width, style2.fontSizePx);
24100
24214
  const labelH = textHeight(texts.consentLabel, width - 28, style2.fontSizePx);
24101
24215
  const disclosure = {
@@ -24227,7 +24341,7 @@ async function verifyFix(input) {
24227
24341
  const rest = captures.filter((c) => !allCheckout(c));
24228
24342
  const unchangedCheckout = latestPages(captures.filter((c) => allCheckout(c) && !matching.some((m) => m.id === c.id))).filter((c) => (c.ctas?.length ?? 0) > 0);
24229
24343
  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 }));
24344
+ const after = base.map((c) => modelAfterCapture(c, input.target, input.texts, { consentCheckbox: consent, ...input.boxed ? { boxed: true } : {} }));
24231
24345
  const changedIds = new Set(objects.map((o) => o.id));
24232
24346
  const afterConfigs = input.ctx.checkoutConfigs?.map(
24233
24347
  (c) => changedIds.has(c.id) ? { ...c, capture: modelAfterCapture(c.capture, input.target, input.texts, { consentCheckbox: consent }), consentRequired: consent || c.consentRequired } : c
@@ -24492,13 +24606,18 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24492
24606
  const verifyTexts = lockedTexts(verifyTerms);
24493
24607
  const page = opts.onStripe && opts.page && (opts.page.ctas?.length ?? 0) > 0 ? [{ ...opts.page, pageKind: "checkout", source: "scanner" }] : [];
24494
24608
  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 } : {} });
24609
+ const v0 = await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, ...opts.boxed ? { boxed: true } : {}, ...opts.onStripe ? { consentCheckbox: false } : {} });
24610
+ const tryBoxed = !opts.boxed && page.length > 0 && !v0.passes && v0.remaining.includes("signup.disclosure.conspicuous");
24611
+ const vb = tryBoxed ? await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, boxed: true, ...opts.onStripe ? { consentCheckbox: false } : {} }) : void 0;
24612
+ const boxedOnPage = !!vb && !vb.remaining.includes("signup.disclosure.conspicuous") && vb.remaining.length < v0.remaining.length;
24613
+ const boxed = opts.boxed === true || boxedOnPage;
24614
+ const v = boxedOnPage ? vb : v0;
24496
24615
  const sha = textsSha256(shown);
24497
24616
  const placeholders = plan2.cancelUrl ? [] : [CANCEL_URL_PLACEHOLDER];
24498
24617
  let short;
24499
24618
  if (plan2.cancelUrl) {
24500
24619
  const shortText = shortDisclosureText(verifyTerms);
24501
- const sv = await verifyFix({ ctx, target: "html", texts: { ...verifyTexts, disclosure: shortText }, terms: verifyTerms, now, ...opts.onStripe ? { consentCheckbox: false } : {} });
24620
+ const sv = await verifyFix({ ctx, target: "html", texts: { ...verifyTexts, disclosure: shortText }, terms: verifyTerms, now, ...boxed ? { boxed: true } : {}, ...opts.onStripe ? { consentCheckbox: false } : {} });
24502
24621
  if (sv.passes && v.passes && shortText !== shown.disclosure) short = shortText;
24503
24622
  }
24504
24623
  if (opts.onStripe) {
@@ -24509,9 +24628,9 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24509
24628
  ...short ? { short_disclosure_text: short } : {},
24510
24629
  consent_label: shown.consentLabel,
24511
24630
  button_text: shown.cta,
24512
- html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe),
24631
+ html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe, boxed ? { boxed: true } : {}),
24513
24632
  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.`,
24633
+ 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
24634
  placeholders,
24516
24635
  verified: v.passes,
24517
24636
  remaining_checks: [.../* @__PURE__ */ new Set([...v.remaining, "signup.consent.affirmative"])],
@@ -24523,7 +24642,7 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24523
24642
  ...short ? { short_disclosure_text: short } : {},
24524
24643
  consent_label: shown.consentLabel,
24525
24644
  button_text: shown.cta,
24526
- html: htmlDisclosureComponent(shown, sha),
24645
+ html: htmlDisclosureComponent(shown, sha, boxed ? { boxed: true } : {}),
24527
24646
  placeholders,
24528
24647
  verified: v.passes,
24529
24648
  remaining_checks: v.remaining,
@@ -24814,6 +24933,17 @@ var init_price_hint = __esm({
24814
24933
  });
24815
24934
 
24816
24935
  // ../rules/src/preflight/stripe.ts
24936
+ function trialPromise(texts) {
24937
+ for (const [field, text] of texts) {
24938
+ if (!text) continue;
24939
+ const a = analyzeTerms(text);
24940
+ if (!a.trialMentions.length && !a.trialLengths.length) continue;
24941
+ const days = a.trialLengths.map((t) => "days" in t ? t.days : null).filter((d) => typeof d === "number" && d > 0);
24942
+ const span = a.trialLengths[0] ?? a.trialMentions[0];
24943
+ return { field, quote: span.text.trim(), days };
24944
+ }
24945
+ return void 0;
24946
+ }
24817
24947
  function clone(v) {
24818
24948
  return JSON.parse(JSON.stringify(v));
24819
24949
  }
@@ -24905,6 +25035,25 @@ function readParams(params, merchant, kind, asOf) {
24905
25035
  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
25036
  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
25037
  }
25038
+ 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]]);
25039
+ if (promisedTrial && !(trial !== void 0 && trial > 0)) {
25040
+ caveats.push({
25041
+ checks: ["signup.disclosure.trial_terms"],
25042
+ status: "fail",
25043
+ 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`,
25044
+ param: promisedTrial.field === "product description" ? "params.line_items" : `params.${promisedTrial.field}.message`
25045
+ });
25046
+ } else if (promisedTrial && trial !== void 0 && trial > 0) {
25047
+ const wrongDays = promisedTrial.days.find((d) => Math.abs(d - trial) > TRIAL_DAYS_SLACK);
25048
+ if (wrongDays !== void 0) {
25049
+ caveats.push({
25050
+ checks: ["signup.disclosure.trial_terms"],
25051
+ status: "fail",
25052
+ 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`,
25053
+ param: promisedTrial.field === "product description" ? "params.line_items" : `params.${promisedTrial.field}.message`
25054
+ });
25055
+ }
25056
+ }
24908
25057
  const uiMode = typeof params.ui_mode === "string" ? params.ui_mode : void 0;
24909
25058
  if (uiMode === "custom" || uiMode === "elements") {
24910
25059
  caveats.push({
@@ -25182,6 +25331,7 @@ function correct(params, shape, checks, forLink, style2 = "full") {
25182
25331
  out.custom_text = set;
25183
25332
  } else {
25184
25333
  for (const [k, v] of Object.entries(set)) {
25334
+ if (JSON.stringify(ct[k]) === JSON.stringify(v)) continue;
25185
25335
  patch.push({ op: k in ct && ct[k] !== null ? "replace" : "add", path: `/custom_text/${k}`, value: v });
25186
25336
  ct[k] = v;
25187
25337
  }
@@ -25449,7 +25599,10 @@ function modelCheckoutConfig(params, opts) {
25449
25599
  // travels with its plan, so the site's checks and copy know the promo without a separate declaration.
25450
25600
  ...plan2.introAmount !== void 0 && plan2.introAmount >= 0 && plan2.introAmount < plan2.amount ? { introAmount: plan2.introAmount, ...plan2.introPeriods && plan2.introPeriods > 1 ? { introPeriods: plan2.introPeriods } : {} } : {}
25451
25601
  },
25452
- consentRequired: shape.consentRequired
25602
+ consentRequired: shape.consentRequired,
25603
+ // Round 33: the trial the params themselves run (subscription_data), 0 when none; a trial taken only from a Price
25604
+ // or merchant terms is not one Stripe runs for these params.
25605
+ trialDays: plan2.trialDays ?? 0
25453
25606
  };
25454
25607
  }
25455
25608
  function noticeChannelField(params) {
@@ -25476,6 +25629,7 @@ var init_stripe2 = __esm({
25476
25629
  init_compact();
25477
25630
  init_plan_terms();
25478
25631
  init_text();
25632
+ init_signup();
25479
25633
  init_verify();
25480
25634
  init_stripe();
25481
25635
  init_common();
@@ -26183,7 +26337,8 @@ async function checkPageHtml(input, opts = {}) {
26183
26337
  });
26184
26338
  }
26185
26339
  const consentOnStripe = onStripe ?? (checkoutType === "payment_link" || checkoutType === "stripe_checkout" ? checkoutType : checkoutType ? void 0 : stripeButtonsKind(capture));
26186
- const repOpts = consentOnStripe ? { onStripe: consentOnStripe } : {};
26340
+ const boldAround = checks.some((c) => c.check_id === "signup.disclosure.conspicuous" && /already bold, so a bolder weight cannot set them apart/.test(c.explanation ?? ""));
26341
+ const repOpts = { ...consentOnStripe ? { onStripe: consentOnStripe } : {}, ...boldAround ? { boxed: true } : {} };
26187
26342
  const built = plan2 ? await buildReplacement(plan2, jurisdictions, asOf, now, { ...repOpts, page: capture }) : void 0;
26188
26343
  const differs = plan2 && !multi ? pagePriceDiffers(run2.findings) : [];
26189
26344
  if (plan2 && differs.length) {
@@ -26266,7 +26421,8 @@ function signupStripeObjects(input) {
26266
26421
  capture: { ...m.capture, siteId: input.siteId },
26267
26422
  consentRequired: m.consentRequired,
26268
26423
  kind: "checkout_config",
26269
- ...m.plan ? { planId: m.plan.id } : {}
26424
+ ...m.plan ? { planId: m.plan.id } : {},
26425
+ ...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
26270
26426
  });
26271
26427
  }
26272
26428
  if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", ...row.name ? { label: row.name } : {} });
@@ -26299,7 +26455,8 @@ function signupStripeObjects(input) {
26299
26455
  consentRequired: m.consentRequired,
26300
26456
  kind: "payment_link",
26301
26457
  ...link.url ? { url: link.url } : {},
26302
- ...m.plan ? { planId: m.plan.id } : {}
26458
+ ...m.plan ? { planId: m.plan.id } : {},
26459
+ ...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
26303
26460
  });
26304
26461
  }
26305
26462
  if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", label: `Payment Link \u201C${name}\u201D` });
@@ -26358,6 +26515,7 @@ __export(preflight_exports, {
26358
26515
  stripeParamsVerdict: () => stripeParamsVerdict,
26359
26516
  supportContactLine: () => supportContactLine,
26360
26517
  testRunSessionIds: () => testRunSessionIds,
26518
+ trialPromise: () => trialPromise,
26361
26519
  withIntroOffers: () => withIntroOffers,
26362
26520
  withTestRunSessions: () => withTestRunSessions
26363
26521
  });
@@ -27005,7 +27163,7 @@ function parseObjectLiteral(source, env2 = {}, consts = {}) {
27005
27163
  const p = new Parser(source, env2, consts);
27006
27164
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
27007
27165
  const v = p.object("");
27008
- return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
27166
+ 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
27167
  }
27010
27168
  var UNKNOWN, Parser;
27011
27169
  var init_literal = __esm({
@@ -27021,6 +27179,7 @@ var init_literal = __esm({
27021
27179
  i = 0;
27022
27180
  unresolved = [];
27023
27181
  fromEnv = {};
27182
+ spreads = [];
27024
27183
  ws() {
27025
27184
  for (; ; ) {
27026
27185
  while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
@@ -27157,7 +27316,9 @@ var init_literal = __esm({
27157
27316
  if (!ch) return UNKNOWN;
27158
27317
  if (this.s.startsWith("...", this.i)) {
27159
27318
  this.i += 3;
27319
+ const from = this.i;
27160
27320
  this.skipExpr();
27321
+ if (!path) this.spreads.push(this.s.slice(from, this.i).trim());
27161
27322
  this.unresolved.push(`${path ? `${path}.` : ""}...`);
27162
27323
  } else {
27163
27324
  let key = null;
@@ -27756,8 +27917,8 @@ function plansFromLiterals(text, file) {
27756
27917
  if (!interval) continue;
27757
27918
  const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
27758
27919
  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;
27920
+ const major2 = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
27921
+ if (major2 === void 0 || !(major2 > 0)) continue;
27761
27922
  const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
27762
27923
  if (!name) continue;
27763
27924
  const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
@@ -27766,7 +27927,7 @@ function plansFromLiterals(text, file) {
27766
27927
  if (out.some((p) => p.name === name)) continue;
27767
27928
  const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
27768
27929
  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 });
27930
+ 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
27931
  }
27771
27932
  return out;
27772
27933
  }
@@ -28304,8 +28465,8 @@ function loaderFlags(file, dir, nodeVersion = process.versions.node, features =
28304
28465
  if (hasTsx(dir)) return ["--import", "tsx"];
28305
28466
  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
28467
  if (features.typescript) return [];
28307
- const [major, minor] = nodeVersion.split(".").map(Number);
28308
- if (major > 22 || major === 22 && minor >= 6) return ["--experimental-strip-types"];
28468
+ const [major2, minor] = nodeVersion.split(".").map(Number);
28469
+ if (major2 > 22 || major2 === 22 && minor >= 6) return ["--experimental-strip-types"];
28309
28470
  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
28471
  }
28311
28472
  function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
@@ -28375,7 +28536,23 @@ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
28375
28536
  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
28537
  const parsed = parseObjectLiteral(lit, env2, consts);
28377
28538
  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 } : {} };
28539
+ const unresolved = [...parsed.unresolved, ...spreadUnread(parsed.value, parsed.spreads ?? [], consts)];
28540
+ return { params: parsed.value, line: line2, unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
28541
+ }
28542
+ function spreadUnread(params, spreads, consts = {}) {
28543
+ const out = [];
28544
+ for (const expr of spreads) {
28545
+ const name = expr.replace(/\s+/g, "").replace(/\?\./g, ".");
28546
+ const seen = Object.prototype.hasOwnProperty.call(consts, name) ? consts[name] : expr;
28547
+ const visible = /[{]/.test(seen);
28548
+ for (const k of SPREAD_VERDICT_KEYS) {
28549
+ if (params[k] !== void 0) continue;
28550
+ if (visible && !new RegExp(`\\b${k}\\b`).test(seen)) continue;
28551
+ const entry = `${k} (may come from ...${expr.length > 40 ? `${expr.slice(0, 37)}\u2026` : expr})`;
28552
+ if (!out.includes(entry)) out.push(entry);
28553
+ }
28554
+ }
28555
+ return out;
28379
28556
  }
28380
28557
  function lineOfAny(text, needles) {
28381
28558
  const lower2 = text.toLowerCase();
@@ -28486,7 +28663,8 @@ async function stripeJob(loaded, e, o, root) {
28486
28663
  if (r.unresolved.length) {
28487
28664
  job.partial = true;
28488
28665
  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.`);
28666
+ const listed = r.unresolved.some((p) => / \(may come from \.\.\./.test(p)) ? r.unresolved.filter((p) => p !== "...") : r.unresolved;
28667
+ 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
28668
  }
28491
28669
  }
28492
28670
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
@@ -28622,12 +28800,12 @@ function pageJob(loaded, e, o, root) {
28622
28800
  function planMoney(plan2) {
28623
28801
  if (!plan2) return void 0;
28624
28802
  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;
28803
+ 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;
28804
+ if (major2 === void 0) return void 0;
28627
28805
  try {
28628
- return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major);
28806
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major2);
28629
28807
  } catch {
28630
- return `${major.toFixed(2)} ${cur}`;
28808
+ return `${major2.toFixed(2)} ${cur}`;
28631
28809
  }
28632
28810
  }
28633
28811
  function fillMergeTags(msg2, tags, config, plan2) {
@@ -28753,7 +28931,7 @@ async function planJobs(loaded, o, root) {
28753
28931
  }
28754
28932
  return jobs;
28755
28933
  }
28756
- var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
28934
+ var InputError, NOT_INSTALLED, CHILD, SPREAD_VERDICT_KEYS, jurisdictionsOf;
28757
28935
  var init_inputs = __esm({
28758
28936
  "src/ci/inputs.ts"() {
28759
28937
  "use strict";
@@ -28781,6 +28959,7 @@ if (typeof v === 'function') v = await v(...JSON.parse(argsJson));
28781
28959
  process.stdout.write('\\n__CHECKBOX_JSON__' + JSON.stringify(v === undefined ? null : v) + '\\n');
28782
28960
  process.exit(0);
28783
28961
  `;
28962
+ SPREAD_VERDICT_KEYS = ["mode", "consent_collection", "custom_text", "subscription_data", "discounts"];
28784
28963
  jurisdictionsOf = (config) => config.jurisdictions ? { jurisdictions: config.jurisdictions } : {};
28785
28964
  }
28786
28965
  });
@@ -29322,7 +29501,31 @@ function resultOf(status, exit, strict) {
29322
29501
  function ciVocabulary(text) {
29323
29502
  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
29503
  }
29325
- function pricingPageFixes(fixes) {
29504
+ function pricingPageFixes(fixes, replacements = []) {
29505
+ return concreteCardFixes(pricingPageFixesRaw(fixes), replacements);
29506
+ }
29507
+ function concreteCardFixes(fixes, replacements) {
29508
+ if (!replacements.length) return fixes;
29509
+ const lineFor = (card) => {
29510
+ const name = card.split(", ")[0].trim().toLowerCase();
29511
+ const r = replacements.find((x) => x.plan.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase() === name);
29512
+ return r?.short_disclosure_text || r?.disclosure_text || void 0;
29513
+ };
29514
+ const covered = /* @__PURE__ */ new Set();
29515
+ const out = fixes.map((f) => {
29516
+ const m = /^Plan card “([^”]+)”: /.exec(f);
29517
+ if (!m || !/"<price> every <interval>[^"]*"/.test(f)) return f;
29518
+ const line2 = lineFor(m[1]);
29519
+ if (!line2) return f;
29520
+ if (/cancel anytime online/i.test(line2)) covered.add(m[1]);
29521
+ return f.replace(/"<price> every <interval>[^"]*"/, `"${line2}"`);
29522
+ });
29523
+ return out.filter((f) => {
29524
+ const m = /^Plan card “([^”]+)”: Add "Cancel anytime online" to the renewal line/.exec(f);
29525
+ return !(m && covered.has(m[1]));
29526
+ });
29527
+ }
29528
+ function pricingPageFixesRaw(fixes) {
29326
29529
  const reworded = fixes.map(
29327
29530
  (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
29531
  );
@@ -29371,8 +29574,10 @@ function fixesOf(r, onlyNeedsFix = false) {
29371
29574
  const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
29372
29575
  if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
29373
29576
  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);
29577
+ for (const x of cardClauses(c.explanation).filter((y) => plain.includes(y.card))) {
29578
+ const standOut = `Plan card \u201C${x.card}\u201D: ${setOffFix(x.clause)}`;
29579
+ if (!out.includes(standOut)) out.push(standOut);
29580
+ }
29376
29581
  }
29377
29582
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
29378
29583
  if (text && !out.includes(text)) out.push(text);
@@ -29407,17 +29612,22 @@ function correctedOf(type, r, partial = false, placeholders, pricingPage = false
29407
29612
  if (type === "stripe_checkout" && r.correction?.patch?.length) {
29408
29613
  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
29614
  }
29410
- const perPlan = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
29411
- if (type === "page" && perPlan.length >= 2 && ciStatusOf(r) !== "pass") {
29615
+ const allPlans = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
29616
+ if (type === "page" && allPlans.length >= 2 && ciStatusOf(r) !== "pass") {
29617
+ 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());
29618
+ const failing = allPlans.filter((p) => named.includes(p.plan.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase()));
29619
+ const perPlan = failing.length ? failing : allPlans;
29412
29620
  const all = perPlan.every((p) => p.verified);
29413
29621
  return {
29414
29622
  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)" : ""}`,
29623
+ 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
29624
  // Round 32 (pilot 31, ci-pr-agent): a pricing page links to the checkout, where consent is asked (Stripe Checkout
29417
29625
  // shows its own checkbox), so no consent checkbox label is suggested for it.
29418
- body: perPlan.map((p) => `${p.plan}:
29626
+ body: `${perPlan.map((p) => `${p.plan}:
29419
29627
  ${p.disclosure_text}${p.consent_label && !pricingPage ? `
29420
- Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
29628
+ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")}${perPlan.some((p) => p.html && BOXED_HTML.test(p.html)) ? `
29629
+
29630
+ ${BOXED_LINE}` : ""}`
29421
29631
  };
29422
29632
  }
29423
29633
  if ((type === "disclosure" || type === "page") && r.replacement?.disclosure_text && ciStatusOf(r) !== "pass") {
@@ -29429,7 +29639,9 @@ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
29429
29639
 
29430
29640
  Fuller version: ${r.replacement.disclosure_text}` : r.replacement.disclosure_text}${r.replacement.consent_label ? `
29431
29641
 
29432
- Consent checkbox label: ${r.replacement.consent_label}` : ""}`
29642
+ Consent checkbox label: ${r.replacement.consent_label}` : ""}${r.replacement.html && BOXED_HTML.test(r.replacement.html) ? `
29643
+
29644
+ ${BOXED_LINE}` : ""}`
29433
29645
  };
29434
29646
  }
29435
29647
  if (type === "email" && r.template && ciStatusOf(r) !== "pass" && r.verdict !== "not_applicable") {
@@ -29505,6 +29717,11 @@ function unverifiedOffer(r) {
29505
29717
  if (coupon && open2.length) return coupon.message;
29506
29718
  return void 0;
29507
29719
  }
29720
+ function sitePlansDifferInCi(message) {
29721
+ const m = /\(you sent (.+?); (site_\w+) has (.+?)\), then check again/.exec(message);
29722
+ if (!m) return message;
29723
+ 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.`;
29724
+ }
29508
29725
  async function runJob(job, engine, config, opts = {}) {
29509
29726
  const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
29510
29727
  if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
@@ -29525,11 +29742,16 @@ async function runJob(job, engine, config, opts = {}) {
29525
29742
  }
29526
29743
  const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
29527
29744
  for (const w of report.warnings ?? []) {
29528
- const text = ciVocabulary(w.message);
29745
+ const text = w.code === "site_plans_differ" ? sitePlansDifferInCi(w.message) : ciVocabulary(w.message);
29529
29746
  base.warnings.push(text);
29530
29747
  if (ALERT_CODES.has(w.code)) alerts.push(text);
29531
29748
  }
29532
29749
  if (alerts.length) Object.assign(base, { alerts });
29750
+ 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));
29751
+ if (onlyPlansDiffer) {
29752
+ const pending = Number(report.compliance?.counts?.pending_live_check) || 0;
29753
+ 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" } };
29754
+ }
29533
29755
  const status = ciStatusOf(report);
29534
29756
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
29535
29757
  let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
@@ -29540,11 +29762,14 @@ async function runJob(job, engine, config, opts = {}) {
29540
29762
  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
29763
  }
29542
29764
  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)));
29765
+ const cardLines = (report.plan_replacements ?? []).filter((p) => p.disclosure_text);
29766
+ if (pricingPage) fixes = pricingPageFixes(fixes, cardLines);
29767
+ const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b|^mode\b/.test(p));
29768
+ 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)));
29769
+ const spreadOnly = unreadText.length > 0 && unreadText.every((p) => / \(may come from \.\.\./.test(p));
29546
29770
  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".`);
29771
+ 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.*)`;
29772
+ 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
29773
  }
29549
29774
  const literal = [];
29550
29775
  if (unread) {
@@ -29595,7 +29820,7 @@ async function runJob(job, engine, config, opts = {}) {
29595
29820
  ...base,
29596
29821
  status: finalStatus,
29597
29822
  ...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),
29823
+ 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
29824
  ...fixes[0] ? { fix: fixes[0] } : {},
29600
29825
  fixes,
29601
29826
  ...corrected ? { corrected } : {},
@@ -29667,7 +29892,7 @@ async function runCi(jobs, engine, opts) {
29667
29892
  result: resultOf(status, exit, opts.strict)
29668
29893
  };
29669
29894
  }
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;
29895
+ var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, CI_DISCLAIMER, RANK2, OK_STATUSES, BOXED_HTML, BOXED_LINE, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
29671
29896
  var init_runner = __esm({
29672
29897
  "src/ci/runner.ts"() {
29673
29898
  "use strict";
@@ -29676,6 +29901,7 @@ var init_runner = __esm({
29676
29901
  init_src();
29677
29902
  init_types2();
29678
29903
  init_stripe_verdict();
29904
+ init_set_off_fix();
29679
29905
  init_config3();
29680
29906
  ENGINE_KIND = {
29681
29907
  stripe_checkout: "stripe_checkout_session",
@@ -29688,7 +29914,8 @@ var init_runner = __esm({
29688
29914
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
29689
29915
  RANK2 = { fail: 0, needs_review: 1 };
29690
29916
  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.";
29917
+ BOXED_HTML = /border:1px solid currentColor/;
29918
+ 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
29919
  NOTICE_TO_EMAIL = {
29693
29920
  acknowledgment: "acknowledgment",
29694
29921
  trial_ending: "trial_reminder",
@@ -29740,7 +29967,8 @@ function renderHuman(run2, c, opts = {}) {
29740
29967
  lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
29741
29968
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
29742
29969
  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, " ")}`);
29970
+ 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, " ")}`);
29971
+ if (!opts.verbose && x.fixes.length > 4) lines.push(c.dim(` (+${x.fixes.length - 4} more with --verbose)`));
29744
29972
  const alerts = x.alerts ?? [];
29745
29973
  for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
29746
29974
  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, " ")}`));
@@ -29766,8 +29994,9 @@ function annotations(run2) {
29766
29994
  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
29995
  if (!level) continue;
29768
29996
  const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
29997
+ 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" : ""}` : "";
29769
29998
  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");
29999
+ ${x.fix}` : `Could not run: ${x.message ?? ""}` : [removed, 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");
29771
30000
  out.push(`::${level} ${props.join(",")}::${escData(body)}`);
29772
30001
  }
29773
30002
  if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
@@ -29789,7 +30018,7 @@ function stepSummary(run2) {
29789
30018
  }
29790
30019
  const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
29791
30020
  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>`, "");
30021
+ 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
30022
  if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
29794
30023
  if (x.corrected) {
29795
30024
  const fence = x.corrected.body.includes("```") ? "````" : "```";
@@ -29862,6 +30091,7 @@ function renderPlan(p, c, opts = {}) {
29862
30091
  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
30092
  else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
29864
30093
  else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
30094
+ 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
30095
  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
30096
  }
29867
30097
  const hidden = p.tasks.length - tasks.length;
@@ -29873,7 +30103,7 @@ function statusColor(s2, c) {
29873
30103
  return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
29874
30104
  }
29875
30105
  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");
30106
+ 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
30107
  const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
29878
30108
  const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
29879
30109
  if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
@@ -29901,7 +30131,7 @@ function workflowYaml(needs = {}) {
29901
30131
  const node = !!(needs.install || needs.build);
29902
30132
  const steps = [" - uses: actions/checkout@v4"];
29903
30133
  if (node) {
29904
- steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 20");
30134
+ steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 22");
29905
30135
  if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
29906
30136
  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
30137
  } else {
@@ -29997,9 +30227,11 @@ var init_workflow = __esm({
29997
30227
  // src/commands/ci/index.ts
29998
30228
  var ci_exports = {};
29999
30229
  __export(ci_exports, {
30230
+ anchorToChanges: () => anchorToChanges,
30000
30231
  attachSiteForCoupons: () => attachSiteForCoupons,
30001
30232
  couponIdsIn: () => couponIdsIn,
30002
30233
  default: () => ci_default,
30234
+ diffHunks: () => diffHunks,
30003
30235
  siteFactsFor: () => siteFactsFor,
30004
30236
  termsChanged: () => termsChanged,
30005
30237
  touchesConfig: () => touchesConfig,
@@ -30019,6 +30251,38 @@ function termsChanged(cwd, base, jobs) {
30019
30251
  return false;
30020
30252
  }
30021
30253
  }
30254
+ function diffHunks(cwd, base, file) {
30255
+ let out;
30256
+ try {
30257
+ out = execFileSync2("git", ["diff", "-U0", "--no-color", "--no-ext-diff", `${base}...HEAD`, "--", file], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
30258
+ } catch {
30259
+ return [];
30260
+ }
30261
+ const hunks = [];
30262
+ for (const l of out.split("\n")) {
30263
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(l);
30264
+ if (h) hunks.push({ start: Number(h[1]), count: h[2] === void 0 ? 1 : Number(h[2]), removed: [] });
30265
+ else if (hunks.length && l.startsWith("-") && !l.startsWith("---")) hunks[hunks.length - 1].removed.push(l.slice(1));
30266
+ }
30267
+ return hunks;
30268
+ }
30269
+ function anchorToChanges(checks, cwd, workspace, base) {
30270
+ const cache2 = /* @__PURE__ */ new Map();
30271
+ for (const x of checks) {
30272
+ if (!(x.status === "fail" || x.status === "needs_review" || x.status === "error") || !x.target.line || x.failure) continue;
30273
+ const file = resolve7(workspace, x.target.file);
30274
+ if (!cache2.has(file)) cache2.set(file, diffHunks(cwd, base, file));
30275
+ const hunks = cache2.get(file);
30276
+ if (!hunks.length) continue;
30277
+ const line2 = x.target.line;
30278
+ if (hunks.some((h) => h.count > 0 && line2 >= h.start && line2 < h.start + h.count)) continue;
30279
+ 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);
30280
+ const near2 = [...hunks].sort((a, b) => dist(a) - dist(b))[0];
30281
+ if (dist(near2) > 40) continue;
30282
+ x.anchored = { from: line2, ...near2.removed.length && near2.count === 0 ? { removed: near2.removed } : {} };
30283
+ x.target = { ...x.target, line: Math.max(1, near2.count > 0 ? near2.start : near2.start + 1) };
30284
+ }
30285
+ }
30022
30286
  function gitChanged(cwd, base) {
30023
30287
  try {
30024
30288
  const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -30360,6 +30624,7 @@ ${message}
30360
30624
  const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
30361
30625
  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
30626
  const baseRef = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
30627
+ if (baseRef) anchorToChanges(run0.checks, ctx.cwd, workspace, baseRef);
30363
30628
  const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? { ...run0, offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE } : run0;
30364
30629
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
30365
30630
  if (gha) {
@@ -30558,12 +30823,20 @@ function diffLines(a, b) {
30558
30823
  ops.push(...a.slice(a.length - suf).map((line2) => ({ t: " ", line: line2 })));
30559
30824
  return ops;
30560
30825
  }
30826
+ function gitLines(text) {
30827
+ if (text === "") return [];
30828
+ const lines = text.split("\n");
30829
+ if (text.endsWith("\n")) lines.pop();
30830
+ else lines[lines.length - 1] += NO_EOL;
30831
+ return lines;
30832
+ }
30561
30833
  function unifiedDiff(before, after, path, context = 3) {
30562
30834
  if (before === after) return "";
30563
- const a = before.split("\n");
30564
- const b = after.split("\n");
30835
+ const a = gitLines(before);
30836
+ const b = gitLines(after);
30565
30837
  const ops = diffLines(a, b);
30566
30838
  const out = [`--- a/${path}`, `+++ b/${path}`];
30839
+ 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
30840
  let i = 0;
30568
30841
  while (i < ops.length) {
30569
30842
  if (ops[i].t === " ") {
@@ -30585,26 +30858,50 @@ function unifiedDiff(before, after, path, context = 3) {
30585
30858
  const bStart = ops.slice(0, start).filter((o) => o.t !== "-").length + 1;
30586
30859
  const aLen = slice.filter((o) => o.t !== "+").length;
30587
30860
  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}`));
30861
+ out.push(`@@ -${aLen ? aStart : aStart - 1},${aLen} +${bLen ? bStart : bStart - 1},${bLen} @@`, ...slice.flatMap(show2));
30589
30862
  i = stop;
30590
30863
  start = stop;
30591
30864
  }
30592
30865
  return `${out.join("\n")}
30593
30866
  `;
30594
30867
  }
30595
- var escapeRe2, VOID_TAGS;
30868
+ var escapeRe2, VOID_TAGS, NO_EOL, NO_EOL_MARKER;
30596
30869
  var init_patch = __esm({
30597
30870
  "src/commands/plan/patch.ts"() {
30598
30871
  "use strict";
30599
30872
  escapeRe2 = (s2) => s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30600
30873
  VOID_TAGS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"];
30874
+ NO_EOL = "\0no-eol";
30875
+ NO_EOL_MARKER = "\";
30601
30876
  }
30602
30877
  });
30603
30878
 
30604
30879
  // src/commands/plan/fix.ts
30605
30880
  import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
30606
30881
  import { relative as relative6, resolve as resolve8 } from "node:path";
30607
- function fixForTask(task, cwd, file) {
30882
+ function declaredPlans(cwd) {
30883
+ const path = findConfig(cwd);
30884
+ if (!path) return [];
30885
+ try {
30886
+ return loadConfig(path).config.plans ?? [];
30887
+ } catch {
30888
+ return [];
30889
+ }
30890
+ }
30891
+ function staleAgainstDeclared(content, plans) {
30892
+ if (!plans.length || !content) return void 0;
30893
+ const text = content.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
30894
+ const said = [];
30895
+ for (const m of text.matchAll(/(\d{1,3})[- ]days? free,? then \$([\d,]+(?:\.\d{1,2})?) (?:every|per|a|\/) ?(month|year|week|day)/gi)) {
30896
+ const trial = Number(m[1]);
30897
+ const price3 = Number(m[2].replace(/,/g, ""));
30898
+ const interval = m[3].toLowerCase();
30899
+ const same = plans.filter((p) => p.interval === interval && major(p) !== void 0 && Math.abs(major(p) - price3) < 5e-3);
30900
+ 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(", ")}`);
30901
+ }
30902
+ return said.length ? said.join("; ") : void 0;
30903
+ }
30904
+ function fixForTask(task, cwd, file, plans = declaredPlans(cwd)) {
30608
30905
  const base = {
30609
30906
  object: "task_fix",
30610
30907
  task: task.id,
@@ -30615,6 +30912,22 @@ function fixForTask(task, cwd, file) {
30615
30912
  verify: task.verify
30616
30913
  };
30617
30914
  const verifyCmd = `checkbox verify ${task.id} --wait`;
30915
+ const stale = staleAgainstDeclared(`${task.change.content}
30916
+ ${task.change.edits.map((e) => e.content ?? "").join("\n")}`, plans);
30917
+ if (stale) {
30918
+ return {
30919
+ ...base,
30920
+ file: null,
30921
+ diff: null,
30922
+ applied: [],
30923
+ unmatched: [],
30924
+ next_action: {
30925
+ type: "refresh_plan",
30926
+ 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.`,
30927
+ command: "checkbox plan"
30928
+ }
30929
+ };
30930
+ }
30618
30931
  const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
30619
30932
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
30620
30933
  const path = resolve8(cwd, candidate);
@@ -30652,14 +30965,16 @@ function fixForTask(task, cwd, file) {
30652
30965
  const type = task.change.type === "manual" ? "answer_question" : task.change.type === "needs_facts" ? "provide_facts" : "apply_change";
30653
30966
  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
30967
  }
30655
- var TASK_RE2, fix_default;
30968
+ var TASK_RE2, major, fix_default;
30656
30969
  var init_fix = __esm({
30657
30970
  "src/commands/plan/fix.ts"() {
30658
30971
  "use strict";
30659
30972
  init_args();
30660
30973
  init_types2();
30974
+ init_config3();
30661
30975
  init_patch();
30662
30976
  TASK_RE2 = /^task_[0-9a-f]{32}$/;
30977
+ major = (p) => p.price !== void 0 ? Number(p.price) : p.amount !== void 0 ? p.amount / 100 : void 0;
30663
30978
  fix_default = defineCommand({
30664
30979
  name: "fix",
30665
30980
  summary: "Show the change for a plan task (exact diff of your file for code tasks)",
@@ -31371,7 +31686,7 @@ var init_init = __esm({
31371
31686
  " --no-stripe Do not offer the Stripe test key / test subscription",
31372
31687
  " --live Also request live-mode keys and write the domain-verification file",
31373
31688
  " --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)",
31689
+ " --wait-seconds <n> How long to wait for the approval (default 900 on a terminal, 40 with --json, 100 otherwise)",
31375
31690
  " --no-open Do not open the browser",
31376
31691
  " --verify Re-check the site now (after deploying) and print the updated checklist",
31377
31692
  " --complete Finish the setup and email the human the summary (sent automatically the first time every",
@@ -31500,7 +31815,7 @@ var init_init = __esm({
31500
31815
  if (bool(flags, "open") !== false) openBrowser(pending.verification_uri_complete, ctx.env);
31501
31816
  const tty = !!process.stdin.isTTY && !!process.stderr.isTTY;
31502
31817
  const waitArg = str(flags, "wait-seconds");
31503
- const waitSeconds = bool(flags, "wait") === false ? 0 : waitArg !== void 0 ? Number(waitArg) : tty ? 900 : 100;
31818
+ const waitSeconds = bool(flags, "wait") === false ? 0 : waitArg !== void 0 ? Number(waitArg) : tty ? 900 : out.json ? 40 : 100;
31504
31819
  if (!Number.isFinite(waitSeconds) || waitSeconds < 0) throw new UsageError("--wait-seconds must be a number of seconds");
31505
31820
  const polled = await pollDeviceLogin(anon, pending, waitSeconds * 1e3, ctx);
31506
31821
  if (polled.status === "timeout") {
@@ -31873,6 +32188,7 @@ var init_plan = __esm({
31873
32188
  init_args();
31874
32189
  init_types2();
31875
32190
  init_lib();
32191
+ init_fix();
31876
32192
  plan_default = defineCommand({
31877
32193
  name: "plan",
31878
32194
  summary: "Show the compliance plan: ordered tasks to apply, then verify",
@@ -31893,7 +32209,15 @@ var init_plan = __esm({
31893
32209
  const client = ctx.client();
31894
32210
  const site = await resolveSite(ctx, str(flags, "site"), client);
31895
32211
  const plan2 = await client.sdk.plans.retrieve(site);
31896
- ctx.out.result(plan2, () => renderPlan(plan2, ctx.out.c, { all: !!bool(flags, "all") }));
32212
+ const declared = declaredPlans(ctx.cwd);
32213
+ const staleOf = (t) => staleAgainstDeclared(`${t.change.content}
32214
+ ${t.change.edits.map((e) => e.content ?? "").join("\n")}`, declared);
32215
+ const stale = plan2.tasks.filter((t) => (t.status === "open" || t.status === "in_progress") && staleOf(t)).map((t) => t.id);
32216
+ ctx.out.result(stale.length ? { ...plan2, stale_tasks: stale } : plan2, () => {
32217
+ const lines = renderPlan(plan2, ctx.out.c, { all: !!bool(flags, "all"), stale: staleOf });
32218
+ 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.`));
32219
+ return lines;
32220
+ });
31897
32221
  return bool(flags, "exit-code") && plan2.status !== "compliant" ? EXIT.fail : EXIT.ok;
31898
32222
  }
31899
32223
  });