@checkbox-site/cli 0.4.6 → 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 +1232 -210
  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.4";
3620
+ SDK_VERSION = "0.2.6";
3621
3621
  }
3622
3622
  });
3623
3623
 
@@ -4484,7 +4484,23 @@ function exitCodeFor(report, strict) {
4484
4484
  if (report.verdict === "needs_review" && strict) return EXIT.needs_review;
4485
4485
  return EXIT.ok;
4486
4486
  }
4487
+ function renderSchedule(report, out, opts) {
4488
+ const c = out.c;
4489
+ const notices = report.notices ?? [];
4490
+ const where2 = report.jurisdictions === "all" || !report.jurisdictions ? "all jurisdictions" : report.jurisdictions.join(", ");
4491
+ const lines = [`${c.bold("NOTICE SCHEDULE")} ${notices.length} notice${notices.length === 1 ? "" : "s"} to send \xB7 ${where2} \xB7 rules ${report.rules_version ?? "?"} \xB7 catalog ${report.catalog_version ?? "?"}`];
4492
+ for (const n of notices.slice(0, opts.verbose ? 200 : 12)) {
4493
+ lines.push(` ${n.send_date ?? "?"} ${n.kind ?? "notice"}${n.occasion ? c.dim(` \xB7 ${n.occasion}`) : ""}${n.days_before ? c.dim(` (${n.days_before} days before)`) : ""}${n.citations?.length ? c.dim(` \xB7 ${n.citations.slice(0, 2).join("; ")}${n.citations.length > 2 ? " \u2026" : ""}`) : ""}`);
4494
+ }
4495
+ if (!opts.verbose && notices.length > 12) lines.push(c.dim(` \u2026 ${notices.length - 12} more (--verbose or --json)`));
4496
+ const notRequired = report.not_required ?? [];
4497
+ if (notRequired.length && opts.verbose) for (const n of notRequired) lines.push(c.dim(` not required: ${n.kind ?? ""}${n.reason ? ` \u2014 ${n.reason}` : ""}`));
4498
+ if (report.next_action?.message) lines.push("", `${c.cyan("Next:")} ${wrap(report.next_action.message, 110, " ")}`);
4499
+ if (report.disclaimer) lines.push(c.dim(report.disclaimer));
4500
+ return lines;
4501
+ }
4487
4502
  function renderReport(report, out, opts) {
4503
+ if (!report.verdict && Array.isArray(report.notices)) return renderSchedule(report, out, opts);
4488
4504
  const c = out.c;
4489
4505
  const icon2 = (s2) => s2 === "pass" ? c.green("\u2713") : s2 === "fail" ? c.red("\u2717") : s2 === "needs_review" ? c.yellow("?") : c.dim("\u2013");
4490
4506
  const verdict = report.verdict ?? "unknown";
@@ -4518,7 +4534,9 @@ function renderReport(report, out, opts) {
4518
4534
  lines.push(...JSON.stringify(report.correction.patch, null, 2).split("\n").map((l) => ` ${l}`));
4519
4535
  }
4520
4536
  if (report.template && verdict !== "pass" && verdict !== "not_applicable") {
4521
- lines.push("", report.template.verified ? `${c.bold("Suggested template (passes the checks)")} ${c.green("(verified)")}` : c.bold("Suggested template"), ` Subject: ${report.template.subject}`, ...report.template.text.split("\n").map((l) => ` ${l}`));
4537
+ const toFill = report.template.placeholders ?? [];
4538
+ const label = !report.template.verified ? c.bold("Suggested template") : toFill.length ? `${c.bold(`Suggested template (passes the checks once you replace ${toFill.join(", ")} with your real value${toFill.length === 1 ? "" : "s"})`)}` : `${c.bold("Suggested template (passes the checks)")} ${c.green("(verified)")}`;
4539
+ lines.push("", label, ` Subject: ${report.template.subject}`, ...report.template.text.split("\n").map((l) => ` ${l}`));
4522
4540
  }
4523
4541
  if (report.next_action?.message) lines.push("", `${c.cyan("Next:")} ${wrap(report.next_action.message, 110, " ")}`);
4524
4542
  if (report.disclaimer) lines.push(c.dim(report.disclaimer));
@@ -4736,16 +4754,71 @@ var init_stripe_ids = __esm({
4736
4754
  });
4737
4755
 
4738
4756
  // ../core/src/stripe-key.ts
4757
+ function stripeKeyPermissionLine(filter) {
4758
+ return STRIPE_KEY_PERMISSIONS.filter(filter ?? (() => true)).map((p) => `${p.resource}: ${p.access}`).join(", ");
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
+ }
4764
+ function approvalKeyAsk(opts = {}) {
4765
+ if (opts.stripe === false) return "";
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)`;
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.`;
4768
+ }
4769
+ var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL, DEVICE_TEST_KEY_LABEL, OAUTH_TEST_KEY_LABEL, TEST_KEY_CHECKBOX_ID;
4739
4770
  var init_stripe_key = __esm({
4740
4771
  "../core/src/stripe-key.ts"() {
4741
4772
  "use strict";
4773
+ STRIPE_KEY_PERMISSIONS = [
4774
+ { resource: "Customers", access: "Write", why: "Hosted cancellation page: finds the subscriber by email (Read). Test runs: create a test customer (Write, test mode only).", uses: ["fixes", "test_runs"], optional: true, writeWhen: "Used only in test mode (test runs)." },
4775
+ { resource: "Products", access: "Read", why: "Plan names next to your prices.", uses: ["checks", "test_runs"] },
4776
+ { resource: "Prices", access: "Read", why: "Amounts, billing intervals and trial lengths, compared with what your pages say.", uses: ["checks", "test_runs"] },
4777
+ { resource: "Coupons", access: "Read", why: "Only when your hosted cancellation page offers a coupon as the save offer.", uses: ["fixes"], optional: true },
4778
+ { resource: "Subscriptions", access: "Write", why: "Read: which trials subscribers actually get. Write: cancels when a subscriber asks on your hosted cancellation page, and test runs create test subscriptions.", uses: ["checks", "fixes", "test_runs"], writeWhen: "Used only after you turn on the hosted cancellation page (you approve it in one click), or in test mode." },
4779
+ { resource: "Test clocks", access: "Write", why: "Test runs fast-forward through trial end and renewal (test-mode keys only; nothing is charged).", uses: ["test_runs"], optional: true, writeWhen: "Used only in test mode." },
4780
+ { resource: "Customer portal", access: "Write", why: "Read: whether subscribers can cancel online and your portal link. Write: test runs open the portal to cancel the test subscription.", uses: ["checks", "test_runs"], writeWhen: "Used only in test mode (test runs)." },
4781
+ { resource: "Checkout Sessions", access: "Write", why: "Read: your checkout text and terms-consent setting. Write: test runs start a test checkout.", uses: ["checks", "test_runs"], writeWhen: "Used only in test mode (test runs)." },
4782
+ { resource: "Payment Links", access: "Write", why: "Read: consent and renewal text on your Payment Links. Write: the Payment Link fix, only when you click Approve.", uses: ["checks", "fixes"], writeWhen: "Used only after you click Approve on the Payment Link fix, or in test mode." },
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." }
4784
+ ];
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";
4742
4789
  }
4743
4790
  });
4744
4791
 
4745
4792
  // ../core/src/go-live.ts
4793
+ function goLiveSteps(input = {}) {
4794
+ const out = [];
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." });
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 });
4797
+ if (!input.liveKeyConnected)
4798
+ out.push({
4799
+ key: "live_stripe_key",
4800
+ who: "person",
4801
+ text: "Switch the dashboard to Live mode and connect your live Stripe key (rk_live_\u2026) with the same permissions as your test key (the checklist next to the key field: Read for the checks; Write only for fixes you approve and to add Checkbox\u2019s webhook endpoint). Only a person adds live keys; an agent never does."
4802
+ });
4803
+ if (input.checkoutKind === "payment_link")
4804
+ out.push({ key: "live_checkout", who: "person", text: "Create the live Payment Link in Stripe live mode with the same settings as the test link Checkbox checked (renewal terms, consent checkbox, cancellation link); then your agent puts the live link on your site in place of the test link." });
4805
+ else if (input.checkoutKind === "stripe_checkout")
4806
+ out.push({ key: "live_checkout", who: "agent", text: "Ship the same Checkout Session params Checkbox checked with your live Price ids (the live Stripe secret key in your production settings is set by a person)." });
4807
+ out.push({
4808
+ key: "promote_site",
4809
+ who: "person",
4810
+ text: `Promote the site to live: Checkbox copies your Checkbox Notices settings, confirmed cancellation policy, support email and New York answers.${input.checkboxSends === false ? "" : " If Checkbox sends your emails, preview them and confirm once that it may email your real subscribers."}`
4811
+ });
4812
+ return out;
4813
+ }
4814
+ var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_TERMS_URL_STEP_CONDITIONAL, GO_LIVE_STEPS;
4746
4815
  var init_go_live = __esm({
4747
4816
  "../core/src/go-live.ts"() {
4748
4817
  "use strict";
4818
+ STRIPE_PUBLIC_DETAILS = "https://dashboard.stripe.com/settings/public";
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.';
4821
+ GO_LIVE_STEPS = goLiveSteps().map((s2) => s2.text);
4749
4822
  }
4750
4823
  });
4751
4824
 
@@ -4788,6 +4861,13 @@ var init_secrets = __esm({
4788
4861
  }
4789
4862
  });
4790
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
+
4791
4871
  // ../core/src/index.ts
4792
4872
  var init_src2 = __esm({
4793
4873
  "../core/src/index.ts"() {
@@ -4799,6 +4879,7 @@ var init_src2 = __esm({
4799
4879
  init_stripe_key();
4800
4880
  init_go_live();
4801
4881
  init_secrets();
4882
+ init_site_unreachable();
4802
4883
  }
4803
4884
  });
4804
4885
 
@@ -4988,7 +5069,7 @@ var init_california = __esm({
4988
5069
  notes: notes(
4989
5070
  AB2863_APPLICABILITY,
4990
5071
  "Misrepresentation is only partly machine-detectable; the auto-renewal language check catches the most common case (offers that hide or deny the renewal).",
4991
- 'Severity raised from medium to critical (catalog 2026.11.01.1, docs/legal/LITIGATION_PRIORITIES.md): misrepresentation is the theory behind the largest recent outcomes \u2014 "cancel at any time" without a prorated refund (Sanderson v. Whoop, sent to a jury in 2026), "no commitment" packs that renewed (FTC v. Shutterstock, $35M), a hidden early-termination fee (Adobe: Justice Department complaint filed June 2024).',
5072
+ 'Severity raised from medium to critical (catalog 2026.11.01.1, docs/legal/LITIGATION_PRIORITIES.md): misrepresentation is the theory behind the largest recent outcomes \u2014 "cancel at any time" without a prorated refund (Sanderson v. Whoop, sent to a jury in 2026), "no commitment" packs that renewed (FTC v. Shutterstock: proposed $35M order filed May 2026), a hidden early-termination fee (Adobe: Justice Department complaint filed June 2024).',
4992
5073
  'CHECK GAP: no check yet flags "cancel anytime" / "no commitment" copy contradicted by the refund or term policy, or a one-time purchase that enrolls the buyer in a subscription (product opportunity: a rollover and misrepresentation check). terms.refund_policy_consistent covers advertised refund promises.'
4993
5074
  )
4994
5075
  }),
@@ -5837,7 +5918,7 @@ var init_new_york = __esm({
5837
5918
  NYC_EFFECTIVE = "2026-10-01";
5838
5919
  NYC = "6 RCNY \xA7 5-110.1";
5839
5920
  NYC_RULE_VERIFIED = "2026-10-31";
5840
- 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.";
5841
5922
  PART_W_NOTE = "Current text from L. 2025, ch. 58, Part W, effective Nov. 5, 2025.";
5842
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.";
5843
5924
  NEW_YORK_REQUIREMENTS = [
@@ -6286,12 +6367,12 @@ var init_new_york = __esm({
6286
6367
  sourceUrl: NYC_RULE_URL,
6287
6368
  effectiveDate: NYC_EFFECTIVE,
6288
6369
  lastVerified: NYC_RULE_VERIFIED,
6289
- 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.",
6290
6371
  status: "in_force",
6291
6372
  confidence: "medium",
6292
6373
  verification: "primary",
6293
6374
  requirementIdPrefixes: ["US-NY.NYC."],
6294
- 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).",
6295
6376
  amendments: [
6296
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).' }
6297
6378
  ]
@@ -10157,11 +10238,11 @@ function findRecurringPrices(text, amounts, freqs) {
10157
10238
  let best = null;
10158
10239
  for (const f of freqs) {
10159
10240
  const after = f.index >= money3.index;
10160
- const gap = after ? f.index - (money3.index + money3.text.length) : money3.index - (f.index + f.text.length);
10161
- if (gap < -1) continue;
10162
- 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;
10163
10244
  if (SENTENCE_BREAK_RE.test(between(text, money3, f))) continue;
10164
- if (!best || gap < best.gap) best = { f, gap };
10245
+ if (!best || gap2 < best.gap) best = { f, gap: gap2 };
10165
10246
  }
10166
10247
  if (best) {
10167
10248
  const start = Math.min(money3.index, best.f.index);
@@ -11440,10 +11521,10 @@ function withIntroOffers(declared, billing, offers) {
11440
11521
  }
11441
11522
  function shortPlanTerms(p) {
11442
11523
  const digits = currencyDigitsOf(p.currency);
11443
- const major = p.amount / 10 ** digits;
11524
+ const major2 = p.amount / 10 ** digits;
11444
11525
  const cur = (p.currency || "usd").toLowerCase();
11445
11526
  const sym = { usd: "$", eur: "\u20AC", gbp: "\xA3", jpy: "\xA5" }[cur];
11446
- const num3 = Number.isInteger(major) ? String(major) : major.toFixed(digits);
11527
+ const num3 = Number.isInteger(major2) ? String(major2) : major2.toFixed(digits);
11447
11528
  const money3 = sym ? `${sym}${num3}` : `${num3} ${cur.toUpperCase()}`;
11448
11529
  const every = (p.intervalCount || 1) > 1 ? ` every ${p.intervalCount} ${p.interval}s` : `/${p.interval}`;
11449
11530
  const trial = (p.trialDays ?? 0) > 0 ? ` with a ${p.trialDays}-day free trial` : "";
@@ -11713,11 +11794,11 @@ function formatMoney(amount, currency) {
11713
11794
  } catch {
11714
11795
  digits = 2;
11715
11796
  }
11716
- const major = amount / 10 ** digits;
11797
+ const major2 = amount / 10 ** digits;
11717
11798
  try {
11718
- 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);
11719
11800
  } catch {
11720
- return `${major.toFixed(digits)} ${code2}`;
11801
+ return `${major2.toFixed(digits)} ${code2}`;
11721
11802
  }
11722
11803
  }
11723
11804
  function intervalPhrase(interval, count = 1) {
@@ -12106,6 +12187,13 @@ function checkoutSessionsOf(config) {
12106
12187
  if (!Array.isArray(raw)) return [];
12107
12188
  return raw.filter((s2) => !!s2 && typeof s2 === "object" && typeof s2.id === "string" && Array.isArray(s2.lineItems));
12108
12189
  }
12190
+ function withTestRunSessions(sessions, runSessionIds) {
12191
+ if (!runSessionIds.size) return [...sessions];
12192
+ return sessions.map((s2) => !s2.checkboxTestRun && runSessionIds.has(s2.id) ? { ...s2, checkboxTestRun: true } : s2);
12193
+ }
12194
+ function testRunSessionIds(runs) {
12195
+ return new Set(runs.map((r) => r.state?.sessionId).filter((x) => typeof x === "string" && x.startsWith("cs_")));
12196
+ }
12109
12197
  function newestFirst(sessions) {
12110
12198
  return sessions.map((s2, i) => ({ s: s2, i })).sort((a, b) => (typeof b.s.created === "number" ? b.s.created : -Infinity) - (typeof a.s.created === "number" ? a.s.created : -Infinity) || a.i - b.i).map((x) => x.s);
12111
12199
  }
@@ -13333,7 +13421,7 @@ function checkoutConfigViews(ctx) {
13333
13421
  return (ctx.checkoutConfigs ?? []).filter((c) => c && c.capture).map((c) => ({
13334
13422
  ...buildSignupView(c.capture),
13335
13423
  label: `${c.kind === "payment_link" ? "Stripe Payment Link" : "Stripe Checkout config"} \u201C${c.name ?? c.id}\u201D`,
13336
- 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 } : {} },
13337
13425
  // A Payment Link is read from Stripe on every billing read: it shows the link as of that read.
13338
13426
  ...c.kind === "payment_link" && readAt ? { observedAt: readAt } : {}
13339
13427
  }));
@@ -13801,7 +13889,7 @@ function platformKey(platform) {
13801
13889
  }
13802
13890
  function portalUnlinkedFix(portalUrl) {
13803
13891
  const login = portalUrl && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(portalUrl) ? portalUrl : "billing.stripe.com/p/login/\u2026";
13804
- 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.`;
13805
13893
  }
13806
13894
  function paymentLinkCancelRemediation(portalUrl, loginPageOn) {
13807
13895
  const link = portalUrl && /^https:\/\/billing\.stripe\.com\//.test(portalUrl) ? portalUrl : void 0;
@@ -14907,6 +14995,18 @@ var init_help_pages = __esm({
14907
14995
  });
14908
14996
 
14909
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
+ }
14910
15010
  function inAppPath(input) {
14911
15011
  const flow = input.ctx.cancellation?.via === "stripe_portal" ? void 0 : input.ctx.cancellation;
14912
15012
  if (flow) {
@@ -14942,7 +15042,7 @@ function inAppPath(input) {
14942
15042
  return true;
14943
15043
  }
14944
15044
  });
14945
- 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 } : {} };
14946
15046
  }
14947
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" };
14948
15048
  return void 0;
@@ -15620,6 +15720,16 @@ var init_cancellation = __esm({
15620
15720
  { unverified: true, source: "snippet" }
15621
15721
  );
15622
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
+ }
15623
15733
  if (controls.length) {
15624
15734
  return makeResult(
15625
15735
  id,
@@ -16043,6 +16153,16 @@ var init_config2 = __esm({
16043
16153
  }
16044
16154
  if (c.selfServeOnline === false) {
16045
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
+ }
16046
16166
  if (model.otherOnlineWorks) {
16047
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`;
16048
16168
  return makeResult(
@@ -16248,8 +16368,8 @@ function introEmailFinding(rawText, plans, opts) {
16248
16368
  const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
16249
16369
  if (!usable.length) return void 0;
16250
16370
  const text = normalizeText(rawText);
16251
- const major = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16252
- 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 ?? []];
16253
16373
  const isRegular = (n) => regular.some((r) => Math.abs(r - n) < 5e-3);
16254
16374
  const offRegular = (sentence) => findAmounts(sentence).filter((m) => {
16255
16375
  if (typeof m.amount !== "number" || !(m.amount > 0) || isRegular(m.amount)) return false;
@@ -16296,7 +16416,7 @@ function introEmailFinding(rawText, plans, opts) {
16296
16416
  };
16297
16417
  }
16298
16418
  const fits = (p2) => {
16299
- const intro = major(p2, p2.introAmount);
16419
+ const intro = major2(p2, p2.introAmount);
16300
16420
  const pct = (1 - p2.introAmount / p2.amount) * 100;
16301
16421
  return statedAmounts.every((m) => Math.abs(m.amount - intro) < 5e-3) && percents.every((x) => Math.abs(x.value - pct) <= 0.5);
16302
16422
  };
@@ -16325,6 +16445,7 @@ function introEmailFinding(rawText, plans, opts) {
16325
16445
  fix: `State how long the promo really lasts: ${introTermsPhrase(p2)}.`
16326
16446
  };
16327
16447
  }
16448
+ if (statesIntroEndDate(text)) return void 0;
16328
16449
  const p = fitting[0];
16329
16450
  return {
16330
16451
  status: "needs_review",
@@ -16334,29 +16455,32 @@ function introEmailFinding(rawText, plans, opts) {
16334
16455
  fix: `State how long the promo lasts and the price after it: ${introTermsPhrase(p)}.`
16335
16456
  };
16336
16457
  }
16458
+ function statesIntroEndDate(text) {
16459
+ return INTRO_END_DATE_RES.some((re) => re.test(text));
16460
+ }
16337
16461
  function introFirstChargeFinding(rawText, plans) {
16338
16462
  const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
16339
16463
  if (!usable.length) return void 0;
16340
16464
  const text = normalizeText(rawText);
16341
16465
  const stated = findAmounts(text).filter((m) => typeof m.amount === "number" && m.amount > 0);
16342
- const major = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16466
+ const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
16343
16467
  const states = (n) => stated.some((m) => Math.abs(m.amount - n) < 5e-3);
16344
- const named = usable.filter((p2) => states(major(p2, p2.amount)));
16468
+ const named = usable.filter((p2) => states(major2(p2, p2.amount)));
16345
16469
  if (!named.length || !named.every(hasIntro)) return void 0;
16346
- 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;
16347
16471
  if (new RegExp(PERCENT_OFF_RE.source, "i").test(text)) return void 0;
16348
16472
  const p = named[0];
16349
16473
  const regular = money(p.amount, p.currency);
16350
16474
  const nameOf = p.label?.trim() || (p.name?.trim() ? `plan \u201C${p.name.trim()}\u201D` : `the ${regular} ${planEvery(p)} plan`);
16351
16475
  return {
16352
16476
  status: "fail",
16353
- 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,
16354
16478
  reason: "intro_first_charge",
16355
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`,
16356
16480
  fix: `State the first charge after the trial and the price after it: ${introTermsPhrase(p)}.`
16357
16481
  };
16358
16482
  }
16359
- 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;
16360
16484
  var init_intro_terms = __esm({
16361
16485
  "../rules/src/checks/intro-terms.ts"() {
16362
16486
  "use strict";
@@ -16372,10 +16496,86 @@ var init_intro_terms = __esm({
16372
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;
16373
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;
16374
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
+ ];
16375
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;
16376
16509
  }
16377
16510
  });
16378
16511
 
16512
+ // ../rules/src/checks/charge-claims.ts
16513
+ function sentenceSpan(text, index) {
16514
+ const dot = text.lastIndexOf(". ", index);
16515
+ const start = Math.max(dot >= 0 ? dot + 2 : 0, text.lastIndexOf("\n", index) + 1);
16516
+ const ends = [text.indexOf(". ", index), text.indexOf("\n", index)].filter((n) => n >= 0);
16517
+ return { start: Math.min(start, index), end: ends.length ? Math.min(...ends) + 1 : text.length };
16518
+ }
16519
+ function misstatedRecurringCharge(text, prices, plans, others = []) {
16520
+ const usable = plans.filter((p) => p.amount > 0 && p.interval);
16521
+ if (!usable.length) return void 0;
16522
+ const known = [...usable.map((p) => p.amount), ...others].filter((n) => n > 0);
16523
+ const same = (a, b) => Math.abs(a - b) < 5e-3;
16524
+ const statesPlanPrice = prices.some((r) => r.money.amount !== null && known.some((k) => same(k, r.money.amount)));
16525
+ if (!statesPlanPrice) return void 0;
16526
+ return prices.find((r) => {
16527
+ const amount = r.money.amount;
16528
+ const unit2 = r.frequency.unit;
16529
+ if (amount === null || !(amount > 0) || !unit2) return false;
16530
+ if (known.some((k) => same(k, amount))) return false;
16531
+ const cur = (r.money.currency || "").toLowerCase();
16532
+ const plansHere = usable.filter((p) => !cur || !p.currency || p.currency.toLowerCase() === cur);
16533
+ if (!plansHere.some((p) => frequencyMatchesPlan(unit2, r.frequency.count, p))) return false;
16534
+ if (plansHere.some((p) => {
16535
+ const k = amount / p.amount;
16536
+ return k >= 2 && Math.abs(k - Math.round(k)) < 1e-3;
16537
+ })) return false;
16538
+ if (isPerPeriodEquivalent(r, plansHere)) return false;
16539
+ const at = r.money.index;
16540
+ if (!(at >= 0) || text.slice(at, at + r.money.text.length) !== r.money.text) return false;
16541
+ {
16542
+ const { start, end } = sentenceSpan(text, at);
16543
+ const sentence = text.slice(start, end);
16544
+ const around = text.slice(Math.max(start, at - 24), Math.min(end, at + r.money.text.length + 40));
16545
+ if (NOT_THE_CHARGE_AROUND_RE.test(around) || NOT_A_PRICE_AROUND_RE.test(around)) return false;
16546
+ if (INTRO_CUE_RE.test(sentence) || INTRO_SENTENCE_RE.test(sentence)) return false;
16547
+ const before = text.slice(Math.max(start, at - 32), at);
16548
+ const adjacent = prices.some((o) => {
16549
+ if (o === r || o.money.amount === null || o.money.index < 0 || !known.some((k) => same(k, o.money.amount))) return false;
16550
+ const [a, b] = o.money.index < at ? [o.money, r.money] : [r.money, o.money];
16551
+ return /^[\s()[\]/,:–—-]*$/.test(text.slice(a.index + a.text.length, b.index));
16552
+ });
16553
+ if (!adjacent && !CHARGE_CUE_BEFORE_RE.test(before)) return false;
16554
+ }
16555
+ return true;
16556
+ });
16557
+ }
16558
+ function isPerPeriodEquivalent(r, plans) {
16559
+ const amount = r.money.amount;
16560
+ const unit2 = r.frequency.unit;
16561
+ if (amount === null || !(amount > 0) || !unit2) return false;
16562
+ const cur = (r.money.currency || "").toLowerCase();
16563
+ return plans.some(
16564
+ (p) => p.amount > 0 && (!cur || !p.currency || p.currency.toLowerCase() === cur) && !frequencyMatchesPlan(unit2, r.frequency.count, p) && Math.abs(amount * periodDays(p.interval, p.intervalCount) / periodDays(unit2, r.frequency.count) - p.amount) <= Math.max(0.02 * p.amount, 0.05)
16565
+ );
16566
+ }
16567
+ var NOT_THE_CHARGE_AROUND_RE, INTRO_SENTENCE_RE, CHARGE_CUE_BEFORE_RE;
16568
+ var init_charge_claims = __esm({
16569
+ "../rules/src/checks/charge-claims.ts"() {
16570
+ "use strict";
16571
+ init_plan_terms();
16572
+ init_intro_terms();
16573
+ NOT_THE_CHARGE_AROUND_RE = /\b(?:off|save[sd]?|saving|savings|value[sd]?|worth|reward|bonus|max(?:imum)?|up\s+to|prorat\w*|earlier|previous(?:ly)?|used\s+to|last\s+(?:year|month|week|period|time)|from|was|were|previously|formerly|regular(?:ly)?|normally|usually|instead\s+of|compared|vs\.?|versus|old|reg\.|retail|list\s+price|add-?ons?|extra|additional|per\s+(?:seat|user|member|license|licence|unit))\b/i;
16574
+ INTRO_SENTENCE_RE = /\btrial\b|\bfree\b|\buntil\b[^.]*\bthen\b|\bfor\s+(?:the\s+|your\s+)?first\b|\bafter\s+(?:the\s+)?(?:first|trial|intro)/i;
16575
+ CHARGE_CUE_BEFORE_RE = /\b(?:charg\w*|bill(?:s|ed|ing)?|pay(?:s|ing|ment)?|renew(?:s|al|ing)?|costs?|total|due|amount|price)\b[^$€£\d]*$/i;
16576
+ }
16577
+ });
16578
+
16379
16579
  // ../rules/src/checks/notice-content.ts
16380
16580
  function offlineOnlyCancelIn(text, cancelMethods) {
16381
16581
  const offline = contactOnlySentence(text);
@@ -16414,11 +16614,21 @@ function noticeContent(e, opts = {}) {
16414
16614
  ];
16415
16615
  const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
16416
16616
  const stated = positiveAmounts(a).filter((m) => m.amount !== null);
16617
+ const toMajor = (p, minor) => minor / 10 ** currencyDigitsOf(p.currency);
16618
+ const chargePlans = plans.map((p) => ({ amount: toMajor(p, p.amount), currency: p.currency, interval: p.interval, intervalCount: p.intervalCount }));
16619
+ const misstated = plans.length ? misstatedRecurringCharge(
16620
+ a.text,
16621
+ a.recurringPrices,
16622
+ chargePlans,
16623
+ [...expected, ...plans.flatMap((p) => typeof p.introAmount === "number" && p.introAmount > 0 ? [toMajor(p, p.introAmount)] : [])]
16624
+ ) : void 0;
16417
16625
  if (amount && expected.length && stated.length && !stated.some((m) => expected.some((x) => Math.abs(x - m.amount) < 5e-3))) wrong.amount = quote(amount);
16626
+ else if (misstated) wrong.amount = quote(misstated.money);
16418
16627
  else if (amount) quotes.amount = quote(amount);
16419
16628
  const freq = a.recurringPrices[0]?.frequency ?? a.frequencies[0];
16420
16629
  const freqs = [...a.recurringPrices.map((r) => r.frequency), ...a.frequencies].filter((f) => f.unit);
16421
- const charged = a.recurringPrices.map((r) => r.frequency).filter((f) => !!f?.unit);
16630
+ const planPriceStated = stated.some((m) => chargePlans.some((p) => Math.abs(p.amount - m.amount) < 5e-3));
16631
+ const charged = a.recurringPrices.filter((r) => !!r.frequency?.unit && !(planPriceStated && isPerPeriodEquivalent(r, chargePlans))).map((r) => r.frequency);
16422
16632
  const misses = (f) => !plans.some((p) => frequencyMatchesPlan(f.unit, f.count, p));
16423
16633
  const wrongCharged = plans.length ? charged.find(misses) : void 0;
16424
16634
  if (wrongCharged) wrong.frequency = quote(wrongCharged);
@@ -16485,6 +16695,7 @@ var init_notice_content = __esm({
16485
16695
  init_context();
16486
16696
  init_intro_terms();
16487
16697
  init_result();
16698
+ init_charge_claims();
16488
16699
  ELEMENT_LABELS = {
16489
16700
  amount: "the amount charged",
16490
16701
  frequency: "how often the subscription is charged",
@@ -16770,7 +16981,7 @@ function withoutRevisedDrafts(emails) {
16770
16981
  const draft2 = (e) => !e.subscriptionRef && !e.relatedSignupAt && !e.testRun && !isCheckboxSentEmail(e);
16771
16982
  const trial = (e) => /\b(?:free\s+)?trial\b/i.test(`${e.subject ?? ""} ${emailBody(e)}`);
16772
16983
  const at = (e) => toDate(e.receivedAt)?.getTime() ?? 0;
16773
- 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))));
16774
16985
  }
16775
16986
  function latestPerTemplateAndPlan(emails, input) {
16776
16987
  const offers = contextOffers(input.ctx);
@@ -17683,29 +17894,29 @@ function contentGap(required, emails, opts = {}) {
17683
17894
  }
17684
17895
  return null;
17685
17896
  }
17686
- function contentFail(spec, required, gap, timing, refs2, data) {
17687
- const intro = gap.intro;
17688
- 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`;
17689
17900
  const introData = intro ? { contentIntro: { status: intro.status, reason: intro.reason, quote: intro.quote, fix: intro.fix } } : {};
17690
- if (!gap.missing.length && intro) {
17901
+ if (!gap2.missing.length && intro) {
17691
17902
  return makeResult(
17692
17903
  spec.checkId,
17693
17904
  intro.status,
17694
- `The ${spec.noun} (${emailLabel(gap.email)}) ${sentWhat} ${intro.detail}`,
17695
- [...emailRefs(gap.email), ...refs2],
17905
+ `The ${spec.noun} (${emailLabel(gap2.email)}) ${sentWhat} ${intro.detail}`,
17906
+ [...emailRefs(gap2.email), ...refs2],
17696
17907
  intro.status === "fail" ? 0.8 : 0.5,
17697
- { ...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 }
17698
17909
  );
17699
17910
  }
17700
17911
  return makeResult(
17701
17912
  spec.checkId,
17702
17913
  "fail",
17703
- `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
17704
17915
  // element says its own reason.
17705
- 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}` : ""}`,
17706
- [...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],
17707
17918
  0.8,
17708
- { ...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 }
17709
17920
  );
17710
17921
  }
17711
17922
  async function runReminder(spec, input) {
@@ -17805,8 +18016,8 @@ async function runReminder(spec, input) {
17805
18016
  const w = conflicting ? "within each state\u2019s window" : why(window);
17806
18017
  if (counted.length) {
17807
18018
  if (!outside.length && !missing.length && !partial.length) {
17808
- const gap = contentGap(required, matched.flatMap((j) => j.email ? [j.email] : []), contentOpts);
17809
- 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);
17810
18021
  return makeResult(
17811
18022
  id,
17812
18023
  "pass",
@@ -17897,8 +18108,8 @@ async function runReminder(spec, input) {
17897
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);
17898
18109
  }
17899
18110
  if (inWindow.length) {
17900
- const gap = contentGap(required, inWindow.map((t) => t.e), contentOpts);
17901
- 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);
17902
18113
  return makeResult(
17903
18114
  id,
17904
18115
  "pass",
@@ -17910,8 +18121,8 @@ async function runReminder(spec, input) {
17910
18121
  }
17911
18122
  if (nearWindow.length) {
17912
18123
  const n = nearWindow[0];
17913
- const gap = contentGap(required, nearWindow.map((t) => t.e), contentOpts);
17914
- 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);
17915
18126
  data.timingNearWindow = { days: n.days, required: conflicting ? w : why(n.w), roundingRule: DAYS_ROUNDING_RULE, ...n.range ? { range: n.range } : {} };
17916
18127
  data.timingOutsideWindow = { days: n.days, required: conflicting ? w : why(n.w) };
17917
18128
  return makeResult(
@@ -19207,8 +19418,8 @@ function gatedByOwnBox(renewalBoxes, path) {
19207
19418
  return renewalBoxes.some((b) => b.required === true && !b.checkedByDefault && consentGate(b, path) === "gated");
19208
19419
  }
19209
19420
  function boxNearCta(box, cta) {
19210
- 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;
19211
- 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;
19212
19423
  }
19213
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;
19214
19425
  var init_signup = __esm({
@@ -19223,6 +19434,7 @@ var init_signup = __esm({
19223
19434
  init_offer_view();
19224
19435
  init_plan_terms();
19225
19436
  init_cancellation();
19437
+ init_charge_claims();
19226
19438
  init_intro_terms();
19227
19439
  init_intro_terms();
19228
19440
  init_result();
@@ -19388,6 +19600,29 @@ var init_signup = __esm({
19388
19600
  const pl = view.plan?.plan;
19389
19601
  return !!pl && hasIntro(pl) && r.money.amount !== null && Math.abs(r.money.amount - pl.introAmount / 10 ** currencyDigits(pl.currency || "usd")) < 5e-3;
19390
19602
  };
19603
+ if (!tied && plans.length) {
19604
+ const sitePrices = withAccountPrices(referencePlans(input.ctx), input.ctx).map((p) => p.amount / 10 ** currencyDigits((p.currency || "usd").toLowerCase()));
19605
+ const intros = [...plans.map((p) => p.plan), ...view.plan ? [view.plan.plan] : []].flatMap(
19606
+ (pl) => hasIntro(pl) ? [pl.introAmount / 10 ** currencyDigits((pl.currency || "usd").toLowerCase())] : []
19607
+ );
19608
+ const misstated = misstatedRecurringCharge(
19609
+ near2.text,
19610
+ parsed,
19611
+ plans.map((p) => ({ amount: p.amount, currency: p.currency, interval: p.plan.interval, intervalCount: p.plan.intervalCount })),
19612
+ [...sitePrices, ...intros]
19613
+ );
19614
+ if (misstated && !NON_CHARGE_AMOUNT_RE.test(quote(misstated) ?? "") && !INTRO_PRICE_RE.test(quote(misstated) ?? "")) {
19615
+ const said = misstated.money.text;
19616
+ const own = quote(parsed.find(recurringMatches) ?? rp) ?? "";
19617
+ const firm = decisive && input.ctx.facts?.partialPlans !== true && !otherPlanCard(view, input);
19618
+ return unit(
19619
+ firm ? "fail" : "needs_review",
19620
+ `the terms state \u201C${own}\u201D but also \u201C${said}\u201D for the same billing period, and ${plans.length === 1 ? "the plan charges" : "the plans charge"} ${plans.slice(0, 4).map((p) => p.label).join(", ")} \u2014 state only the recurring charge subscribers will actually pay`,
19621
+ firm ? 0.85 : 0.6,
19622
+ { quote: said, meta: { ...meta(misstated.money.amount, misstated.money.currency), priceMismatch: true, secondCharge: true } }
19623
+ );
19624
+ }
19625
+ }
19391
19626
  const foreign = tied && plans.length ? parsed.find((r) => r.frequency.unit && !recurringMatches(r) && !equivalentOfPlan(r) && !ownIntro(r) && !INTRO_PRICE_RE.test(quote(r) ?? "")) : void 0;
19392
19627
  if (foreign) {
19393
19628
  const own = parsed.find(recurringMatches) ?? rp;
@@ -19689,8 +19924,18 @@ var init_signup = __esm({
19689
19924
  const offers = contextOffers(input.ctx);
19690
19925
  const loose = !view.plan || view.plan.how === "only_plan";
19691
19926
  const cardOffer = loose && (view.offer || view.checkoutConfig) && offers.length > 1 ? offerForView(view, offers) : void 0;
19692
- 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;
19693
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
+ }
19694
19939
  if (!mentioned) {
19695
19940
  if (planTrial === 0 && offers.some((o) => o.trialDays > 0)) {
19696
19941
  const withTrial = offers.filter((o) => o.trialDays > 0).map(offerName);
@@ -19715,7 +19960,7 @@ var init_signup = __esm({
19715
19960
  const meta = {};
19716
19961
  if (length && "days" in length && length.days !== null) meta.disclosedTrialDays = length.days;
19717
19962
  if (input.facts.trialDays) meta.configuredTrialDays = input.facts.trialDays;
19718
- 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;
19719
19964
  const trialPlans = [
19720
19965
  ...(input.ctx.config?.plans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: configPlanSource(input.ctx) })),
19721
19966
  ...(input.ctx.referencePlans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: pl.source }))
@@ -22025,6 +22270,24 @@ var init_brand_scope = __esm({
22025
22270
  }
22026
22271
  });
22027
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
+
22028
22291
  // ../rules/src/status.ts
22029
22292
  function tierOf(checkId) {
22030
22293
  return CHECK_TIERS[checkId] ?? "required";
@@ -22121,10 +22384,18 @@ function cancelTestProblem(raw) {
22121
22384
  }
22122
22385
  function policyUnreadableFix(text) {
22123
22386
  const n = /\((\d+) characters/.exec(text)?.[1];
22124
- if (n !== void 0 || /almost no text/.test(text)) {
22125
- 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.`;
22126
22394
  }
22127
- 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.";
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.`;
22397
+ }
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.`;
22128
22399
  }
22129
22400
  function finish(base, reason, checkId, tier = tierOf(checkId)) {
22130
22401
  if (reason === "ny_owner_decision") return { ...base, label: NY_EMAIL_ONLY_LABEL, reason, tier };
@@ -22359,6 +22630,7 @@ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_L
22359
22630
  var init_status = __esm({
22360
22631
  "../rules/src/status.ts"() {
22361
22632
  "use strict";
22633
+ init_set_off_fix();
22362
22634
  init_notice_content();
22363
22635
  init_notices_on();
22364
22636
  init_plan_terms();
@@ -22647,17 +22919,30 @@ var init_status = __esm({
22647
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." }) },
22648
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." }) },
22649
22921
  // Normal round 2 (nextjs, django): "looks like the surrounding text" is a measurement Checkbox cannot finish (borders,
22650
- // 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.
22651
22929
  {
22652
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",
22653
22937
  category: "legal_choice",
22654
- when: has(/look like the surrounding text/),
22655
- build: () => ({
22938
+ when: (c) => c.kind === "disclosure" && /look like the surrounding text/.test(c.text),
22939
+ build: (c) => ({
22656
22940
  status: "your_call",
22941
+ fix: setOffFix(c.text),
22657
22942
  question: ask(
22658
- "Confirm by eye: does the renewal line stand out from the text around it (semibold, a brand colour, or a border)?",
22659
- "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).",
22660
- "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."
22661
22946
  )
22662
22947
  })
22663
22948
  },
@@ -23005,7 +23290,7 @@ var init_status = __esm({
23005
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) }) }
23006
23291
  ];
23007
23292
  STATUS_RULES = RULES.map((r) => ({ code: r.code, category: r.category }));
23008
- NY_EMAIL_ONLY_LABEL = "Your decision: email only";
23293
+ NY_EMAIL_ONLY_LABEL = "Your decision: email only (not verified)";
23009
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.';
23010
23295
  STRIPE_HOSTED_UNITS = /* @__PURE__ */ new Set(["checkout_config", "payment_link", "stripe_hosted"]);
23011
23296
  PLATFORM_NAMES2 = { paddle: "Paddle", shopify: "Shopify", recharge: "Recharge", chargebee: "Chargebee", recurly: "Recurly" };
@@ -23807,12 +24092,12 @@ function disclosureParts(text) {
23807
24092
  function disclosureHtml(text) {
23808
24093
  return disclosureParts(text).map((p) => "url" in p ? `<a href="${esc(p.url)}">${esc(p.label)}</a>` : esc(p.text)).join("");
23809
24094
  }
23810
- function htmlDisclosureComponent(texts, sha2562) {
24095
+ function htmlDisclosureComponent(texts, sha2562, opts = {}) {
23811
24096
  return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
23812
24097
  Paste INSIDE your sign-up <form>, directly ABOVE the subscribe / purchase button.
23813
- One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
23814
- 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. -->
23815
- <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 : ""}">
23816
24101
  <p style="margin:0 0 6px;font-weight:600;">${disclosureHtml(texts.disclosure)}</p>
23817
24102
  <label style="display:flex;gap:8px;align-items:flex-start;">
23818
24103
  <!-- Never add "checked": consent must be an affirmative act. -->
@@ -23822,23 +24107,26 @@ function htmlDisclosureComponent(texts, sha2562) {
23822
24107
  </div>
23823
24108
  <button type="submit">${esc(texts.cta)}</button>`;
23824
24109
  }
23825
- function htmlTermsOnly(disclosure, sha2562, onStripe) {
24110
+ function htmlTermsOnly(disclosure, sha2562, onStripe, opts = {}) {
23826
24111
  const where2 = onStripe === "payment_link" ? "Payment Link" : "Stripe Checkout";
23827
24112
  return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
23828
24113
  Paste directly ABOVE your own subscribe / Join link or button. Keep that link or button as it is.
23829
24114
  No consent checkbox here: the ${where2} collects consent (consent_collection.terms_of_service = "required").
23830
- One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
23831
- If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead. -->
23832
- <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 : ""}">
23833
24118
  <p style="margin:0;font-weight:600;">${disclosureHtml(disclosure)}</p>
23834
24119
  </div>`;
23835
24120
  }
23836
- var esc, URL_RE2;
24121
+ var esc, URL_RE2, BOXED_STYLE, BOXED_NOTE;
23837
24122
  var init_html = __esm({
23838
24123
  "../rules/src/fixes/html.ts"() {
23839
24124
  "use strict";
23840
24125
  esc = (s2) => s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
23841
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.`;
23842
24130
  }
23843
24131
  });
23844
24132
 
@@ -23865,6 +24153,10 @@ function fixTextsFor(t, style2) {
23865
24153
  if (style2 === "full") return fixTexts(t);
23866
24154
  return { disclosure: compactDisclosureText(t), consentLabel: COMPACT_CONSENT_LABEL, cta: compactCtaLabel(t) };
23867
24155
  }
24156
+ function stripeCompactConsent(t) {
24157
+ const trial = t.trialDays && t.trialDays > 0 ? ` after the ${t.trialDays}-day free trial` : "";
24158
+ return `I agree that my subscription renews automatically at ${formatMoney(t.amount, t.currency)} every ${intervalPhrase(t.interval, t.intervalCount)}${trial} until I cancel.`;
24159
+ }
23868
24160
  var COMPACT_CONSENT_LABEL;
23869
24161
  var init_compact = __esm({
23870
24162
  "../rules/src/fixes/compact.ts"() {
@@ -23881,9 +24173,10 @@ function shortDisclosureText(t) {
23881
24173
  const trial = !!t.trialDays && t.trialDays > 0;
23882
24174
  const intro = introPhrase(t);
23883
24175
  const cancel = `Cancel anytime online at ${t.cancelUrl}`;
23884
- if (trial) return `${t.trialDays} days free, then ${intro ?? `${price3} every ${every}`}. Renews automatically until you cancel. ${cancel} before the trial ends to avoid being charged.`;
23885
- if (intro) return `${intro[0].toUpperCase()}${intro.slice(1)}. Renews automatically until you cancel. ${cancel}.`;
23886
- return `Renews automatically every ${every} at ${price3} until you cancel. ${cancel}.`;
24176
+ const tax = " (plus applicable taxes)";
24177
+ if (trial) return `${t.trialDays} days free, then ${intro ?? `${price3} every ${every}`}${tax}. Renews automatically until you cancel. ${cancel} before the trial ends to avoid being charged.`;
24178
+ if (intro) return `${intro[0].toUpperCase()}${intro.slice(1)}${tax}. Renews automatically until you cancel. ${cancel}.`;
24179
+ return `Renews automatically every ${every} at ${price3}${tax} until you cancel. ${cancel}.`;
23887
24180
  }
23888
24181
  var init_short = __esm({
23889
24182
  "../rules/src/fixes/short.ts"() {
@@ -23916,7 +24209,7 @@ function modelAfterCapture(before, target, texts, opts) {
23916
24209
  const ctaY = cta ? cta.rect.y : 700;
23917
24210
  const ctaH = cta ? cta.rect.height : 48;
23918
24211
  const stripe = target === "stripe_checkout" || target === "stripe_payment_link";
23919
- 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;
23920
24213
  const disclosureH = textHeight(texts.disclosure, width, style2.fontSizePx);
23921
24214
  const labelH = textHeight(texts.consentLabel, width - 28, style2.fontSizePx);
23922
24215
  const disclosure = {
@@ -24048,7 +24341,7 @@ async function verifyFix(input) {
24048
24341
  const rest = captures.filter((c) => !allCheckout(c));
24049
24342
  const unchangedCheckout = latestPages(captures.filter((c) => allCheckout(c) && !matching.some((m) => m.id === c.id))).filter((c) => (c.ctas?.length ?? 0) > 0);
24050
24343
  const otherCheckoutPages = new Set(unchangedCheckout.map((c) => c.url.split(/[?#]/)[0])).size;
24051
- 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 } : {} }));
24052
24345
  const changedIds = new Set(objects.map((o) => o.id));
24053
24346
  const afterConfigs = input.ctx.checkoutConfigs?.map(
24054
24347
  (c) => changedIds.has(c.id) ? { ...c, capture: modelAfterCapture(c.capture, input.target, input.texts, { consentCheckbox: consent }), consentRequired: consent || c.consentRequired } : c
@@ -24313,13 +24606,18 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24313
24606
  const verifyTexts = lockedTexts(verifyTerms);
24314
24607
  const page = opts.onStripe && opts.page && (opts.page.ctas?.length ?? 0) > 0 ? [{ ...opts.page, pageKind: "checkout", source: "scanner" }] : [];
24315
24608
  const ctx = modelContext(jurisdictions, asOf, planFacts(plan2), { config: planConfig(plan2, asOf), ...page.length ? { captures: page } : {}, ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24316
- 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;
24317
24615
  const sha = textsSha256(shown);
24318
24616
  const placeholders = plan2.cancelUrl ? [] : [CANCEL_URL_PLACEHOLDER];
24319
24617
  let short;
24320
24618
  if (plan2.cancelUrl) {
24321
24619
  const shortText = shortDisclosureText(verifyTerms);
24322
- 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 } : {} });
24323
24621
  if (sv.passes && v.passes && shortText !== shown.disclosure) short = shortText;
24324
24622
  }
24325
24623
  if (opts.onStripe) {
@@ -24330,9 +24628,9 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24330
24628
  ...short ? { short_disclosure_text: short } : {},
24331
24629
  consent_label: shown.consentLabel,
24332
24630
  button_text: shown.cta,
24333
- html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe),
24631
+ html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe, boxed ? { boxed: true } : {}),
24334
24632
  consent_on: opts.onStripe,
24335
- 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)." : ""}`,
24336
24634
  placeholders,
24337
24635
  verified: v.passes,
24338
24636
  remaining_checks: [.../* @__PURE__ */ new Set([...v.remaining, "signup.consent.affirmative"])],
@@ -24344,7 +24642,7 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
24344
24642
  ...short ? { short_disclosure_text: short } : {},
24345
24643
  consent_label: shown.consentLabel,
24346
24644
  button_text: shown.cta,
24347
- html: htmlDisclosureComponent(shown, sha),
24645
+ html: htmlDisclosureComponent(shown, sha, boxed ? { boxed: true } : {}),
24348
24646
  placeholders,
24349
24647
  verified: v.passes,
24350
24648
  remaining_checks: v.remaining,
@@ -24595,7 +24893,57 @@ var init_stripe = __esm({
24595
24893
  }
24596
24894
  });
24597
24895
 
24896
+ // ../rules/src/preflight/price-hint.ts
24897
+ function intervalHintOf(ref) {
24898
+ if (typeof ref !== "string" || !ref.trim() || ref.length > 200) return void 0;
24899
+ const s2 = ref.trim();
24900
+ if (GENERATED.test(s2)) return void 0;
24901
+ const tokens = s2.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
24902
+ const hits = /* @__PURE__ */ new Set();
24903
+ for (const t of tokens) for (const [k, re] of WORDS2) if (re.test(t)) hits.add(k);
24904
+ return hits.size === 1 ? [...hits][0] : void 0;
24905
+ }
24906
+ function priceIntervalConflict(ids, plan2) {
24907
+ const interval = plan2?.interval;
24908
+ if (!interval || !ids.length) return void 0;
24909
+ const count = Number(plan2?.interval_count ?? 1) || 1;
24910
+ for (const id of ids) {
24911
+ const hint = intervalHintOf(id);
24912
+ if (!hint) continue;
24913
+ const planUnit = interval === "month" && count === 12 ? "year" : interval;
24914
+ if (hint !== planUnit) {
24915
+ const name = plan2?.plan_name ?? plan2?.name;
24916
+ return `line_items use ${id}, whose name says it bills every ${hint}, but the plan terms given${name ? ` (plan "${name}")` : ""} bill every ${count > 1 ? `${count} ${interval}s` : interval}`;
24917
+ }
24918
+ }
24919
+ return void 0;
24920
+ }
24921
+ var WORDS2, GENERATED;
24922
+ var init_price_hint = __esm({
24923
+ "../rules/src/preflight/price-hint.ts"() {
24924
+ "use strict";
24925
+ WORDS2 = [
24926
+ ["year", /^(?:annual|annually|yearly|year|years|yr|yrly|12mo|12m|1y|1yr)$/],
24927
+ ["month", /^(?:monthly|month|months|mo|mth|1m|1mo)$/],
24928
+ ["week", /^(?:weekly|week|weeks|wk)$/],
24929
+ ["day", /^(?:daily|day|days)$/]
24930
+ ];
24931
+ GENERATED = /^(?:price|plan|prod)_1[A-Za-z0-9]{12,}$/;
24932
+ }
24933
+ });
24934
+
24598
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
+ }
24599
24947
  function clone(v) {
24600
24948
  return JSON.parse(JSON.stringify(v));
24601
24949
  }
@@ -24687,6 +25035,25 @@ function readParams(params, merchant, kind, asOf) {
24687
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)) {
24688
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" });
24689
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
+ }
24690
25057
  const uiMode = typeof params.ui_mode === "string" ? params.ui_mode : void 0;
24691
25058
  if (uiMode === "custom" || uiMode === "elements") {
24692
25059
  caveats.push({
@@ -24964,6 +25331,7 @@ function correct(params, shape, checks, forLink, style2 = "full") {
24964
25331
  out.custom_text = set;
24965
25332
  } else {
24966
25333
  for (const [k, v] of Object.entries(set)) {
25334
+ if (JSON.stringify(ct[k]) === JSON.stringify(v)) continue;
24967
25335
  patch.push({ op: k in ct && ct[k] !== null ? "replace" : "add", path: `/custom_text/${k}`, value: v });
24968
25336
  ct[k] = v;
24969
25337
  }
@@ -24987,10 +25355,6 @@ function correct(params, shape, checks, forLink, style2 = "full") {
24987
25355
  }
24988
25356
  return { patch, corrected: out, texts, needText, needConsent };
24989
25357
  }
24990
- function stripeCompactConsent(t) {
24991
- const trial = t.trialDays && t.trialDays > 0 ? ` after the ${t.trialDays}-day free trial` : "";
24992
- return `I agree that my subscription renews automatically at ${formatMoney(t.amount, t.currency)} every ${intervalPhrase(t.interval, t.intervalCount)}${trial} until I cancel.`;
24993
- }
24994
25358
  async function bestCorrection(params, shape, checks, forLink, judgeAfter) {
24995
25359
  const none = () => ({ fix: correct(params, shape, [], forLink), after: checks });
24996
25360
  const first2 = correct(params, shape, checks, forLink, "compact");
@@ -25071,6 +25435,12 @@ function withResolvedPrices(input, knownPrices, amountSource = "merchant.price")
25071
25435
  warnings: [{ code: "price_amount_unverified", message: `line_items use ${ids[0]}, which Checkbox could not read (no connected Stripe account has it), so the amount in the checks and in the corrected custom_text comes from ${amountSource} 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.`, param: "params.line_items" }]
25072
25436
  };
25073
25437
  }
25438
+ function pricePairingConflict(input, resolved) {
25439
+ if (!resolved.warnings.some((w) => w.code === "price_amount_unverified")) return void 0;
25440
+ const conflict = priceIntervalConflict(lineItemPriceIds(input.params), input.merchant);
25441
+ if (!conflict) return void 0;
25442
+ return { code: "price_plan_conflict", message: `${conflict}. Checkbox offers no corrected params for this pairing: they would state the other plan's price. Check which plan this checkout sells and pass that plan's terms (in checkbox.json: the entry's "plan"), or connect Stripe so Checkbox reads the Price.`, param: "params.line_items" };
25443
+ }
25074
25444
  async function checkStripeCheckoutSession(input0, opts = {}) {
25075
25445
  const { jurisdictions, asOf, now } = common(input0, opts.now);
25076
25446
  const resolvedPrices = withResolvedPrices(input0, opts.knownPrices, opts.amountSource);
@@ -25086,7 +25456,8 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
25086
25456
  const shape = readParams(params, input.merchant, "session", asOf);
25087
25457
  const url = "https://checkout.stripe.com/c/pay/cs_test_preflight";
25088
25458
  const checks = await judge2(shape, url, jurisdictions, asOf, now, "preflight-stripe-before");
25089
- const { fix, after } = await bestCorrection(
25459
+ const pairing = pricePairingConflict(input0, resolvedPrices);
25460
+ const { fix, after } = pairing ? { fix: correct(params, shape, [], false), after: checks } : await bestCorrection(
25090
25461
  params,
25091
25462
  shape,
25092
25463
  checks,
@@ -25101,12 +25472,12 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
25101
25472
  verification: { checks: d.checks, passes: d.passes, remaining_checks: d.remaining, note: HOSTED_STYLING_NOTE },
25102
25473
  placeholders
25103
25474
  };
25104
- const warnings = [...resolvedPrices.warnings, ...shape.warnings];
25475
+ const warnings = [...resolvedPrices.warnings, ...pairing ? [pairing] : [], ...shape.warnings];
25105
25476
  if (fix.needConsent) warnings.push({ code: "stripe_tos_url_required", message: 'consent_collection.terms_of_service = "required" needs a Terms of Service URL in Stripe Dashboard \u2192 Settings \u2192 Public details, or session creation fails.' });
25106
25477
  const channel2 = noticeChannelField(params);
25107
25478
  if (channel2) warnings.push(channel2);
25108
25479
  return {
25109
- ...baseReport("stripe_checkout_session", checks, jurisdictions, asOf, warnings, nextAction("session", checks, correction), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25480
+ ...baseReport("stripe_checkout_session", checks, jurisdictions, asOf, warnings, pairing ? PAIRING_ACTION(pairing) : nextAction("session", checks, correction), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25110
25481
  kind: "stripe_checkout_session",
25111
25482
  correction
25112
25483
  };
@@ -25124,7 +25495,8 @@ async function checkStripePaymentLink(input0, opts = {}) {
25124
25495
  const id = typeof params.id === "string" ? params.id : void 0;
25125
25496
  const url = typeof params.url === "string" && /^https:\/\/buy\.stripe\.com\//.test(params.url) ? params.url : "https://buy.stripe.com/test_preflight";
25126
25497
  const checks = await judge2(shape, url, jurisdictions, asOf, now, "preflight-link-before");
25127
- const { fix, after } = await bestCorrection(
25498
+ const pairing = pricePairingConflict(input0, resolvedPrices);
25499
+ const { fix, after } = pairing ? { fix: correct(params, shape, [], true), after: checks } : await bestCorrection(
25128
25500
  params,
25129
25501
  shape,
25130
25502
  checks,
@@ -25163,11 +25535,11 @@ async function checkStripePaymentLink(input0, opts = {}) {
25163
25535
  ...update_params ? { update_params } : {},
25164
25536
  needs_new_link_for_consent: needsNew
25165
25537
  };
25166
- const warnings = [...resolvedPrices.warnings, ...shape.warnings];
25538
+ const warnings = [...resolvedPrices.warnings, ...pairing ? [pairing] : [], ...shape.warnings];
25167
25539
  if (fix.needConsent) warnings.push({ code: "stripe_tos_url_required", message: 'consent_collection.terms_of_service = "required" needs a Terms of Service URL in Stripe Dashboard \u2192 Settings \u2192 Public details.' });
25168
25540
  const extra = needsNew && !id ? " Stripe cannot add the consent checkbox to an existing link (stripe.paymentLinks.update refuses consent_collection): create a new link with correction.corrected_params, replace the old URL everywhere, then deactivate the old link." : needsNew ? ` Stripe cannot add the consent checkbox to an existing link: send correction.update_params with stripe.paymentLinks.update("${id}", \u2026) for the text now, then create a new link with correction.corrected_params, replace the old URL everywhere, then deactivate the old link.` : id && update_params ? ` For the existing link use stripe.paymentLinks.update("${id}", correction.update_params) (same URL).` : "";
25169
25541
  return {
25170
- ...baseReport("stripe_payment_link", checks, jurisdictions, asOf, warnings, nextAction("link", checks, correction, extra), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25542
+ ...baseReport("stripe_payment_link", checks, jurisdictions, asOf, warnings, pairing ? PAIRING_ACTION(pairing) : nextAction("link", checks, correction, extra), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25171
25543
  kind: "stripe_payment_link",
25172
25544
  correction
25173
25545
  };
@@ -25227,7 +25599,10 @@ function modelCheckoutConfig(params, opts) {
25227
25599
  // travels with its plan, so the site's checks and copy know the promo without a separate declaration.
25228
25600
  ...plan2.introAmount !== void 0 && plan2.introAmount >= 0 && plan2.introAmount < plan2.amount ? { introAmount: plan2.introAmount, ...plan2.introPeriods && plan2.introPeriods > 1 ? { introPeriods: plan2.introPeriods } : {} } : {}
25229
25601
  },
25230
- 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
25231
25606
  };
25232
25607
  }
25233
25608
  function noticeChannelField(params) {
@@ -25246,7 +25621,7 @@ function noticeChannelField(params) {
25246
25621
  param: `params.custom_fields[${i}]`
25247
25622
  };
25248
25623
  }
25249
- var TEXT_CHECKS, CONSENT_CHECKS2, HOSTED_STYLING_NOTE, INTRO_PROMISE_RE, isObj, msg, CUSTOM_TEXT_FIELDS, STRIPE_GREY, DAY_MS3, open, STRIPE_HOSTED_STYLING_NOTE;
25624
+ var TEXT_CHECKS, CONSENT_CHECKS2, HOSTED_STYLING_NOTE, INTRO_PROMISE_RE, isObj, msg, CUSTOM_TEXT_FIELDS, STRIPE_GREY, DAY_MS3, open, PAIRING_ACTION, STRIPE_HOSTED_STYLING_NOTE;
25250
25625
  var init_stripe2 = __esm({
25251
25626
  "../rules/src/preflight/stripe.ts"() {
25252
25627
  "use strict";
@@ -25254,11 +25629,13 @@ var init_stripe2 = __esm({
25254
25629
  init_compact();
25255
25630
  init_plan_terms();
25256
25631
  init_text();
25632
+ init_signup();
25257
25633
  init_verify();
25258
25634
  init_stripe();
25259
25635
  init_common();
25260
25636
  init_disclosure();
25261
25637
  init_model();
25638
+ init_price_hint();
25262
25639
  TEXT_CHECKS = [
25263
25640
  "signup.disclosure.autorenew_language",
25264
25641
  "signup.disclosure.price",
@@ -25277,6 +25654,7 @@ var init_stripe2 = __esm({
25277
25654
  STRIPE_GREY = { fontSizePx: 13, fontWeight: 400, color: "rgba(26, 26, 26, 0.7)", backgroundColor: "rgb(255, 255, 255)" };
25278
25655
  DAY_MS3 = 864e5;
25279
25656
  open = (c) => !!c && (c.status === "fail" || c.status === "needs_review");
25657
+ PAIRING_ACTION = (w) => ({ type: "human_review", message: `${w.message}` });
25280
25658
  STRIPE_HOSTED_STYLING_NOTE = HOSTED_STYLING_NOTE;
25281
25659
  }
25282
25660
  });
@@ -25286,6 +25664,8 @@ function stripeParamsVerdict(r) {
25286
25664
  if (r.verdict === "not_applicable") return { verdict: "not_applicable", open: [] };
25287
25665
  const open2 = r.checks.filter((c) => c.status === "fail" || c.status === "needs_review" && !(c.compliance_status && SATISFIED_COMPLIANCE.has(c.compliance_status))).map((c) => ({ check_id: c.check_id, status: c.status, title: c.title ?? c.check_id, detail: c.explanation ?? "" })).sort((a, b) => (a.status === "fail" ? 0 : 1) - (b.status === "fail" ? 0 : 1));
25288
25666
  if (r.verdict === "fail" || r.compliance_status === "needs_fix" || open2.some((c) => c.status === "fail")) return { verdict: "fail", open: open2 };
25667
+ const pairing = (r.warnings ?? []).find((w) => w.code === "price_plan_conflict");
25668
+ if (pairing) return { verdict: "needs_review", open: open2, reason: pairing.message };
25289
25669
  const coupon = (r.warnings ?? []).find((w) => w.code === "coupon_unverified");
25290
25670
  if (open2.length) return { verdict: "needs_review", open: open2, ...coupon ? { reason: coupon.message } : {} };
25291
25671
  if (coupon) return { verdict: "needs_review", open: [], reason: coupon.message };
@@ -25686,7 +26066,8 @@ ${html ?? ""}`, kind);
25686
26066
  text: t.text,
25687
26067
  html: htmlOf(t.text),
25688
26068
  merge_tags: t.tags,
25689
- placeholders: [...plan2.cancelUrl ? [] : [CANCEL_URL_PLACEHOLDER], ...t.text.includes(SUPPORT_CONTACT_PLACEHOLDER) ? [SUPPORT_CONTACT_PLACEHOLDER] : []],
26069
+ // Round 32: only the placeholders the template really contains (a cancellation confirmation has no {{cancel_url}}).
26070
+ placeholders: [CANCEL_URL_PLACEHOLDER, SUPPORT_CONTACT_PLACEHOLDER].filter((p) => t.text.includes(p) || t.subject.includes(p)),
25690
26071
  verified: remaining.length === 0,
25691
26072
  remaining_checks: remaining
25692
26073
  };
@@ -25956,7 +26337,8 @@ async function checkPageHtml(input, opts = {}) {
25956
26337
  });
25957
26338
  }
25958
26339
  const consentOnStripe = onStripe ?? (checkoutType === "payment_link" || checkoutType === "stripe_checkout" ? checkoutType : checkoutType ? void 0 : stripeButtonsKind(capture));
25959
- 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 } : {} };
25960
26342
  const built = plan2 ? await buildReplacement(plan2, jurisdictions, asOf, now, { ...repOpts, page: capture }) : void 0;
25961
26343
  const differs = plan2 && !multi ? pagePriceDiffers(run2.findings) : [];
25962
26344
  if (plan2 && differs.length) {
@@ -26039,7 +26421,8 @@ function signupStripeObjects(input) {
26039
26421
  capture: { ...m.capture, siteId: input.siteId },
26040
26422
  consentRequired: m.consentRequired,
26041
26423
  kind: "checkout_config",
26042
- ...m.plan ? { planId: m.plan.id } : {}
26424
+ ...m.plan ? { planId: m.plan.id } : {},
26425
+ ...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
26043
26426
  });
26044
26427
  }
26045
26428
  if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", ...row.name ? { label: row.name } : {} });
@@ -26072,7 +26455,8 @@ function signupStripeObjects(input) {
26072
26455
  consentRequired: m.consentRequired,
26073
26456
  kind: "payment_link",
26074
26457
  ...link.url ? { url: link.url } : {},
26075
- ...m.plan ? { planId: m.plan.id } : {}
26458
+ ...m.plan ? { planId: m.plan.id } : {},
26459
+ ...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
26076
26460
  });
26077
26461
  }
26078
26462
  if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", label: `Payment Link \u201C${name}\u201D` });
@@ -26114,6 +26498,7 @@ __export(preflight_exports, {
26114
26498
  checkoutSessionsOf: () => checkoutSessionsOf,
26115
26499
  emailTemplate: () => emailTemplate,
26116
26500
  extractStaticPage: () => extractStaticPage,
26501
+ intervalHintOf: () => intervalHintOf,
26117
26502
  lineItemPriceIds: () => lineItemPriceIds,
26118
26503
  modelCheckoutConfig: () => modelCheckoutConfig,
26119
26504
  modelContext: () => modelContext,
@@ -26121,6 +26506,7 @@ __export(preflight_exports, {
26121
26506
  parseHtml: () => parseHtml,
26122
26507
  parseJurisdictionList: () => parseJurisdictionList,
26123
26508
  paymentLinksOf: () => paymentLinksOf,
26509
+ priceIntervalConflict: () => priceIntervalConflict,
26124
26510
  resolvePlan: () => resolvePlan,
26125
26511
  resolvePriceIds: () => resolvePriceIds,
26126
26512
  runChecks: () => runChecks,
@@ -26128,7 +26514,10 @@ __export(preflight_exports, {
26128
26514
  signupStripeObjects: () => signupStripeObjects,
26129
26515
  stripeParamsVerdict: () => stripeParamsVerdict,
26130
26516
  supportContactLine: () => supportContactLine,
26131
- withIntroOffers: () => withIntroOffers
26517
+ testRunSessionIds: () => testRunSessionIds,
26518
+ trialPromise: () => trialPromise,
26519
+ withIntroOffers: () => withIntroOffers,
26520
+ withTestRunSessions: () => withTestRunSessions
26132
26521
  });
26133
26522
  var init_preflight = __esm({
26134
26523
  "../rules/src/preflight/index.ts"() {
@@ -26136,6 +26525,7 @@ var init_preflight = __esm({
26136
26525
  init_common();
26137
26526
  init_disclosure();
26138
26527
  init_stripe2();
26528
+ init_price_hint();
26139
26529
  init_stripe_verdict();
26140
26530
  init_email();
26141
26531
  init_html_page();
@@ -26321,7 +26711,8 @@ var init_check = __esm({
26321
26711
  }
26322
26712
  const hf = str(f, "html-file");
26323
26713
  if (hf) html = await readInput(ctx, hf);
26324
- if (!subject) throw new UsageError('--subject is required (or a "Subject: \u2026" first line in --file)');
26714
+ if (!subject && html) subject = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.replace(/\s+/g, " ").trim() || void 0;
26715
+ if (!subject) throw new UsageError('--subject is required (or a "Subject: \u2026" first line in --file, or an HTML <title>)');
26325
26716
  if (!text && !html) throw new UsageError("Give the email body with --text, --file or --html-file");
26326
26717
  const terms2 = planFrom(f);
26327
26718
  const pp = str(f, "previous-price");
@@ -26483,7 +26874,7 @@ function loadConfig(path) {
26483
26874
  function validateConfig(value) {
26484
26875
  const is = new Issues();
26485
26876
  if (!isObj2(value)) return ["must be a JSON object (see https://checkbox.site/docs/guides/ci#config)"];
26486
- is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "notices", "checks", "coverage"]);
26877
+ is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "notices", "merge_tags", "checks", "coverage"]);
26487
26878
  const cov = value.coverage;
26488
26879
  if (cov !== void 0 && cov !== false) {
26489
26880
  if (!isObj2(cov)) is.add("coverage", `must be false or an object like { "ignore": ["scripts/**"] } (got ${show(cov)})`);
@@ -26551,6 +26942,15 @@ function validateConfig(value) {
26551
26942
  }
26552
26943
  }
26553
26944
  }
26945
+ const mergeTags = (at, m2) => {
26946
+ if (m2 === void 0) return;
26947
+ if (!isObj2(m2) || !Object.keys(m2).length) return is.add(at, `must be an object mapping each merge tag to its value, like { "price": "plan.price", "interval": "plan.interval", "first_name": "Sam" } (got ${show(m2)})`);
26948
+ for (const [k, v] of Object.entries(m2)) {
26949
+ if (!/^[A-Za-z_][\w.-]{0,60}$/.test(k)) is.add(`${at}.${k}`, "is not a merge tag name (letters, digits, _ . -; the name inside {{ }})");
26950
+ else if (typeof v !== "string" || v.length > 500) is.add(`${at}.${k}`, `must be a string: a sample value, or one of ${MERGE_TAG_FIELDS.join(", ")} (got ${show(v)})`);
26951
+ }
26952
+ };
26953
+ mergeTags("merge_tags", value.merge_tags);
26554
26954
  const checks = value.checks;
26555
26955
  if (!isObj2(checks)) {
26556
26956
  is.add("checks", checks === void 0 ? "is required (an object with stripe_checkout, disclosures, pages, emails and/or notice_schedule)" : `must be an object (got ${show(checks)})`);
@@ -26635,7 +27035,8 @@ function validateConfig(value) {
26635
27035
  entries(
26636
27036
  "emails",
26637
27037
  (at, e) => {
26638
- is.keys(at, e, ["name", "plan", "kind", "subject", "file", "text", "html", "module", "args", "previous_price"]);
27038
+ is.keys(at, e, ["name", "plan", "kind", "subject", "file", "text", "html", "module", "args", "previous_price", "merge_tags"]);
27039
+ mergeTags(`${at}.merge_tags`, e.merge_tags);
26639
27040
  is.oneOf(`${at}.kind`, e.kind, EMAIL_KINDS2, true);
26640
27041
  is.exactlyOne(at, e, ["file", "text", "html", "module"], [["text", "html"]]);
26641
27042
  if (e.file !== void 0) {
@@ -26668,7 +27069,7 @@ function entryLine(raw, name) {
26668
27069
  const i = lines.findIndex((l) => needle.test(l));
26669
27070
  return i >= 0 ? i + 1 : 1;
26670
27071
  }
26671
- var CONFIG_FILES, SCHEMA_URL, INTERVALS4, EMAIL_KINDS2, PAGE_KINDS2, ConfigError, isObj2, show, TODO, Issues, PLAN_KEYS;
27072
+ var CONFIG_FILES, SCHEMA_URL, INTERVALS4, EMAIL_KINDS2, PAGE_KINDS2, MERGE_TAG_FIELDS, ConfigError, isObj2, show, TODO, Issues, PLAN_KEYS;
26672
27073
  var init_config3 = __esm({
26673
27074
  "src/ci/config.ts"() {
26674
27075
  "use strict";
@@ -26677,6 +27078,7 @@ var init_config3 = __esm({
26677
27078
  INTERVALS4 = ["day", "week", "month", "year"];
26678
27079
  EMAIL_KINDS2 = ["acknowledgment", "trial_reminder", "renewal_reminder", "price_change", "annual_terms", "charge_notice", "cancellation_confirmation"];
26679
27080
  PAGE_KINDS2 = ["checkout", "pricing"];
27081
+ MERGE_TAG_FIELDS = ["plan.price", "plan.amount", "plan.interval", "plan.name", "plan.trial_days", "plan.currency", "merchant.name", "merchant.cancel_url", "merchant.support_contact"];
26680
27082
  ConfigError = class extends Error {
26681
27083
  constructor(file, issues) {
26682
27084
  super(`${file}: ${issues.length === 1 ? issues[0] : `${issues.length} problems:
@@ -26761,7 +27163,7 @@ function parseObjectLiteral(source, env2 = {}, consts = {}) {
26761
27163
  const p = new Parser(source, env2, consts);
26762
27164
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
26763
27165
  const v = p.object("");
26764
- 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 } : {} };
26765
27167
  }
26766
27168
  var UNKNOWN, Parser;
26767
27169
  var init_literal = __esm({
@@ -26777,6 +27179,7 @@ var init_literal = __esm({
26777
27179
  i = 0;
26778
27180
  unresolved = [];
26779
27181
  fromEnv = {};
27182
+ spreads = [];
26780
27183
  ws() {
26781
27184
  for (; ; ) {
26782
27185
  while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
@@ -26913,7 +27316,9 @@ var init_literal = __esm({
26913
27316
  if (!ch) return UNKNOWN;
26914
27317
  if (this.s.startsWith("...", this.i)) {
26915
27318
  this.i += 3;
27319
+ const from = this.i;
26916
27320
  this.skipExpr();
27321
+ if (!path) this.spreads.push(this.s.slice(from, this.i).trim());
26917
27322
  this.unresolved.push(`${path ? `${path}.` : ""}...`);
26918
27323
  } else {
26919
27324
  let key = null;
@@ -27030,6 +27435,12 @@ function detectBrand(root, layoutFile, pkgName) {
27030
27435
  }
27031
27436
  return void 0;
27032
27437
  }
27438
+ function detectMailer(deps, framework, hasPackageJson) {
27439
+ const found = MAILER_PACKAGES.find((m) => m in deps);
27440
+ if (found) return found;
27441
+ if (!hasPackageJson && framework === "html") return "none";
27442
+ return null;
27443
+ }
27033
27444
  function readJson(path) {
27034
27445
  try {
27035
27446
  return JSON.parse(readFileSync5(path, "utf8"));
@@ -27089,12 +27500,49 @@ function walk2(root, visit) {
27089
27500
  }
27090
27501
  }
27091
27502
  }
27503
+ function hasAppRouteFile(dir, depth = 0) {
27504
+ if (depth > 3) return false;
27505
+ let names = [];
27506
+ try {
27507
+ names = readdirSync2(dir);
27508
+ } catch {
27509
+ return false;
27510
+ }
27511
+ if (names.some((n) => /^(?:page|route|layout)\.(?:tsx|jsx|ts|js|mdx)$/.test(n))) return true;
27512
+ return names.some((n) => !n.startsWith(".") && n !== "node_modules" && (() => {
27513
+ try {
27514
+ return statSync(join4(dir, n)).isDirectory();
27515
+ } catch {
27516
+ return false;
27517
+ }
27518
+ })() && hasAppRouteFile(join4(dir, n), depth + 1));
27519
+ }
27520
+ function hasTsFiles(dir, depth = 0) {
27521
+ if (depth > 3) return false;
27522
+ let names = [];
27523
+ try {
27524
+ names = readdirSync2(dir);
27525
+ } catch {
27526
+ return false;
27527
+ }
27528
+ if (names.some((n) => /\.tsx?$/.test(n))) return true;
27529
+ return names.some((n) => !n.startsWith(".") && (() => {
27530
+ try {
27531
+ return statSync(join4(dir, n)).isDirectory();
27532
+ } catch {
27533
+ return false;
27534
+ }
27535
+ })() && hasTsFiles(join4(dir, n), depth + 1));
27536
+ }
27092
27537
  function detectFramework(root, deps) {
27093
27538
  const has2 = (d) => d in deps;
27094
27539
  if (existsSync4(join4(root, "layout", "theme.liquid")) && existsSync4(join4(root, "config", "settings_schema.json"))) return { framework: "shopify-theme", layoutFile: "layout/theme.liquid" };
27095
27540
  if (has2("next")) {
27096
27541
  const app = first(root, ["app/layout.tsx", "app/layout.jsx", "app/layout.js", "app/layout.ts", "src/app/layout.tsx", "src/app/layout.jsx", "src/app/layout.js"]);
27097
27542
  if (app) return { framework: "nextjs-app", layoutFile: app };
27543
+ const appDir = ["src/app", "app"].find((d) => existsSync4(join4(root, d)) && hasAppRouteFile(join4(root, d)));
27544
+ const pagesExists = existsSync4(join4(root, "pages")) || existsSync4(join4(root, "src", "pages"));
27545
+ if (appDir && !pagesExists) return { framework: "nextjs-app", layoutFile: `${appDir}/layout.${existsSync4(join4(root, "tsconfig.json")) || hasTsFiles(join4(root, appDir)) ? "tsx" : "js"}` };
27098
27546
  const doc = first(root, ["pages/_document.tsx", "pages/_document.jsx", "pages/_document.js", "src/pages/_document.tsx", "src/pages/_document.jsx", "src/pages/_document.js"]);
27099
27547
  const pagesDir = existsSync4(join4(root, "src", "pages")) ? "src/pages" : "pages";
27100
27548
  return { framework: "nextjs-pages", layoutFile: doc ?? `${pagesDir}/_document.${existsSync4(join4(root, "tsconfig.json")) ? "tsx" : "js"}` };
@@ -27181,11 +27629,11 @@ function routeOf(rel2) {
27181
27629
  }
27182
27630
  return null;
27183
27631
  }
27184
- function findStripeKey(env2, processEnv) {
27632
+ function findStripeKey(env2, processEnv, envFile = {}) {
27185
27633
  const sources = [];
27186
27634
  for (const k of ["CHECKBOX_STRIPE_TEST_KEY", "STRIPE_RESTRICTED_KEY", "STRIPE_TEST_RESTRICTED_KEY", "STRIPE_SECRET_KEY", "STRIPE_TEST_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_SK"]) {
27187
27635
  sources.push([`${k} (environment)`, processEnv[k]]);
27188
- sources.push([`${k} (.env)`, env2[k]]);
27636
+ sources.push([`${k} (${envFile[k] ?? ".env"})`, env2[k]]);
27189
27637
  }
27190
27638
  const found = sources.filter((s2) => typeof s2[1] === "string" && /^(sk|rk)_(test|live)_[A-Za-z0-9]{10,}$/.test(s2[1].trim())).map(([src, v]) => [src, v.trim()]);
27191
27639
  const hint = (key) => `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
@@ -27285,11 +27733,15 @@ function detectProject(root, processEnv = {}) {
27285
27733
  const { framework, layoutFile } = detectFramework(root, deps);
27286
27734
  const packageManager = existsSync4(join4(root, "pnpm-lock.yaml")) ? "pnpm" : existsSync4(join4(root, "yarn.lock")) ? "yarn" : existsSync4(join4(root, "bun.lockb")) || existsSync4(join4(root, "bun.lock")) ? "bun" : pkg ? "npm" : null;
27287
27735
  const env2 = {};
27736
+ const envFile = {};
27288
27737
  for (const f of [".env", ".env.production", ".env.local", ".env.development", ".env.development.local"]) {
27289
27738
  const t = readText(join4(root, f));
27290
- if (t) Object.assign(env2, parseDotenv(t));
27739
+ if (!t) continue;
27740
+ const vars = parseDotenv(t);
27741
+ Object.assign(env2, vars);
27742
+ for (const k of Object.keys(vars)) envFile[k] = f;
27291
27743
  }
27292
- const billingPlatform = "stripe" in deps || "@stripe/stripe-js" in deps || "@stripe/react-stripe-js" in deps ? "stripe" : framework === "shopify-theme" || "@shopify/hydrogen" in deps || "@shopify/shopify-api" in deps ? "shopify" : "@rechargeapps/storefront-client" in deps ? "recharge" : "@paddle/paddle-js" in deps ? "paddle" : "chargebee" in deps ? "chargebee" : "custom";
27744
+ let billingPlatform = "stripe" in deps || "@stripe/stripe-js" in deps || "@stripe/react-stripe-js" in deps ? "stripe" : framework === "shopify-theme" || "@shopify/hydrogen" in deps || "@shopify/shopify-api" in deps ? "shopify" : "@rechargeapps/storefront-client" in deps ? "recharge" : "@paddle/paddle-js" in deps ? "paddle" : "chargebee" in deps ? "chargebee" : "custom";
27293
27745
  const checkoutCode = [];
27294
27746
  const payButtons = [];
27295
27747
  const checkoutSessions = [];
@@ -27319,8 +27771,9 @@ function detectProject(root, processEnv = {}) {
27319
27771
  checkoutSessions.push({ file: rel2, line: line2, ...parsed.value ? { params: redactSecretsDeep(parsed.value) } : {}, unresolved: parsed.unresolved });
27320
27772
  }
27321
27773
  });
27774
+ if (billingPlatform === "custom" && checkoutCode.some((c) => c.kind.startsWith("stripe_"))) billingPlatform = "stripe";
27322
27775
  const site = detectSiteUrl(root, env2, pkg);
27323
- const stripeKey = findStripeKey(env2, processEnv);
27776
+ const stripeKey = findStripeKey(env2, processEnv, envFile);
27324
27777
  const brand = detectBrand(root, layoutFile, typeof pkg?.name === "string" ? pkg.name : void 0);
27325
27778
  return {
27326
27779
  root,
@@ -27341,16 +27794,18 @@ function detectProject(root, processEnv = {}) {
27341
27794
  ...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
27342
27795
  ...brand ? { brand } : {},
27343
27796
  plans: plansFromSessions(checkoutSessions),
27344
- env: env2
27797
+ env: env2,
27798
+ mailer: detectMailer(deps, framework, !!pkg)
27345
27799
  };
27346
27800
  }
27347
- var GENERIC_NAMES, IGNORE_DIRS, CODE_EXT, MAX_FILES, MAX_FILE_BYTES, first, SITE_URL_KEYS, POLICY_ROUTES, CHECKOUT_PATTERNS, PAY_BUTTON;
27801
+ var GENERIC_NAMES, MAILER_PACKAGES, IGNORE_DIRS, CODE_EXT, MAX_FILES, MAX_FILE_BYTES, first, SITE_URL_KEYS, POLICY_ROUTES, CHECKOUT_PATTERNS, PAY_BUTTON;
27348
27802
  var init_detect = __esm({
27349
27803
  "src/commands/init/detect.ts"() {
27350
27804
  "use strict";
27351
27805
  init_src2();
27352
27806
  init_literal();
27353
27807
  GENERIC_NAMES = /^(?:web|app|site|website|frontend|client|my-app|my-site|demo|example|store|shop|starter|template|project|monorepo|root)$/i;
27808
+ MAILER_PACKAGES = ["nodemailer", "resend", "@sendgrid/mail", "postmark", "mailgun.js", "mailgun-js", "@aws-sdk/client-ses", "@aws-sdk/client-sesv2", "@react-email/components", "react-email", "@mailchimp/mailchimp_transactional", "mailersend", "@sparkpost/node", "sparkpost", "emailjs", "@emailjs/browser", "loops", "plunk", "@plunk/node", "brevo", "@getbrevo/brevo", "sib-api-v3-sdk", "nodemailer-mailgun-transport", "@vercel/email"];
27354
27809
  IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify", ".turbo", ".cache", "dist", "build", "out", "coverage", ".astro", "vendor", ".venv", "__pycache__"]);
27355
27810
  CODE_EXT = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|liquid|html|ejs|hbs|erb)$/i;
27356
27811
  MAX_FILES = 4e3;
@@ -27366,6 +27821,7 @@ var init_detect = __esm({
27366
27821
  CHECKOUT_PATTERNS = [
27367
27822
  { kind: "stripe_checkout_session", re: /checkout\.sessions\.create\s*\(/ },
27368
27823
  { kind: "stripe_payment_link", re: /paymentLinks\.create\s*\(|https:\/\/buy\.stripe\.com\// },
27824
+ { kind: "stripe_checkout_link", re: /https:\/\/checkout\.stripe\.com\// },
27369
27825
  { kind: "stripe_pricing_table", re: /<stripe-pricing-table\b/ },
27370
27826
  { kind: "stripe_subscription_api", re: /subscriptions\.create\s*\(/ },
27371
27827
  { kind: "stripe_elements", re: /<(PaymentElement|CardElement|ExpressCheckoutElement)\b|elements\.create\(\s*['"]payment['"]/ },
@@ -27380,6 +27836,47 @@ var init_detect = __esm({
27380
27836
  // src/ci/scaffold.ts
27381
27837
  import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27382
27838
  import { basename, dirname as dirname4, join as join5, relative as relative2 } from "node:path";
27839
+ function countTodoValues(v) {
27840
+ if (typeof v === "string") return /^TODO\b/.test(v) ? 1 : 0;
27841
+ if (Array.isArray(v)) return v.reduce((n, x) => n + countTodoValues(x), 0);
27842
+ if (v && typeof v === "object") return Object.values(v).reduce((n, x) => n + countTodoValues(x), 0);
27843
+ return 0;
27844
+ }
27845
+ function mergeTagsIn(text) {
27846
+ const out = [];
27847
+ for (const m of text.matchAll(/\{\{\{?\s*([A-Za-z_][\w.-]*)\s*\}?\}\}|\*\|([A-Za-z_][\w.-]*)\|\*/g)) {
27848
+ const t = m[1] ?? m[2];
27849
+ if (!out.includes(t)) out.push(t);
27850
+ }
27851
+ return out;
27852
+ }
27853
+ function mergeTagField(tag) {
27854
+ const t = tag.toLowerCase().replace(/[.-]/g, "_").replace(/^(?:subscription|sub)_/, "");
27855
+ if (/^(?:plan_)?(?:price|amount|cost|recurring_price|price_formatted|formatted_price|renewal_price|renewal_amount)$/.test(t)) return "plan.price";
27856
+ if (/^(?:plan_)?(?:interval|billing_interval|period|billing_period|frequency|billing_cycle|cycle|interval_name)$/.test(t)) return "plan.interval";
27857
+ if (/^(?:plan|plan_name|product|product_name|plan_title|tier)$/.test(t)) return "plan.name";
27858
+ if (/^(?:plan_)?(?:trial_days|trial_length|trial_period_days)$/.test(t)) return "plan.trial_days";
27859
+ if (/^(?:cancel_url|cancellation_url|manage_url|billing_url|account_url|cancel_link)$/.test(t)) return "merchant.cancel_url";
27860
+ if (/^(?:company|company_name|merchant|merchant_name|brand|brand_name|business_name|app_name)$/.test(t)) return "merchant.name";
27861
+ if (/^(?:support_email|support_contact|support|help_email|contact_email)$/.test(t)) return "merchant.support_contact";
27862
+ return void 0;
27863
+ }
27864
+ function pageSources(root) {
27865
+ const out = [];
27866
+ walk3(root, (rel2) => {
27867
+ if (out.length >= 4) return;
27868
+ let m = /^(?:src\/)?app\/((?:\([^/]+\)\/)*)([^/()]+)\/page\.(?:tsx|jsx|ts|js|mdx)$/.exec(rel2);
27869
+ if (m && PAGE_ROUTE.test(m[2])) {
27870
+ out.push({ source: rel2, file: `.next/server/app/${m[2]}.html`, route: m[2] });
27871
+ return;
27872
+ }
27873
+ m = /^(?:src\/)?pages\/([^/]+?)(?:\/index)?\.(tsx|jsx|ts|js|astro)$/.exec(rel2);
27874
+ if (m && PAGE_ROUTE.test(m[1])) {
27875
+ out.push({ source: rel2, file: m[2] === "astro" ? `dist/${m[1]}/index.html` : `.next/server/pages/${m[1]}.html`, route: m[1] });
27876
+ }
27877
+ });
27878
+ return out;
27879
+ }
27383
27880
  function walk3(root, visit) {
27384
27881
  let n = 0;
27385
27882
  const go = (dir, depth) => {
@@ -27420,8 +27917,8 @@ function plansFromLiterals(text, file) {
27420
27917
  if (!interval) continue;
27421
27918
  const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
27422
27919
  const minor = /\b(?:amount|unit_amount|price_cents|cents)\s*:\s*(\d+)\b/.exec(body)?.[1];
27423
- const major = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
27424
- 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;
27425
27922
  const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
27426
27923
  if (!name) continue;
27427
27924
  const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
@@ -27429,7 +27926,8 @@ function plansFromLiterals(text, file) {
27429
27926
  const trial = /\btrial(?:_period)?_?days\s*:\s*(\d+)/i.exec(body)?.[1];
27430
27927
  if (out.some((p) => p.name === name)) continue;
27431
27928
  const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
27432
- 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) } : {}, source: file });
27929
+ const ids = [...new Set([...body.matchAll(/['"`](price_[A-Za-z0-9_]+)['"`]/g)].map((x) => x[1]))];
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 });
27433
27931
  }
27434
27932
  return out;
27435
27933
  }
@@ -27480,10 +27978,84 @@ function pageSource(root, built) {
27480
27978
  const hits = bases.flatMap((b) => [".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".astro", ".mdx"].map((e) => `${b}${e}`)).filter((f) => existsSync5(join5(root, f)));
27481
27979
  return hits.length === 1 ? hits[0] : void 0;
27482
27980
  }
27981
+ function priceExpressions(root, file, line2) {
27982
+ let text;
27983
+ try {
27984
+ text = readFileSync6(join5(root, file), "utf8");
27985
+ } catch {
27986
+ return [];
27987
+ }
27988
+ const from = text.split("\n").slice(line2 - 1).join("\n");
27989
+ const at = from.search(/checkout\.sessions\.create|paymentLinks\.create/);
27990
+ const open2 = at < 0 ? -1 : from.indexOf("(", at);
27991
+ if (open2 < 0) return [];
27992
+ let depth = 0;
27993
+ let end = from.length;
27994
+ for (let i = open2; i < from.length; i++) {
27995
+ const c = from[i];
27996
+ if (c === "(" || c === "{" || c === "[") depth++;
27997
+ else if (c === ")" || c === "}" || c === "]") {
27998
+ depth--;
27999
+ if (depth === 0) {
28000
+ end = i;
28001
+ break;
28002
+ }
28003
+ }
28004
+ }
28005
+ const call = from.slice(open2, end);
28006
+ const li = call.indexOf("line_items");
28007
+ if (li < 0) return [];
28008
+ const items = call.slice(li);
28009
+ const out = [];
28010
+ for (const m of items.matchAll(/\bprice\s*:\s*/g)) {
28011
+ let d = 0;
28012
+ let j = m.index + m[0].length;
28013
+ const start = j;
28014
+ for (; j < items.length; j++) {
28015
+ const c = items[j];
28016
+ if (c === "(" || c === "{" || c === "[") d++;
28017
+ else if (c === ")" || c === "}" || c === "]") {
28018
+ if (d === 0) break;
28019
+ d--;
28020
+ } else if ((c === "," || c === "\n") && d === 0) break;
28021
+ }
28022
+ const expr = items.slice(start, j).trim();
28023
+ if (expr) out.push(expr);
28024
+ if (out.length >= 5) break;
28025
+ }
28026
+ return out;
28027
+ }
28028
+ function pairCall(root, s2, plans, priceIds, calls) {
28029
+ if (plans.length === 1) return { plan: String(plans[0].name) };
28030
+ if (!plans.length || plans.some((p) => String(p.name).startsWith("TODO"))) return {};
28031
+ const sold = s2.params ? plansSoldBy(s2.params, plans) : [];
28032
+ if (sold.length === 1) return { plan: String(sold[0].name) };
28033
+ const items = Array.isArray(s2.params?.line_items) ? s2.params.line_items : [];
28034
+ const literalIds = items.map((li) => li && typeof li.price === "string" ? li.price : void 0).filter((x) => !!x);
28035
+ const exprs = priceExpressions(root, s2.file, s2.line).filter((e) => !/^['"`][^'"`]*['"`]$/.test(e));
28036
+ for (const id of literalIds) {
28037
+ const owner = plans.filter((p) => priceIds.get(String(p.name))?.includes(id));
28038
+ if (owner.length === 1) return { plan: String(owner[0].name), why: `${id} is the Price id of that plan in the plans module` };
28039
+ }
28040
+ const runtime = exprs.find((e) => /\?|\|\||&&|\[[^\]'"`]*\]|\(/.test(e));
28041
+ if (runtime && !literalIds.length) return calls === 1 ? { several: true } : { ref: runtime };
28042
+ for (const ref of [...literalIds, ...exprs]) {
28043
+ const hint = intervalHintOf(ref);
28044
+ if (!hint) continue;
28045
+ const match = plans.filter((p) => {
28046
+ const count = Number(p.interval_count ?? 1) || 1;
28047
+ const unit2 = p.interval === "month" && count === 12 ? "year" : count === 1 ? p.interval : void 0;
28048
+ return unit2 === hint;
28049
+ });
28050
+ if (match.length === 1) return { plan: String(match[0].name), why: `its Price ${ref} is named for a ${hint === "day" ? "dai" : hint}ly price; check this is the plan it sells` };
28051
+ }
28052
+ return { ref: literalIds[0] ?? exprs[0] };
28053
+ }
27483
28054
  function scaffoldConfig(root, env2 = {}, facts = {}) {
27484
28055
  const det = detectProject(root, env2);
27485
28056
  const todos = [];
27486
28057
  const found = [];
28058
+ const notes2 = [];
27487
28059
  const names = /* @__PURE__ */ new Set();
27488
28060
  const merchant = {};
27489
28061
  const fact = (k) => typeof facts[k] === "string" && facts[k].trim() ? facts[k].trim() : void 0;
@@ -27511,8 +28083,21 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27511
28083
  const m = initPlans.find((x) => typeof x.name === "string" && x.amount === p.amount && x.interval === p.interval && (Number(x.interval_count ?? 1) || 1) === (p.interval_count ?? 1));
27512
28084
  return m ? String(m.name) : void 0;
27513
28085
  };
28086
+ const literals = [];
28087
+ walk3(root, (rel2) => {
28088
+ if (!PLAN_FILE.test(rel2) || literals.length >= 8) return;
28089
+ let text = "";
28090
+ try {
28091
+ text = readFileSync6(join5(root, rel2), "utf8");
28092
+ } catch {
28093
+ return;
28094
+ }
28095
+ for (const p of plansFromLiterals(text, rel2)) if (!literals.some((x) => x.name === p.name)) literals.push(p);
28096
+ });
28097
+ const minorOf = (p) => typeof p.amount === "number" ? p.amount : typeof p.price === "number" ? Math.round(p.price * 100) : NaN;
28098
+ const literalFor = (p) => literals.find((x) => Math.round(x.price * 100) === minorOf(p) && x.interval === p.interval && (x.interval_count ?? 1) === (Number(p.interval_count ?? 1) || 1));
27514
28099
  const plans = det.plans.map((p, i) => ({
27515
- name: p.name ?? initName(p) ?? `Plan ${i + 1}`,
28100
+ name: p.name ?? initName(p) ?? literalFor(p)?.name ?? `Plan ${i + 1}`,
27516
28101
  amount: p.amount,
27517
28102
  currency: p.currency,
27518
28103
  interval: p.interval,
@@ -27520,31 +28105,28 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27520
28105
  ...p.trial_days ? { trial_days: p.trial_days } : {}
27521
28106
  }));
27522
28107
  for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
27523
- const planKeys = /* @__PURE__ */ new Map();
27524
28108
  if (!plans.length && Array.isArray(facts.plans)) {
27525
28109
  for (const [i, p] of facts.plans.entries()) {
27526
28110
  if (typeof p.amount !== "number" || typeof p.interval !== "string") continue;
27527
- plans.push({ name: typeof p.name === "string" ? p.name : `Plan ${i + 1}`, amount: p.amount, currency: typeof p.currency === "string" ? p.currency : "usd", interval: p.interval, ...typeof p.interval_count === "number" ? { interval_count: p.interval_count } : {}, ...typeof p.trial_days === "number" ? { trial_days: p.trial_days } : {} });
28111
+ plans.push({ name: typeof p.name === "string" && p.name.trim() ? p.name : literalFor(p)?.name ?? `Plan ${i + 1}`, amount: p.amount, currency: typeof p.currency === "string" ? p.currency : "usd", interval: p.interval, ...typeof p.interval_count === "number" ? { interval_count: p.interval_count } : {}, ...typeof p.trial_days === "number" ? { trial_days: p.trial_days } : {} });
27528
28112
  found.push(`plan ${String(plans[plans.length - 1].name)} (from checkbox init --plan)`);
27529
28113
  }
27530
28114
  }
27531
28115
  if (!plans.length) {
27532
- walk3(root, (rel2) => {
27533
- if (!PLAN_FILE.test(rel2) || plans.length >= 8) return;
27534
- let text = "";
27535
- try {
27536
- text = readFileSync6(join5(root, rel2), "utf8");
27537
- } catch {
27538
- return;
27539
- }
27540
- for (const p of plansFromLiterals(text, rel2)) {
27541
- if (plans.some((x) => x.name === p.name)) continue;
27542
- const { source, key, ...plan2 } = p;
27543
- plans.push(plan2);
27544
- if (key) planKeys.set(p.name, key);
27545
- found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
27546
- }
27547
- });
28116
+ for (const p of literals) {
28117
+ const { source, key: _key, price_ids: _ids, ...plan2 } = p;
28118
+ plans.push(plan2);
28119
+ found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
28120
+ }
28121
+ }
28122
+ const planKeys = /* @__PURE__ */ new Map();
28123
+ const planPriceIds = /* @__PURE__ */ new Map();
28124
+ for (const p of plans) {
28125
+ const lit = literals.find((x) => x.name === p.name) ?? literalFor(p);
28126
+ if (lit?.key) planKeys.set(String(p.name), lit.key);
28127
+ if (lit?.price_ids?.length) planPriceIds.set(String(p.name), lit.price_ids);
28128
+ const known = initPlans.find((x) => Array.isArray(x.stripe_price_ids) && minorOf(x) === minorOf(p) && x.interval === p.interval && (Number(x.interval_count ?? 1) || 1) === (Number(p.interval_count ?? 1) || 1));
28129
+ if (known) planPriceIds.set(String(p.name), [...planPriceIds.get(String(p.name)) ?? [], ...known.stripe_price_ids]);
27548
28130
  }
27549
28131
  if (!plans.length) {
27550
28132
  plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
@@ -27588,11 +28170,20 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27588
28170
  } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
27589
28171
  else found.push(`${s2.file}:${s2.line}: Checkout Session`);
27590
28172
  entry.strict = true;
27591
- const sold = s2.params ? plansSoldBy(s2.params, plans) : [];
27592
- if (plans.length === 1 || sold.length === 1) stripe.push({ ...entry, plan: String((sold[0] ?? plans[0]).name) });
27593
- else if (s2.params && plans.length > 1 && !plans.some((p) => String(p.name).startsWith("TODO"))) {
28173
+ const pair = pairCall(root, s2, plans, planPriceIds, det.checkoutSessions.length);
28174
+ if (pair.plan) {
28175
+ if (plans.length > 1) {
28176
+ names.delete(String(entry.name));
28177
+ entry.name = unique(names, `${String(entry.name).replace(/\)$/, "")}: ${pair.plan})`);
28178
+ }
28179
+ stripe.push({ ...entry, plan: pair.plan });
28180
+ if (pair.why) found.push(`${s2.file}:${s2.line}: sells plan "${pair.plan}" (${pair.why})`);
28181
+ } else if (pair.several) {
27594
28182
  for (const p of plans) stripe.push({ ...entry, name: unique(names, `${String(entry.name).replace(/\)$/, "")}: ${String(p.name)})`), plan: String(p.name) });
27595
28183
  names.delete(String(entry.name));
28184
+ } else if (plans.length > 1 && !plans.some((p) => String(p.name).startsWith("TODO"))) {
28185
+ stripe.push({ ...entry, plan: `TODO: which plan does this call sell? one of ${oneOf}` });
28186
+ todos.push(`${s2.file}:${s2.line}: which plan this Checkout call sells ("plan"); Checkbox could not tell from its Price id${pair.ref ? ` (${pair.ref})` : ""}`);
27596
28187
  } else stripe.push(entry);
27597
28188
  }
27598
28189
  const emails = [];
@@ -27602,12 +28193,22 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27602
28193
  if (IN_EMAIL_DIR.test(rel2)) {
27603
28194
  const kind = emailKindOf(rel2);
27604
28195
  if (/\.(?:html?|txt|md)$/i.test(rel2) && kind) {
27605
- emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...planFor() });
27606
- found.push(`${rel2}: ${kind} email`);
28196
+ let text = "";
28197
+ try {
28198
+ text = readFileSync6(join5(root, rel2), "utf8");
28199
+ } catch {
28200
+ }
28201
+ const tags = Object.fromEntries(mergeTagsIn(text).flatMap((t) => mergeTagField(t) ? [[t, mergeTagField(t)]] : []));
28202
+ const planTagged = Object.values(tags).some((v) => v.startsWith("plan."));
28203
+ const hint = intervalHintOf(basename(rel2).replace(/\.[^.]+$/, ""));
28204
+ const byName = hint && plans.length > 1 ? plans.filter((p) => p.interval === hint && (Number(p.interval_count ?? 1) || 1) === 1) : [];
28205
+ const plan2 = plans.length > 1 && planTagged ? { plan: "all" } : byName.length === 1 ? { plan: String(byName[0].name) } : planFor();
28206
+ emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...plan2, ...Object.keys(tags).length ? { merge_tags: tags } : {} });
28207
+ found.push(`${rel2}: ${kind} email${Object.keys(tags).length ? ` (its merge tags ${Object.keys(tags).map((t) => `{{${t}}}`).join(", ")} are filled from checkbox.json: see "merge_tags")` : ""}`);
27607
28208
  return;
27608
28209
  }
27609
28210
  if (/\.[jt]sx$|\.mjml$/i.test(rel2) && kind) {
27610
- todos.push(`${rel2}: a ${kind} email component; add it with "module": "<file>#<export>" returning the rendered { subject, html } (React Email: render() with sample props)`);
28211
+ notes2.push(`${rel2}: a ${kind} email component; add it with "module": "<file>#<export>" returning the rendered { subject, html } (React Email: render() with sample props)`);
27611
28212
  return;
27612
28213
  }
27613
28214
  }
@@ -27631,8 +28232,22 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27631
28232
  }
27632
28233
  }
27633
28234
  });
27634
- if (!disclosures.length && det.payButtons.length) {
27635
- todos.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
28235
+ for (const ps of pageSources(root)) {
28236
+ if (pages.some((p) => p.source === ps.source || p.file === ps.file)) continue;
28237
+ let text = "";
28238
+ try {
28239
+ text = readFileSync6(join5(root, ps.source), "utf8");
28240
+ } catch {
28241
+ }
28242
+ const kind = /checkout|subscribe/i.test(ps.route) ? "checkout" : "pricing";
28243
+ const shows = (p) => text.includes(String(p.name)) || typeof p.price === "number" && text.includes(`$${p.price}`) || typeof p.amount === "number" && text.includes(`$${p.amount / 100}`);
28244
+ const showsAll = plans.length > 1 && plans.every(shows);
28245
+ pages.push({ name: unique(names, `Page: ${ps.route}`), file: ps.file, source: ps.source, kind, ...showsAll ? { plan: "all" } : planFor("page") });
28246
+ found.push(`${ps.source}: ${kind} page (checked as the built ${ps.file}; the workflow builds it first)`);
28247
+ }
28248
+ const inPages = (f) => pages.some((p) => p.source === f);
28249
+ if (!disclosures.length && det.payButtons.length && !det.payButtons.some((b) => inPages(b.file))) {
28250
+ notes2.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
27636
28251
  }
27637
28252
  if (planTodo) todos.push('plan: pick the plan of each disclosure, page and email entry marked TODO ("all" for a page that shows every plan)');
27638
28253
  const checks = {};
@@ -27642,20 +28257,24 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27642
28257
  if (emails.length) checks.emails = emails;
27643
28258
  checks.notice_schedule = true;
27644
28259
  if (!stripe.length && !disclosures.length && !pages.length && !emails.length) {
27645
- todos.push('No checkout code, email templates or pages were found: add entries under "checks" (https://checkbox.site/docs/guides/ci#config)');
28260
+ notes2.push('No checkout code, email templates or pages were found: add entries under "checks" (https://checkbox.site/docs/guides/ci#config)');
27646
28261
  }
27647
28262
  return {
27648
28263
  config: { $schema: SCHEMA_URL, merchant, jurisdictions: "all", plans, checks },
27649
28264
  todos,
27650
- found
28265
+ found,
28266
+ notes: notes2,
28267
+ todoValues: countTodoValues({ merchant, plans, checks })
27651
28268
  };
27652
28269
  }
27653
- var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
28270
+ var PAGE_ROUTE, IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
27654
28271
  var init_scaffold = __esm({
27655
28272
  "src/ci/scaffold.ts"() {
27656
28273
  "use strict";
27657
28274
  init_detect();
28275
+ init_preflight();
27658
28276
  init_config3();
28277
+ PAGE_ROUTE = /^(pricing|checkout|subscribe|plans)$/i;
27659
28278
  IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify", ".turbo", ".cache", "coverage", ".astro", "vendor", ".venv", "__pycache__", ".github"]);
27660
28279
  MAX_FILES2 = 5e3;
27661
28280
  EMAIL_NAMES = [
@@ -27846,8 +28465,8 @@ function loaderFlags(file, dir, nodeVersion = process.versions.node, features =
27846
28465
  if (hasTsx(dir)) return ["--import", "tsx"];
27847
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`;
27848
28467
  if (features.typescript) return [];
27849
- const [major, minor] = nodeVersion.split(".").map(Number);
27850
- 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"];
27851
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`;
27852
28471
  }
27853
28472
  function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
@@ -27917,7 +28536,23 @@ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
27917
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`);
27918
28537
  const parsed = parseObjectLiteral(lit, env2, consts);
27919
28538
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
27920
- 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;
27921
28556
  }
27922
28557
  function lineOfAny(text, needles) {
27923
28558
  const lower2 = text.toLowerCase();
@@ -28028,7 +28663,8 @@ async function stripeJob(loaded, e, o, root) {
28028
28663
  if (r.unresolved.length) {
28029
28664
  job.partial = true;
28030
28665
  job.unresolved = r.unresolved;
28031
- 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.`);
28032
28668
  }
28033
28669
  }
28034
28670
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
@@ -28161,6 +28797,53 @@ function pageJob(loaded, e, o, root) {
28161
28797
  }
28162
28798
  return job;
28163
28799
  }
28800
+ function planMoney(plan2) {
28801
+ if (!plan2) return void 0;
28802
+ const cur = (plan2.currency ?? "usd").toUpperCase();
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;
28805
+ try {
28806
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major2);
28807
+ } catch {
28808
+ return `${major2.toFixed(2)} ${cur}`;
28809
+ }
28810
+ }
28811
+ function fillMergeTags(msg2, tags, config, plan2) {
28812
+ const field = (v) => {
28813
+ if (!MERGE_TAG_FIELDS.includes(v)) return v;
28814
+ switch (v) {
28815
+ case "plan.price":
28816
+ case "plan.amount":
28817
+ return planMoney(plan2);
28818
+ case "plan.interval":
28819
+ return plan2 ? (plan2.interval_count ?? 1) > 1 ? `${plan2.interval_count} ${plan2.interval}s` : plan2.interval : void 0;
28820
+ case "plan.name":
28821
+ return plan2?.name;
28822
+ case "plan.trial_days":
28823
+ return plan2?.trial_days !== void 0 ? String(plan2.trial_days) : void 0;
28824
+ case "plan.currency":
28825
+ return (plan2?.currency ?? "usd").toUpperCase();
28826
+ case "merchant.name":
28827
+ return config.merchant?.name;
28828
+ case "merchant.cancel_url":
28829
+ return config.merchant?.cancel_url;
28830
+ case "merchant.support_contact":
28831
+ return config.merchant?.support_contact;
28832
+ }
28833
+ return void 0;
28834
+ };
28835
+ const values = /* @__PURE__ */ new Map();
28836
+ for (const [k, v] of Object.entries(tags)) {
28837
+ const val = field(v);
28838
+ if (val !== void 0) values.set(k.toLowerCase(), val);
28839
+ }
28840
+ const esc3 = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28841
+ const fill2 = (t, html) => t?.replace(/\{\{\{\s*([\w.-]+)\s*\}\}\}|\{\{\s*([\w.-]+)\s*\}\}|\*\|([\w.-]+)\|\*/g, (m, a, b, c) => {
28842
+ const v = values.get(String(a ?? b ?? c).toLowerCase());
28843
+ return v === void 0 ? m : html ? esc3(v) : v;
28844
+ });
28845
+ return { ...msg2.subject !== void 0 ? { subject: fill2(msg2.subject, false) } : {}, ...msg2.text !== void 0 ? { text: fill2(msg2.text, false) } : {}, ...msg2.html !== void 0 ? { html: fill2(msg2.html, true) } : {} };
28846
+ }
28164
28847
  async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
28165
28848
  const { config, dir } = loaded;
28166
28849
  const job = { name, type: "email", ...planName ? { plan: planName } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
@@ -28181,6 +28864,8 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
28181
28864
  msg2 = { ...typeof r.subject === "string" ? { subject: r.subject } : e.subject ? { subject: e.subject } : {}, ...typeof r.text === "string" ? { text: r.text } : {}, ...typeof r.html === "string" ? { html: r.html } : {} };
28182
28865
  } else throw new InputError(`${e.module} must return { subject, text | html } or a string`);
28183
28866
  } else msg2 = { subject: e.subject, ...e.text ? { text: e.text } : {}, ...e.html ? { html: e.html } : {} };
28867
+ const tags = { ...config.merge_tags ?? {}, ...e.merge_tags ?? {} };
28868
+ if (Object.keys(tags).length) msg2 = fillMergeTags(msg2, tags, config, pickPlan(config, planName));
28184
28869
  if (!msg2.subject) throw new InputError(`no subject: add "subject" to the entry, a first line "Subject: \u2026" to the file, or an HTML <title>`);
28185
28870
  if (!msg2.text && !msg2.html) throw new InputError("the email body is empty");
28186
28871
  const terms2 = { ...planTerms(config, pickPlan(config, planName)) };
@@ -28246,7 +28931,7 @@ async function planJobs(loaded, o, root) {
28246
28931
  }
28247
28932
  return jobs;
28248
28933
  }
28249
- var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
28934
+ var InputError, NOT_INSTALLED, CHILD, SPREAD_VERDICT_KEYS, jurisdictionsOf;
28250
28935
  var init_inputs = __esm({
28251
28936
  "src/ci/inputs.ts"() {
28252
28937
  "use strict";
@@ -28274,6 +28959,7 @@ if (typeof v === 'function') v = await v(...JSON.parse(argsJson));
28274
28959
  process.stdout.write('\\n__CHECKBOX_JSON__' + JSON.stringify(v === undefined ? null : v) + '\\n');
28275
28960
  process.exit(0);
28276
28961
  `;
28962
+ SPREAD_VERDICT_KEYS = ["mode", "consent_collection", "custom_text", "subscription_data", "discounts"];
28277
28963
  jurisdictionsOf = (config) => config.jurisdictions ? { jurisdictions: config.jurisdictions } : {};
28278
28964
  }
28279
28965
  });
@@ -28606,6 +29292,7 @@ var init_timing = __esm({
28606
29292
  var notice_schedule_exports = {};
28607
29293
  __export(notice_schedule_exports, {
28608
29294
  computeNoticeSchedule: () => computeNoticeSchedule,
29295
+ noticesKindsNote: () => noticesKindsNote,
28609
29296
  singleDayNote: () => singleDayNote
28610
29297
  });
28611
29298
  function singleDayNote(requirementIds, days) {
@@ -28714,7 +29401,12 @@ function computeNoticeSchedule(input, now = /* @__PURE__ */ new Date()) {
28714
29401
  if (t.governed) push("annual_terms", termMonths2 >= 12 ? "Yearly terms reminder (first anniversary; the renewal reminder can carry it)" : "Yearly terms reminder (first anniversary)", anniversary, t);
28715
29402
  else notRequired.push({ kind: "annual_terms", reason: "No applicable requirement demands a yearly terms reminder under these jurisdictions." });
28716
29403
  }
28717
- if (!input.price_change) {
29404
+ if (!input.price_change && plan2.introAmount !== void 0 && plan2.introAmount < plan2.amount) {
29405
+ const stepUp = addInterval(firstCharge, plan2.interval, plan2.intervalCount * Math.max(1, plan2.introPeriods ?? 1));
29406
+ const t = timing("price_change", stepUp);
29407
+ if (t.governed) push("price_change", `Intro price ends: the regular price is first charged`, stepUp, t);
29408
+ else notRequired.push({ kind: "price_change", reason: "The intro price ends, but no applicable requirement demands a price-change notice for it under these jurisdictions." });
29409
+ } else if (!input.price_change) {
28718
29410
  notRequired.push({ kind: "price_change", reason: "No price change planned. Pass price_change (effective_date, new_price) to schedule its advance notice." });
28719
29411
  } else {
28720
29412
  const eff = parseDate(input.price_change.effective_date, "price_change.effective_date", start);
@@ -28741,6 +29433,24 @@ function computeNoticeSchedule(input, now = /* @__PURE__ */ new Date()) {
28741
29433
  }
28742
29434
  };
28743
29435
  }
29436
+ function noticesKindsNote(kinds, settings) {
29437
+ const scheduled = [...new Set(kinds)];
29438
+ if (!scheduled.length) return null;
29439
+ if (!settings || settings.mode !== "checkbox") {
29440
+ return {
29441
+ code: "notices_you_send",
29442
+ message: `This site's notices are in "You send" mode: your code sends ${scheduled.join(", ")} at these dates (Checkbox tracks them and can call your webhook when each is due). To have Checkbox send them: updateNoticeSettings { "mode": "checkbox", "add_kinds": ${JSON.stringify(scheduled)} }.`,
29443
+ param: "site"
29444
+ };
29445
+ }
29446
+ const off = scheduled.filter((k) => !settings.enabledKinds.includes(k));
29447
+ if (!off.length) return null;
29448
+ return {
29449
+ code: "notices_kinds_off",
29450
+ message: `Checkbox Notices does not send ${off.join(", ")} for this site yet: updateNoticeSettings { "add_kinds": ${JSON.stringify(off)} } turns them on (or your code sends them at these dates).`,
29451
+ param: "site"
29452
+ };
29453
+ }
28744
29454
  var CITATION, BY_ID2, day, unix;
28745
29455
  var init_notice_schedule = __esm({
28746
29456
  "../../apps/api/src/v1/checks/notice-schedule.ts"() {
@@ -28781,10 +29491,41 @@ function operationFor2(kind) {
28781
29491
  function onlineEngine(client) {
28782
29492
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28783
29493
  }
29494
+ function resultOf(status, exit, strict) {
29495
+ if (exit === 0) return status === "needs_review" ? "passed with items to review (they do not fail this run)" : "passed";
29496
+ if (status === "needs_review") return `failed: items need review and ${strict ? "--strict is on" : 'a "strict" entry or a blocking check makes them fail the run'} (exit ${exit})`;
29497
+ if (status === "skipped") return `failed: nothing was checked (exit ${exit})`;
29498
+ if (status === "fail") return `failed: fixes needed (exit ${exit})`;
29499
+ return `failed: could not run every check (exit ${exit})`;
29500
+ }
28784
29501
  function ciVocabulary(text) {
28785
- return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored").replace(/comes from merchant\.price and is not verified against the Price\. Connect Stripe on the site, or register the params with createStripeCheckoutConfig on a site whose Stripe account has this Price\./g, "comes from the plan's price in checkbox.json and is not verified against the Price. Connect Stripe on the site in Checkbox, then run checkbox ci --online (CHECKBOX_SECRET_KEY) so Checkbox reads the Price.").replace(/comes from merchant\.price\b/g, "comes from the plan's price in checkbox.json").replace(/\bmerchant\.price says\b/g, "The plan's price in checkbox.json says").replace(/\(or pass merchant\.cancel_url\)/g, "(or set merchant.cancel_url in checkbox.json)").replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
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).');
29503
+ }
29504
+ function pricingPageFixes(fixes, replacements = []) {
29505
+ return concreteCardFixes(pricingPageFixesRaw(fixes), replacements);
28786
29506
  }
28787
- function pricingPageFixes(fixes) {
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) {
28788
29529
  const reworded = fixes.map(
28789
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")
28790
29531
  );
@@ -28833,8 +29574,10 @@ function fixesOf(r, onlyNeedsFix = false) {
28833
29574
  const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
28834
29575
  if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
28835
29576
  cards = cards.filter((x) => !plain.includes(x));
28836
- const standOut = `Plan card ${plain.map((x) => `\u201C${x}\u201D`).join(", ")}: ${STAND_OUT_FIX}`;
28837
- if (!out.includes(standOut)) out.push(standOut);
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
+ }
28838
29581
  }
28839
29582
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
28840
29583
  if (text && !out.includes(text)) out.push(text);
@@ -28862,22 +29605,29 @@ function withPlaceholders(params, placeholders) {
28862
29605
  }
28863
29606
  return copy;
28864
29607
  }
28865
- function correctedOf(type, r, partial = false, placeholders) {
29608
+ function correctedOf(type, r, partial = false, placeholders, pricingPage = false) {
28866
29609
  if (type === "stripe_checkout" && r.correction?.patch?.length && partial) {
28867
29610
  return { format: "json", title: `Changes to make to the params (JSON Patch; keep your other values)${r.correction.verification?.passes ? ", verified by the rules engine" : ""}`, body: JSON.stringify(r.correction.patch, null, 2) };
28868
29611
  }
28869
29612
  if (type === "stripe_checkout" && r.correction?.patch?.length) {
28870
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) };
28871
29614
  }
28872
- const perPlan = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
28873
- 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;
28874
29620
  const all = perPlan.every((p) => p.verified);
28875
29621
  return {
28876
29622
  format: "text",
28877
- title: `Suggested replacement per plan, each directly above its own plan's button${all ? " (each passes the checks, verified by the rules engine)" : ""}`,
28878
- body: perPlan.map((p) => `${p.plan}:
28879
- ${p.disclosure_text}${p.consent_label ? `
28880
- Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
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)" : ""}`,
29624
+ // Round 32 (pilot 31, ci-pr-agent): a pricing page links to the checkout, where consent is asked (Stripe Checkout
29625
+ // shows its own checkbox), so no consent checkbox label is suggested for it.
29626
+ body: `${perPlan.map((p) => `${p.plan}:
29627
+ ${p.disclosure_text}${p.consent_label && !pricingPage ? `
29628
+ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")}${perPlan.some((p) => p.html && BOXED_HTML.test(p.html)) ? `
29629
+
29630
+ ${BOXED_LINE}` : ""}`
28881
29631
  };
28882
29632
  }
28883
29633
  if ((type === "disclosure" || type === "page") && r.replacement?.disclosure_text && ciStatusOf(r) !== "pass") {
@@ -28889,11 +29639,14 @@ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
28889
29639
 
28890
29640
  Fuller version: ${r.replacement.disclosure_text}` : r.replacement.disclosure_text}${r.replacement.consent_label ? `
28891
29641
 
28892
- 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}` : ""}`
28893
29645
  };
28894
29646
  }
28895
29647
  if (type === "email" && r.template && ciStatusOf(r) !== "pass" && r.verdict !== "not_applicable") {
28896
- return { format: "text", title: `Suggested template${r.template.verified ? " (passes the checks, verified by the rules engine)" : ""}`, body: `Subject: ${r.template.subject}
29648
+ const toFill = r.template.placeholders ?? [];
29649
+ return { format: "text", title: `Suggested template${r.template.verified ? toFill.length ? ` (passes the checks once you replace ${toFill.join(", ")} with your real value${toFill.length === 1 ? "" : "s"}${toFill.includes("{{support_contact}}") ? ", or set merchant.support_contact in checkbox.json" : ""})` : " (passes the checks, verified by the rules engine)" : ""}`, body: `Subject: ${r.template.subject}
28897
29650
 
28898
29651
  ${r.template.text}` };
28899
29652
  }
@@ -28937,13 +29690,20 @@ function noticeCoverage(config, planName, schedule, opts = {}) {
28937
29690
  const required = /* @__PURE__ */ new Map();
28938
29691
  for (const n of schedule.notices ?? []) {
28939
29692
  const k = NOTICE_TO_EMAIL[n.kind];
28940
- if (k && !required.has(k)) required.set(k, { occasion: n.occasion, days: n.days_before });
28941
- }
29693
+ if (k && !required.has(k)) required.set(k, { occasion: n.occasion, days: n.days_before, citations: n.citations ?? [], jurisdictions: n.jurisdictions ?? [] });
29694
+ }
29695
+ const law = (w) => {
29696
+ if (!w.citations.length) return "";
29697
+ const where2 = w.jurisdictions.map((j) => j.replace(/^US-/, "")).filter((j) => j !== "FED");
29698
+ const fed = w.jurisdictions.includes("US-FED");
29699
+ const places = [...fed ? ["federal"] : [], ...where2];
29700
+ return ` Required by ${w.citations.slice(0, 2).join("; ")}${w.citations.length > 2 ? ` and ${w.citations.length - 2} more` : ""}${places.length ? ` (${places.length > 4 ? `${places.slice(0, 4).join(", ")} and ${places.length - 4} more` : places.join(", ")})` : ""}.`;
29701
+ };
28942
29702
  const missing = [...required].filter(([k]) => !have.has(k));
28943
29703
  if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
28944
29704
  const sent2 = [...required.keys()].filter((k) => byCheckbox.has(k)).length;
28945
29705
  if (!missing.length) return { status: "pass", label: `Checks pass \xB7 ${required.size} required email${required.size === 1 ? "" : "s"} covered${sent2 ? ` (${sent2} sent by Checkbox Notices, ${real ? "as the site's Notice settings confirm" : "as checkbox.json declares; --online confirms it against the site"})` : ""}`, fixes: [] };
28946
- const fixes = missing.map(([k, w]) => `Add ${/^[aeiou]/i.test(EMAIL_LABEL[k]) ? "an" : "a"} ${EMAIL_LABEL[k]} to checks.emails (kind "${k}"; ${w.days > 0 ? `${w.days} days before: ${w.occasion}` : w.occasion}), or have Checkbox Notices send it${declared.includes(k) ? " (checkbox.json says it does, but the site's Notice settings do not send it)" : ""}.`);
29706
+ const fixes = missing.map(([k, w]) => `Add ${/^[aeiou]/i.test(EMAIL_LABEL[k]) ? "an" : "a"} ${EMAIL_LABEL[k]} to checks.emails (kind "${k}"; ${w.days > 0 ? `${w.days} days before: ${w.occasion}` : w.occasion}), or have Checkbox Notices send it${declared.includes(k) ? " (checkbox.json says it does, but the site's Notice settings do not send it)" : ` ("notices": { "sent_by_checkbox": true } in checkbox.json once Notices is on for the site)`}.${law(w)}`);
28947
29707
  return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
28948
29708
  }
28949
29709
  function unverifiedOffer(r) {
@@ -28957,6 +29717,11 @@ function unverifiedOffer(r) {
28957
29717
  if (coupon && open2.length) return coupon.message;
28958
29718
  return void 0;
28959
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
+ }
28960
29725
  async function runJob(job, engine, config, opts = {}) {
28961
29726
  const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
28962
29727
  if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
@@ -28970,22 +29735,41 @@ async function runJob(job, engine, config, opts = {}) {
28970
29735
  return { ...base, status: "error", label: "Could not run", message: `${ciVocabulary(e.message)}${e.param && !failure && /^merchant\./.test(e.param) ? "" : e.param ? ` (${e.param})` : ""}`, fixes: [], ...failure ? { failure } : {} };
28971
29736
  }
28972
29737
  if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report, opts), report };
29738
+ const pairing = (report.warnings ?? []).find((w) => w.code === "price_plan_conflict");
29739
+ if (pairing) {
29740
+ const names = (config.plans ?? []).map((p) => JSON.stringify(p.name)).join(", ");
29741
+ return { ...base, status: "error", label: "Could not run \xB7 plan pairing", message: `${pairing.message.replace(/\. Checkbox offers no corrected params[\s\S]*$/, "")}. Set "plan" on "${job.name}" to the plan this call really sells (one of ${names}); Checkbox shows no fix until the pairing agrees, because a fix would write the other plan's price into this checkout.`, fixes: [], report };
29742
+ }
28973
29743
  const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
28974
29744
  for (const w of report.warnings ?? []) {
28975
- const text = ciVocabulary(w.message);
29745
+ const text = w.code === "site_plans_differ" ? sitePlansDifferInCi(w.message) : ciVocabulary(w.message);
28976
29746
  base.warnings.push(text);
28977
29747
  if (ALERT_CODES.has(w.code)) alerts.push(text);
28978
29748
  }
28979
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
+ }
28980
29755
  const status = ciStatusOf(report);
28981
29756
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28982
29757
  let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
29758
+ const tagsLeft = job.type === "email" && status !== "pass" ? (report.warnings ?? []).find((w) => w.code === "unresolved_placeholders") : void 0;
29759
+ if (tagsLeft) {
29760
+ const tags = [...new Set(tagsLeft.message.match(/\{\{\{?\s*[\w.-]+\s*\}?\}\}|\*\|[\w.-]+\|\*/g) ?? [])];
29761
+ const sample = tags.slice(0, 3).map((t) => t.replace(/[{}*|\s]/g, "")).map((t) => `"${t}": "${/price|amount|cost/i.test(t) ? "plan.price" : /interval|period|frequency|cycle/i.test(t) ? "plan.interval" : /trial/i.test(t) ? "plan.trial_days" : /plan/i.test(t) ? "plan.name" : /cancel/i.test(t) ? "merchant.cancel_url" : "a sample value"}"`);
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.`);
29763
+ }
28983
29764
  const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
28984
- if (pricingPage) fixes = pricingPageFixes(fixes);
28985
- const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
28986
- const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review" || status === "pass" && unreadText.some((p) => /^(?:custom_text|consent_collection|discounts|subscription_data\.trial)/.test(p)));
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));
28987
29770
  if (unread) {
28988
- 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" : ""}.`);
28989
29773
  }
28990
29774
  const literal = [];
28991
29775
  if (unread) {
@@ -29024,7 +29808,7 @@ async function runJob(job, engine, config, opts = {}) {
29024
29808
  );
29025
29809
  }
29026
29810
  const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon || unreadCoupon ? "needs_review" : status;
29027
- const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
29811
+ const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders, pricingPage);
29028
29812
  if (corrected?.format === "json" && job.placeholders) {
29029
29813
  const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
29030
29814
  if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
@@ -29036,7 +29820,7 @@ async function runJob(job, engine, config, opts = {}) {
29036
29820
  ...base,
29037
29821
  status: finalStatus,
29038
29822
  ...blocking ? { blocking: true } : {},
29039
- label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : unreadCoupon ? "Needs review \xB7 coupon not read" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
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),
29040
29824
  ...fixes[0] ? { fix: fixes[0] } : {},
29041
29825
  fixes,
29042
29826
  ...corrected ? { corrected } : {},
@@ -29104,10 +29888,11 @@ async function runCi(jobs, engine, opts) {
29104
29888
  checks,
29105
29889
  ...versioned?.rules_version ? { rules_version: versioned.rules_version } : {},
29106
29890
  ...versioned?.catalog_version ? { catalog_version: versioned.catalog_version } : {},
29107
- disclaimer: CI_DISCLAIMER
29891
+ disclaimer: CI_DISCLAIMER,
29892
+ result: resultOf(status, exit, opts.strict)
29108
29893
  };
29109
29894
  }
29110
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, STAND_OUT_FIX, 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;
29111
29896
  var init_runner = __esm({
29112
29897
  "src/ci/runner.ts"() {
29113
29898
  "use strict";
@@ -29116,6 +29901,7 @@ var init_runner = __esm({
29116
29901
  init_src();
29117
29902
  init_types2();
29118
29903
  init_stripe_verdict();
29904
+ init_set_off_fix();
29119
29905
  init_config3();
29120
29906
  ENGINE_KIND = {
29121
29907
  stripe_checkout: "stripe_checkout_session",
@@ -29124,10 +29910,12 @@ var init_runner = __esm({
29124
29910
  email: "email",
29125
29911
  notice_schedule: "notice_schedule"
29126
29912
  };
29913
+ STRIPE_OBJECTS_OFFLINE_NOTICE = "Stripe objects (Payment Links, and the trial or price set on a Stripe Price) are not checked offline: this change touches a trial, price or interval, so run checkbox ci --online (CHECKBOX_SECRET_KEY, with Stripe connected on the site) to check that Stripe sells the same terms.";
29127
29914
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
29128
29915
  RANK2 = { fail: 0, needs_review: 1 };
29129
29916
  OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
29130
- STAND_OUT_FIX = "Make its renewal line stand out from the text around it: larger type, bold (weight 600 or more), a contrasting colour, or a border.";
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.';
29131
29919
  NOTICE_TO_EMAIL = {
29132
29920
  acknowledgment: "acknowledgment",
29133
29921
  trial_ending: "trial_reminder",
@@ -29172,21 +29960,29 @@ function renderHuman(run2, c, opts = {}) {
29172
29960
  const lines = [];
29173
29961
  const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
29174
29962
  lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
29963
+ const unreadPrices = [];
29175
29964
  const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
29176
29965
  for (const x of run2.checks) {
29177
29966
  const label = x.status === "fail" || x.status === "error" ? c.red(x.label) : x.status === "needs_review" ? c.yellow(x.label) : x.status === "pass" ? c.green(x.label) : c.dim(x.label);
29178
29967
  lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
29179
29968
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
29180
29969
  if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
29181
- if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(` fix: ${wrap(f, 110, " ")}`);
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)`));
29182
29972
  const alerts = x.alerts ?? [];
29183
29973
  for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
29184
- for (const w of x.warnings.filter((w2) => !alerts.includes(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
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, " ")}`));
29975
+ for (const w of x.warnings) {
29976
+ const id = PRICE_UNREAD.exec(w)?.[1];
29977
+ if (id && !unreadPrices.includes(id)) unreadPrices.push(id);
29978
+ }
29185
29979
  if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
29186
29980
  }
29981
+ if (unreadPrices.length) lines.push("", c.dim(wrap(`note: ${unreadPrices.length === 1 ? `Price ${unreadPrices[0]} was` : `Prices ${unreadPrices.join(", ")} were`} not read (no connected Stripe account has ${unreadPrices.length === 1 ? "it" : "them"}), so the amounts checked come from "plans" in checkbox.json. To check them against Stripe, connect Stripe on the site in Checkbox and run checkbox ci --online.`, 110, " ")));
29187
29982
  const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
29188
29983
  if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
29189
- lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
29984
+ if (run2.offline_notice) lines.push("", c.yellow(wrap(`Not checked offline: ${run2.offline_notice}`, 110, " ")));
29985
+ lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}${run2.result ? ` (${run2.result.replace(/ \(exit \d+\)$/, "")})` : ""}`);
29190
29986
  if (run2.checks.some((x) => x.corrected) && !opts.verbose) lines.push(c.dim("Corrected params / text: --verbose, --json, or the job summary in GitHub Actions."));
29191
29987
  lines.push(c.dim(run2.disclaimer));
29192
29988
  return lines;
@@ -29198,8 +29994,9 @@ function annotations(run2) {
29198
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;
29199
29995
  if (!level) continue;
29200
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" : ""}` : "";
29201
29998
  const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
29202
- ${x.fix}` : `Could not run: ${x.message ?? ""}` : [x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
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");
29203
30000
  out.push(`::${level} ${props.join(",")}::${escData(body)}`);
29204
30001
  }
29205
30002
  if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
@@ -29211,6 +30008,8 @@ function stepSummary(run2) {
29211
30008
  const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
29212
30009
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
29213
30010
  if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
30011
+ if (run2.result) lines.push(`Result: ${run2.result}.`, "");
30012
+ if (run2.offline_notice) lines.push(`\u26A0\uFE0F ${run2.offline_notice}`, "");
29214
30013
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
29215
30014
  for (const x of run2.checks) {
29216
30015
  const fix0 = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : x.status === "needs_review" && x.message ? x.message : "";
@@ -29219,7 +30018,7 @@ function stepSummary(run2) {
29219
30018
  }
29220
30019
  const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
29221
30020
  for (const x of detailed) {
29222
- lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
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>`, "");
29223
30022
  if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
29224
30023
  if (x.corrected) {
29225
30024
  const fence = x.corrected.body.includes("```") ? "````" : "```";
@@ -29233,7 +30032,7 @@ function stepSummary(run2) {
29233
30032
  function escapeHtml(s2) {
29234
30033
  return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29235
30034
  }
29236
- var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
30035
+ var TYPE_LABEL, where, PRICE_UNREAD, escData, escProp, cell, STATUS_MD;
29237
30036
  var init_report = __esm({
29238
30037
  "src/ci/report.ts"() {
29239
30038
  "use strict";
@@ -29247,6 +30046,7 @@ var init_report = __esm({
29247
30046
  notice_schedule: "Notice schedule"
29248
30047
  };
29249
30048
  where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
30049
+ PRICE_UNREAD = /^line_items use (\S+), which Checkbox could not read\b/;
29250
30050
  escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
29251
30051
  escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
29252
30052
  cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
@@ -29291,6 +30091,7 @@ function renderPlan(p, c, opts = {}) {
29291
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"}>`)}`);
29292
30092
  else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
29293
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)}`);
29294
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}`));
29295
30096
  }
29296
30097
  const hidden = p.tasks.length - tasks.length;
@@ -29302,7 +30103,7 @@ function statusColor(s2, c) {
29302
30103
  return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
29303
30104
  }
29304
30105
  function renderVerification(v, c) {
29305
- 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");
29306
30107
  const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
29307
30108
  const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
29308
30109
  if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
@@ -29330,7 +30131,7 @@ function workflowYaml(needs = {}) {
29330
30131
  const node = !!(needs.install || needs.build);
29331
30132
  const steps = [" - uses: actions/checkout@v4"];
29332
30133
  if (node) {
29333
- steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 20");
30134
+ steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 22");
29334
30135
  if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
29335
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}`);
29336
30137
  } else {
@@ -29426,9 +30227,13 @@ var init_workflow = __esm({
29426
30227
  // src/commands/ci/index.ts
29427
30228
  var ci_exports = {};
29428
30229
  __export(ci_exports, {
30230
+ anchorToChanges: () => anchorToChanges,
29429
30231
  attachSiteForCoupons: () => attachSiteForCoupons,
29430
30232
  couponIdsIn: () => couponIdsIn,
29431
30233
  default: () => ci_default,
30234
+ diffHunks: () => diffHunks,
30235
+ siteFactsFor: () => siteFactsFor,
30236
+ termsChanged: () => termsChanged,
29432
30237
  touchesConfig: () => touchesConfig,
29433
30238
  undiffableInputs: () => undiffableInputs
29434
30239
  });
@@ -29436,6 +30241,48 @@ import { execFileSync as execFileSync2 } from "node:child_process";
29436
30241
  import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
29437
30242
  import { createInterface } from "node:readline";
29438
30243
  import { dirname as dirname7, join as join9, relative as relative5, resolve as resolve7 } from "node:path";
30244
+ function termsChanged(cwd, base, jobs) {
30245
+ if (jobs.some((j) => j.type === "stripe_checkout" && (j.sold || j.warnings.some((w) => /^The params and checkbox\.json disagree/.test(w))))) return true;
30246
+ try {
30247
+ const args = ["diff", "-U0", "--no-color", ...base ? [`${base}...HEAD`] : ["HEAD"], "--", ".", ":(exclude)*.md", ":(exclude)**/*.test.*", ":(exclude)**/*.lock", ":(exclude)package-lock.json", ":(exclude)pnpm-lock.yaml"];
30248
+ const out = execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
30249
+ return out.split("\n").some((l) => /^[+-](?![+-])/.test(l) && /\b(?:trial(?:_period)?_?days|trialDays|trial_end|unit_amount|interval(?:_count)?|price|amount)\b/i.test(l));
30250
+ } catch {
30251
+ return false;
30252
+ }
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
+ }
29439
30286
  function gitChanged(cwd, base) {
29440
30287
  try {
29441
30288
  const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -29505,6 +30352,36 @@ async function confirm(question) {
29505
30352
  rl.close();
29506
30353
  }
29507
30354
  }
30355
+ async function siteFactsFor(ctx, setupSession) {
30356
+ if (!setupSession) return {};
30357
+ try {
30358
+ if (!ctx.credentials()) return {};
30359
+ const client = ctx.client();
30360
+ const sess = await Promise.race([client.sdk.setupSessions.retrieve(setupSession), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 8e3))]);
30361
+ const out = {};
30362
+ const b = sess?.business ?? {};
30363
+ if (b.merchant_name) out.merchant_name = b.merchant_name;
30364
+ if (b.site_url) out.site_url = b.site_url;
30365
+ const cancel = b.cancellation_url ?? b.account_url;
30366
+ if (cancel) out.account_url = cancel;
30367
+ const plans = (sess?.site_plans ?? []).filter((p) => typeof p.amount === "number" && p.amount > 0 && typeof p.interval === "string");
30368
+ if (plans.length) {
30369
+ out.plans = plans.map((p) => ({
30370
+ // A name that is only the price ("$29.00 / month") is left out: the plans module names the plan better.
30371
+ ...p.name && !/^\W?\s*[\d.,]+/.test(p.name) ? { name: p.name } : {},
30372
+ amount: p.amount,
30373
+ currency: p.currency ?? "usd",
30374
+ interval: p.interval,
30375
+ ...p.interval_count && p.interval_count > 1 ? { interval_count: p.interval_count } : {},
30376
+ ...p.trial_days ? { trial_days: p.trial_days } : {},
30377
+ ...p.stripe_price_ids?.length ? { stripe_price_ids: p.stripe_price_ids } : {}
30378
+ }));
30379
+ }
30380
+ return out;
30381
+ } catch {
30382
+ return {};
30383
+ }
30384
+ }
29508
30385
  async function runInit(ctx, argv) {
29509
30386
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
29510
30387
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
@@ -29515,13 +30392,24 @@ async function runInit(ctx, argv) {
29515
30392
  const withWorkflow = bool(p.flags, "workflow") !== false;
29516
30393
  const projects = ctx.config.load().init?.projects ?? {};
29517
30394
  let facts = {};
30395
+ let setupSession;
29518
30396
  for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
29519
- if (projects[dir]?.facts) {
29520
- facts = projects[dir].facts;
30397
+ const proj = projects[dir];
30398
+ if (proj?.facts || proj?.setup_session) {
30399
+ facts = proj.facts ?? {};
30400
+ setupSession = proj.setup_session;
29521
30401
  break;
29522
30402
  }
29523
30403
  if (dirname7(dir) === dir) break;
29524
30404
  }
30405
+ const fromSite = await siteFactsFor(ctx, setupSession);
30406
+ facts = { ...fromSite, ...facts, ...fromSite.plans && !facts.plans ? { plans: fromSite.plans } : {} };
30407
+ if (Array.isArray(facts.plans) && Array.isArray(fromSite.plans)) {
30408
+ facts.plans = facts.plans.map((p2) => {
30409
+ const m = fromSite.plans.find((x) => x.amount === p2.amount && x.interval === p2.interval);
30410
+ return m && !p2.stripe_price_ids ? { ...p2, stripe_price_ids: m.stripe_price_ids } : p2;
30411
+ });
30412
+ }
29525
30413
  const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
29526
30414
  const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
29527
30415
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
@@ -29553,8 +30441,13 @@ async function runInit(ctx, argv) {
29553
30441
  found: s2.found,
29554
30442
  ...withWorkflow ? { workflow_steps: workflowSteps } : {},
29555
30443
  todos: s2.todos,
30444
+ // Round 32 (pilot 31, ci-pr-agent: "Fill in the 2 TODO item(s)" with 1 TODO in the file): suggestions are notes;
30445
+ // the count is the number of "TODO" values in the file.
30446
+ todo_values: s2.todoValues,
30447
+ notes: s2.notes,
30448
+ ...Object.keys(fromSite).length ? { from_site: Object.keys(fromSite) } : {},
29556
30449
  config: s2.config,
29557
- next_action: s2.todos.length ? { type: "edit_config", message: `Fill in the ${s2.todos.length} TODO item(s) in ${relName(configPath)} (checkbox ci refuses TODO values), then run \`checkbox ci\` and fix what it reports.` } : { type: "run_ci", message: "Run `checkbox ci` locally, fix what it reports, then commit checkbox.json and the workflow." }
30450
+ next_action: s2.todoValues ? { type: "edit_config", message: `Fill in the ${s2.todoValues} "TODO" value${s2.todoValues === 1 ? "" : "s"} in ${relName(configPath)} (checkbox ci refuses TODO values), then run \`checkbox ci\` and fix what it reports.` } : { type: "run_ci", message: "Run `checkbox ci` locally, fix what it reports, then commit checkbox.json and the workflow." }
29558
30451
  };
29559
30452
  ctx.out.result(result, () => {
29560
30453
  const lines = [];
@@ -29564,6 +30457,7 @@ async function runInit(ctx, argv) {
29564
30457
  if (withWorkflow && workflowSteps.length) lines.push("", `The workflow runs ${workflowSteps.map((c) => `\`${c}\``).join(" and ")} before the checks, so they work on a fresh clone.`);
29565
30458
  if (needs.urlPages) lines.push("", 'Note: "url" pages are fetched by the API (checkbox ci --online), which needs a LIVE key (ck_live_\u2026) on an activated account. Without one, list the built page as "file".');
29566
30459
  if (s2.todos.length) lines.push("", "To do:", ...s2.todos.map((t) => ` - ${t}`));
30460
+ if (s2.notes.length) lines.push("", "Also:", ...s2.notes.map((t) => ` - ${t}`));
29567
30461
  lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
29568
30462
  if (!write) lines.push("", ctx.out.c.dim(`${relName(configPath)}:`), ...JSON.stringify(s2.config, null, 2).split("\n").map((l) => ctx.out.c.dim(` ${l}`)));
29569
30463
  return lines;
@@ -29728,13 +30622,18 @@ ${message}
29728
30622
  const engine = client ? onlineEngine(client) : offlineEngine();
29729
30623
  const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29730
30624
  const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
29731
- const run2 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra, ...notices ? { notices, noticesTarget: { file: relative5(workspace, path).split("\\").join("/") || configName, line: noticesLine } } : {} });
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 } } : {} });
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);
30628
+ const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? { ...run0, offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE } : run0;
29732
30629
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
29733
30630
  if (gha) {
29734
30631
  for (const a of annotations(run2)) emit(a);
29735
30632
  writeGithubFile(ctx.env.GITHUB_STEP_SUMMARY, stepSummary(run2));
30633
+ if (run2.offline_notice) emit(`::notice title=Checkbox%3A Stripe not checked offline::${run2.offline_notice}`);
29736
30634
  writeGithubFile(ctx.env.GITHUB_OUTPUT, `status=${run2.status}
29737
30635
  exit_code=${run2.exit_code}
30636
+ result=${run2.result}
29738
30637
  `);
29739
30638
  }
29740
30639
  return run2.exit_code;
@@ -29794,6 +30693,14 @@ function findAnchor(src, target) {
29794
30693
  }
29795
30694
  }
29796
30695
  }
30696
+ if (!text && !id && tag) {
30697
+ const all = [...src.matchAll(tagRe)];
30698
+ if (all.length === 1) return openAt(all[0].index + 1, `the only <${tag}> in this file`);
30699
+ if (tag === "button" || tag === "input") {
30700
+ const submit = all.filter((m) => /^[^>]*\btype\s*=\s*(?:"submit"|'submit'|\{\s*["']submit["']\s*\})/i.test(src.slice(m.index, src.indexOf(">", m.index) + 1)));
30701
+ if (submit.length === 1) return openAt(submit[0].index + 1, `the only <${tag} type="submit"> in this file`);
30702
+ }
30703
+ }
29797
30704
  return null;
29798
30705
  }
29799
30706
  function lineStart(src, idx) {
@@ -29916,12 +30823,20 @@ function diffLines(a, b) {
29916
30823
  ops.push(...a.slice(a.length - suf).map((line2) => ({ t: " ", line: line2 })));
29917
30824
  return ops;
29918
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
+ }
29919
30833
  function unifiedDiff(before, after, path, context = 3) {
29920
30834
  if (before === after) return "";
29921
- const a = before.split("\n");
29922
- const b = after.split("\n");
30835
+ const a = gitLines(before);
30836
+ const b = gitLines(after);
29923
30837
  const ops = diffLines(a, b);
29924
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}`];
29925
30840
  let i = 0;
29926
30841
  while (i < ops.length) {
29927
30842
  if (ops[i].t === " ") {
@@ -29943,26 +30858,50 @@ function unifiedDiff(before, after, path, context = 3) {
29943
30858
  const bStart = ops.slice(0, start).filter((o) => o.t !== "-").length + 1;
29944
30859
  const aLen = slice.filter((o) => o.t !== "+").length;
29945
30860
  const bLen = slice.filter((o) => o.t !== "-").length;
29946
- 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));
29947
30862
  i = stop;
29948
30863
  start = stop;
29949
30864
  }
29950
30865
  return `${out.join("\n")}
29951
30866
  `;
29952
30867
  }
29953
- var escapeRe2, VOID_TAGS;
30868
+ var escapeRe2, VOID_TAGS, NO_EOL, NO_EOL_MARKER;
29954
30869
  var init_patch = __esm({
29955
30870
  "src/commands/plan/patch.ts"() {
29956
30871
  "use strict";
29957
30872
  escapeRe2 = (s2) => s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29958
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 = "\";
29959
30876
  }
29960
30877
  });
29961
30878
 
29962
30879
  // src/commands/plan/fix.ts
29963
30880
  import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
29964
30881
  import { relative as relative6, resolve as resolve8 } from "node:path";
29965
- 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)) {
29966
30905
  const base = {
29967
30906
  object: "task_fix",
29968
30907
  task: task.id,
@@ -29973,6 +30912,22 @@ function fixForTask(task, cwd, file) {
29973
30912
  verify: task.verify
29974
30913
  };
29975
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
+ }
29976
30931
  const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
29977
30932
  if (task.change.type === "patch" && task.change.edits.length && candidate) {
29978
30933
  const path = resolve8(cwd, candidate);
@@ -30010,14 +30965,16 @@ function fixForTask(task, cwd, file) {
30010
30965
  const type = task.change.type === "manual" ? "answer_question" : task.change.type === "needs_facts" ? "provide_facts" : "apply_change";
30011
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 } };
30012
30967
  }
30013
- var TASK_RE2, fix_default;
30968
+ var TASK_RE2, major, fix_default;
30014
30969
  var init_fix = __esm({
30015
30970
  "src/commands/plan/fix.ts"() {
30016
30971
  "use strict";
30017
30972
  init_args();
30018
30973
  init_types2();
30974
+ init_config3();
30019
30975
  init_patch();
30020
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;
30021
30978
  fix_default = defineCommand({
30022
30979
  name: "fix",
30023
30980
  summary: "Show the change for a plan task (exact diff of your file for code tasks)",
@@ -30243,6 +31200,10 @@ function planSnippet(d, s2) {
30243
31200
  return { change: { file, action: existing === null ? "create" : "modify", summary: "Nuxt client plugin that adds the snippet to every page", contents, added: contents.trim().split("\n") } };
30244
31201
  }
30245
31202
  if (existing === null) {
31203
+ if (d.framework === "nextjs-app") {
31204
+ const contents = NEXT_APP_LAYOUT(s2, /\.tsx$/.test(file));
31205
+ return { change: { file, action: "create", summary: "Root layout with the snippet in <head>", contents, added: contents.trim().split("\n") } };
31206
+ }
30246
31207
  if (d.framework === "nextjs-pages") {
30247
31208
  const contents = NEXT_DOCUMENT(s2);
30248
31209
  return { change: { file, action: "create", summary: "Custom _document with the snippet in <Head>", contents, added: contents.trim().split("\n") } };
@@ -30371,7 +31332,7 @@ function applyChange(root, c) {
30371
31332
  mkdirSync3(dirname8(abs), { recursive: true });
30372
31333
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
30373
31334
  }
30374
- var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
31335
+ var MARK_START, MARK_END, NEXT_DOCUMENT, NEXT_APP_LAYOUT, NUXT_PLUGIN;
30375
31336
  var init_patch2 = __esm({
30376
31337
  "src/commands/init/patch.ts"() {
30377
31338
  "use strict";
@@ -30392,6 +31353,17 @@ ${jsxBlock(s2, " ").join("\n")}
30392
31353
  </Html>
30393
31354
  );
30394
31355
  }
31356
+ `;
31357
+ NEXT_APP_LAYOUT = (s2, ts) => `${ts ? "import type { ReactNode } from 'react';\n\n" : ""}export default function RootLayout({ children }${ts ? ": { children: ReactNode }" : ""}) {
31358
+ return (
31359
+ <html lang="en">
31360
+ <head>
31361
+ ${jsxBlock(s2, " ").join("\n")}
31362
+ </head>
31363
+ <body>{children}</body>
31364
+ </html>
31365
+ );
31366
+ }
30395
31367
  `;
30396
31368
  NUXT_PLUGIN = (s2) => `// ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet)
30397
31369
  export default defineNuxtPlugin(() => {
@@ -30412,8 +31384,11 @@ __export(init_exports, {
30412
31384
  configLines: () => configLines,
30413
31385
  configNameOf: () => configNameOf,
30414
31386
  default: () => init_default,
31387
+ liveKeyNextAction: () => liveKeyNextAction,
30415
31388
  parsePlanFlag: () => parsePlanFlag,
30416
- syncCheckoutConfigs: () => syncCheckoutConfigs
31389
+ snippetWords: () => snippetWords,
31390
+ syncCheckoutConfigs: () => syncCheckoutConfigs,
31391
+ withEvaluatedAt: () => withEvaluatedAt
30417
31392
  });
30418
31393
  import { existsSync as existsSync11 } from "node:fs";
30419
31394
  import { join as join11 } from "node:path";
@@ -30433,6 +31408,10 @@ function saveState(ctx, root, patch) {
30433
31408
  }
30434
31409
  ctx.config.save({ init: { ...cfg, projects } });
30435
31410
  }
31411
+ function withEvaluatedAt(c) {
31412
+ const t = c.evaluated;
31413
+ return typeof t === "number" && t > 0 ? { evaluated_at: new Date(t * 1e3).toISOString(), ...c } : c;
31414
+ }
30436
31415
  async function ask2(question) {
30437
31416
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
30438
31417
  try {
@@ -30460,6 +31439,7 @@ function projectFacts(d) {
30460
31439
  ...d.layoutFile ? { layout_file: d.layoutFile } : {},
30461
31440
  env_file: d.envFile,
30462
31441
  ...d.publicDir ? { public_dir: d.publicDir } : {},
31442
+ ...d.mailer ? { mailer: d.mailer } : {},
30463
31443
  checkout_code: d.checkoutCode.slice(0, 100).map((c) => ({ file: c.file.slice(0, 500), line: c.line, kind: c.kind, snippet: c.snippet.slice(0, 500) })),
30464
31444
  pay_buttons: d.payButtons.slice(0, 100).map((c) => ({ file: c.file.slice(0, 500), line: c.line, kind: c.kind, snippet: c.snippet.slice(0, 500) }))
30465
31445
  };
@@ -30547,6 +31527,20 @@ function registrable(params, unresolved) {
30547
31527
  if (unresolved.some((u) => /^(mode|line_items|subscription_data|custom_text|consent_collection)\b/.test(u))) return false;
30548
31528
  return params.mode === "subscription" && Array.isArray(params.line_items) && params.line_items.length > 0;
30549
31529
  }
31530
+ function snippetWords(homepage, live) {
31531
+ if (homepage) return "detected";
31532
+ if (live) return "live (seen through the page captures it sends; not in the homepage HTML Checkbox loaded)";
31533
+ return "NOT detected yet (deploy, then verify again)";
31534
+ }
31535
+ function liveKeyNextAction(dashboard) {
31536
+ const url = `${dashboard.replace(/\/+$/, "")}/settings/developers?mode=live&create=key`;
31537
+ return {
31538
+ type: "ask_human_for_live_key",
31539
+ message: `Ask the human for live mode (an agent never creates live keys): either they approve again with \`checkbox init --live --relogin --yes --json\` and tick \u201CAlso create live-mode keys\u201D on the approval page, or they create a live key in the Checkbox dashboard (Live mode, Agent & API: ${url}) and put it in the project env as CHECKBOX_LIVE_SECRET_KEY (never in the chat). Then run \`checkbox init --live --yes --json\`: it links the live site and writes the domain-verification file. Live checks start once the domain is verified.`,
31540
+ command: "checkbox init --live --relogin --yes --json",
31541
+ url
31542
+ };
31543
+ }
30550
31544
  function detectionSummary(d, siteUrl) {
30551
31545
  return {
30552
31546
  framework: d.framework,
@@ -30569,9 +31563,14 @@ function detectionSummary(d, siteUrl) {
30569
31563
  function fileOut(c) {
30570
31564
  return { file: c.file, action: c.action, summary: c.summary, added: c.added };
30571
31565
  }
30572
- function resultOf(status, d, session, changes, extra, apiBase) {
31566
+ function resultOf2(status, d, session, changes, extra, apiBase) {
30573
31567
  const names = envVarNames(d);
30574
- const next = session.next_action.type === "done" ? { type: "done", message: "Checkbox is set up. After each change to checkout, emails or cancellation, run `checkbox plan --json` and apply what it returns.", command: "checkbox plan --json" } : session.next_action;
31568
+ const next = session.next_action.type === "done" ? {
31569
+ type: "done",
31570
+ // Round 32 (pilot 31 item 1): a test-mode setup says so, with the go-live steps left (go_live), never a bare "set up".
31571
+ message: session.go_live?.steps.length ? `Checkbox is set up in Stripe test mode. ${session.go_live.summary} After each change to checkout, emails or cancellation, run \`checkbox plan --json\` and apply what it returns.` : "Checkbox is set up. After each change to checkout, emails or cancellation, run `checkbox plan --json` and apply what it returns.",
31572
+ command: "checkbox plan --json"
31573
+ } : session.next_action;
30575
31574
  return {
30576
31575
  object: "checkbox_init",
30577
31576
  status,
@@ -30586,7 +31585,10 @@ function resultOf(status, d, session, changes, extra, apiBase) {
30586
31585
  install: session.install,
30587
31586
  stripe: { ...session.stripe, ...extra.stripe ?? {} },
30588
31587
  ...extra.live && Object.keys(extra.live).length ? { live: extra.live } : {},
30589
- compliance: session.compliance,
31588
+ // Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp): the CLI says
31589
+ // when, as evaluated_at (ISO 8601), next to the API's field.
31590
+ compliance: session.compliance ? withEvaluatedAt(session.compliance) : session.compliance,
31591
+ ...session.go_live ? { go_live: session.go_live } : {},
30590
31592
  steps: session.steps.map((s2) => ({ key: s2.key, status: s2.status, summary: s2.summary })),
30591
31593
  warnings: extra.warnings ?? [],
30592
31594
  owner_notified: session.owner_notified,
@@ -30609,6 +31611,10 @@ function renderSession(ctx, s2) {
30609
31611
  for (const t of todo.slice(0, 5)) lines.push(` ${t.status === "needs_fix" || t.status === "not_set_up" ? c.red("\u2717") : c.yellow("?")} ${t.title} ${c.dim(`(${t.label})`)}`);
30610
31612
  if (todo.length > 5) lines.push(c.dim(` \u2026 ${todo.length - 5} more: checkbox plan`));
30611
31613
  }
31614
+ if (s2.go_live?.steps.length) {
31615
+ lines.push("", c.bold(`Before real customers (Stripe test mode so far): ${s2.go_live.steps.length} step${s2.go_live.steps.length === 1 ? "" : "s"}`));
31616
+ s2.go_live.steps.forEach((st, i) => lines.push(` ${i + 1}. ${c.dim(st.who === "person" ? "[owner]" : "[agent]")} ${st.text}`));
31617
+ }
30612
31618
  lines.push("", `${c.cyan("next")} ${s2.next_action.message}${s2.next_action.command ? `
30613
31619
  ${s2.next_action.command}` : ""}`, `${c.dim("review")} ${s2.review_url}`);
30614
31620
  return lines;
@@ -30634,6 +31640,7 @@ var init_init = __esm({
30634
31640
  "use strict";
30635
31641
  init_args();
30636
31642
  init_config();
31643
+ init_src2();
30637
31644
  init_http();
30638
31645
  init_types2();
30639
31646
  init_detect();
@@ -30679,7 +31686,7 @@ var init_init = __esm({
30679
31686
  " --no-stripe Do not offer the Stripe test key / test subscription",
30680
31687
  " --live Also request live-mode keys and write the domain-verification file",
30681
31688
  " --no-wait Start the login, print the link and exit (run `checkbox init` again to continue)",
30682
- " --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)",
30683
31690
  " --no-open Do not open the browser",
30684
31691
  " --verify Re-check the site now (after deploying) and print the updated checklist",
30685
31692
  " --complete Finish the setup and email the human the summary (sent automatically the first time every",
@@ -30733,7 +31740,7 @@ var init_init = __esm({
30733
31740
  if (bool(flags, "verify")) {
30734
31741
  const v = await client2.sdk.setupSessions.verify(state.setup_session);
30735
31742
  session2 = v.setup_session;
30736
- out.info(v.homepage.ok ? `Site loaded; snippet ${v.homepage.snippet_detected ? "detected" : "NOT detected yet (deploy, then verify again)"}` : `Could not load the site: ${v.homepage.error}`);
31743
+ out.info(v.homepage.ok ? `Site loaded; snippet ${snippetWords(v.homepage.snippet_detected, session2.install?.detected_on_site ?? null)}` : `Could not load the site: ${v.homepage.error}`);
30737
31744
  } else {
30738
31745
  session2 = await client2.sdk.setupSessions.retrieve(state.setup_session);
30739
31746
  }
@@ -30743,7 +31750,7 @@ var init_init = __esm({
30743
31750
  const configs = await syncCheckoutConfigs(client2, session2.site, d.checkoutSessions, root).catch((err) => [{ file: "", line: 0, skipped: err instanceof Error ? err.message : String(err) }]);
30744
31751
  if (configs.length) stripeOut.checkout_configs = configs;
30745
31752
  }
30746
- out.result(resultOf("verified", d, session2, [], Object.keys(stripeOut).length ? { stripe: stripeOut } : {}, apiBase), () => [...renderSession(ctx, session2), ...configLines(stripeOut.checkout_configs)]);
31753
+ out.result(resultOf2("verified", d, session2, [], Object.keys(stripeOut).length ? { stripe: stripeOut } : {}, apiBase), () => [...renderSession(ctx, session2), ...configLines(stripeOut.checkout_configs)]);
30747
31754
  return EXIT.ok;
30748
31755
  }
30749
31756
  if (dryRun && (!state.setup_session || !creds)) {
@@ -30772,10 +31779,11 @@ var init_init = __esm({
30772
31779
  let token = null;
30773
31780
  let apiKey = creds?.apiKey;
30774
31781
  let sessionId = state.setup_session;
30775
- const reuse = !!(state.setup_session && creds && bool(flags, "relogin") !== true);
31782
+ const pendingLogin = state.pending && Date.parse(state.pending.expires_at) > Date.now() + 1e4 && state.pending.api_base === apiBase ? state.pending : void 0;
31783
+ const reuse = !!(state.setup_session && creds && bool(flags, "relogin") !== true && !pendingLogin);
30776
31784
  if (!reuse) {
30777
31785
  const anon = ctx.client({ apiKey: "device-login", apiBase });
30778
- let pending = state.pending && Date.parse(state.pending.expires_at) > Date.now() + 1e4 && state.pending.api_base === apiBase ? state.pending : void 0;
31786
+ let pending = pendingLogin;
30779
31787
  if (!pending) {
30780
31788
  const agent2 = detectAgent(ctx.env, agentName);
30781
31789
  const wantStripe = bool(flags, "stripe") !== false && d.billingPlatform === "stripe" && !!d.stripeTestKey;
@@ -30788,7 +31796,8 @@ var init_init = __esm({
30788
31796
  ...merchantName ? { merchant_name: merchantName.slice(0, 100) } : {},
30789
31797
  framework: d.framework,
30790
31798
  ...d.packageManager ? { package_manager: d.packageManager } : {},
30791
- billing_platform: d.billingPlatform,
31799
+ // "custom" means nothing was detected: never sent as a declared platform (the API then looks for Stripe itself).
31800
+ ...d.billingPlatform !== "custom" ? { billing_platform: d.billingPlatform } : {},
30792
31801
  ...bool(flags, "live") ? { live: true } : {},
30793
31802
  ...wantStripe ? { stripe_test_key_hint: d.stripeTestKey.hint } : {}
30794
31803
  });
@@ -30806,7 +31815,7 @@ var init_init = __esm({
30806
31815
  if (bool(flags, "open") !== false) openBrowser(pending.verification_uri_complete, ctx.env);
30807
31816
  const tty = !!process.stdin.isTTY && !!process.stderr.isTTY;
30808
31817
  const waitArg = str(flags, "wait-seconds");
30809
- 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;
30810
31819
  if (!Number.isFinite(waitSeconds) || waitSeconds < 0) throw new UsageError("--wait-seconds must be a number of seconds");
30811
31820
  const polled = await pollDeviceLogin(anon, pending, waitSeconds * 1e3, ctx);
30812
31821
  if (polled.status === "timeout") {
@@ -30819,7 +31828,7 @@ var init_init = __esm({
30819
31828
  expires_at: pending.expires_at,
30820
31829
  next_action: {
30821
31830
  type: "ask_human_to_approve",
30822
- message: `Ask the human to open ${pending.verification_uri_complete} and click Approve, then run \`checkbox init --yes --json\` again (it continues where it stopped and remembers ${Object.keys(factsToRemember).length ? Object.keys(factsToRemember).join(", ") : "the flags given now"}).`,
31831
+ message: `Ask the human to open ${pending.verification_uri_complete} and click Approve.${d.billingPlatform === "stripe" && bool(flags, "stripe") !== false ? ` ${approvalKeyAsk({ stripe: true, keyFound: !!d.stripeTestKey })}` : ""} Then run \`checkbox init --yes --json\` again (it continues where it stopped and remembers ${Object.keys(factsToRemember).length ? Object.keys(factsToRemember).join(", ") : "the flags given now"}).`,
30823
31832
  command: "checkbox init --yes --json",
30824
31833
  url: pending.verification_uri_complete
30825
31834
  }
@@ -30962,9 +31971,12 @@ var init_init = __esm({
30962
31971
  }
30963
31972
  const live = {};
30964
31973
  if (!dryRun && bool(flags, "live") && siteUrl) {
30965
- const liveKey = token?.live_secret_key ?? void 0;
30966
- if (!liveKey) {
30967
- warnings.push("No live key (approve live keys on the approval page, or create one in the dashboard) \u2014 live mode skipped.");
31974
+ const liveKey = token?.live_secret_key ?? ctx.env.CHECKBOX_LIVE_SECRET_KEY ?? d.env.CHECKBOX_LIVE_SECRET_KEY ?? void 0;
31975
+ if (!liveKey || !/^ck_live_/.test(liveKey)) {
31976
+ const ask3 = liveKeyNextAction(session.review_url.replace(/\/setup\/[^/]*$/, ""));
31977
+ live.skipped = "no_live_key";
31978
+ live.next_action = ask3;
31979
+ warnings.push(`Live mode not set up yet: it needs a live Checkbox key (ck_live_\u2026), and only a person creates one. ${ask3.message}`);
30968
31980
  } else {
30969
31981
  try {
30970
31982
  const lc = ctx.client({ apiKey: liveKey, apiBase });
@@ -30978,7 +31990,7 @@ var init_init = __esm({
30978
31990
  changes.push(wk);
30979
31991
  }
30980
31992
  }
30981
- session = await client.sdk.setupSessions.update(sessionId, { live_site: liveSite.id });
31993
+ session = await lc.sdk.setupSessions.update(sessionId, { live_site: liveSite.id });
30982
31994
  saveState(ctx, root, { live_site: liveSite.id });
30983
31995
  live.site = liveSite.id;
30984
31996
  live.domain_verification = { status: dv.status, file: dv.file?.url ?? null, dns_txt: dv.dns_record ? `${dv.dns_record.name} = ${dv.dns_record.value}` : null };
@@ -30991,7 +32003,8 @@ var init_init = __esm({
30991
32003
  out.info("Running the first checks\u2026");
30992
32004
  session = (await client.sdk.setupSessions.verify(sessionId)).setup_session;
30993
32005
  }
30994
- const doc = resultOf(dryRun ? "dry_run" : "configured", d, session, changes, { stripe, live, warnings, manualSnippet, token, envNotWritten: envTracked && !!token }, apiBase);
32006
+ const doc = resultOf2(dryRun ? "dry_run" : "configured", d, session, changes, { stripe, live, warnings, manualSnippet, token, envNotWritten: envTracked && !!token }, apiBase);
32007
+ if (live.next_action && (session.next_action.type === "done" || session.next_action.type === "complete_setup")) doc.next_action = live.next_action;
30995
32008
  out.result(doc, () => [...renderFiles(ctx, changes, dryRun), ...manualSnippet ? [`Add the snippet yourself: ${manualSnippet}`] : [], ...warnings.map((w) => `${out.c.yellow("!")} ${w}`), ...configLines(stripe.checkout_configs), ...renderSession(ctx, session)]);
30996
32009
  return EXIT.ok;
30997
32010
  }
@@ -31175,6 +32188,7 @@ var init_plan = __esm({
31175
32188
  init_args();
31176
32189
  init_types2();
31177
32190
  init_lib();
32191
+ init_fix();
31178
32192
  plan_default = defineCommand({
31179
32193
  name: "plan",
31180
32194
  summary: "Show the compliance plan: ordered tasks to apply, then verify",
@@ -31195,7 +32209,15 @@ var init_plan = __esm({
31195
32209
  const client = ctx.client();
31196
32210
  const site = await resolveSite(ctx, str(flags, "site"), client);
31197
32211
  const plan2 = await client.sdk.plans.retrieve(site);
31198
- 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
+ });
31199
32221
  return bool(flags, "exit-code") && plan2.status !== "compliant" ? EXIT.fail : EXIT.ok;
31200
32222
  }
31201
32223
  });