@checkbox-site/cli 0.4.6 → 0.4.7

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 +798 -100
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -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.5";
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,63 @@ 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 approvalKeyAsk(opts = {}) {
4761
+ if (opts.stripe === false) return "";
4762
+ const create = `create a restricted key in Stripe Test mode (${STRIPE_TEST_API_KEYS_URL} \u2192 Create restricted key) ticking ${stripeKeyPermissionLine()}, and put it in the project's env file (never in the chat)`;
4763
+ return `${opts.stripe ? "In the same message" : "If the site sells through Stripe, in the same message"}, ask them to tick \u201CLet your agent store a Stripe test-mode key\u201D on that page${opts.keyFound ? "" : ` and, if the project has no rk_test_ key yet, to ${create}`}: one approval then covers the key and the test subscription.`;
4764
+ }
4765
+ var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL;
4739
4766
  var init_stripe_key = __esm({
4740
4767
  "../core/src/stripe-key.ts"() {
4741
4768
  "use strict";
4769
+ STRIPE_KEY_PERMISSIONS = [
4770
+ { 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)." },
4771
+ { resource: "Products", access: "Read", why: "Plan names next to your prices.", uses: ["checks", "test_runs"] },
4772
+ { resource: "Prices", access: "Read", why: "Amounts, billing intervals and trial lengths, compared with what your pages say.", uses: ["checks", "test_runs"] },
4773
+ { resource: "Coupons", access: "Read", why: "Only when your hosted cancellation page offers a coupon as the save offer.", uses: ["fixes"], optional: true },
4774
+ { 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." },
4775
+ { 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." },
4776
+ { 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)." },
4777
+ { 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)." },
4778
+ { 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." },
4779
+ { resource: "Webhook Endpoints", access: "Write", why: "Registers Checkbox\u2019s webhook endpoint, so renewals and cancellations are seen as they happen.", uses: ["checks"], writeWhen: "Used once, when you connect the key, to add Checkbox\u2019s own webhook endpoint (and to remove it when you disconnect); nothing else." }
4780
+ ];
4781
+ STRIPE_TEST_API_KEYS_URL = "https://dashboard.stripe.com/test/apikeys";
4742
4782
  }
4743
4783
  });
4744
4784
 
4745
4785
  // ../core/src/go-live.ts
4786
+ function goLiveSteps(input = {}) {
4787
+ const out = [];
4788
+ if (!input.domainVerified) out.push({ key: "verify_domain", who: "person", text: "Verify your domain (a DNS record or a file on your site). Live checks start only on a verified public domain." });
4789
+ if (input.termsOfServiceRequired && !input.termsOfServiceUrlSet) out.push({ key: "stripe_terms_url", who: "person", text: GO_LIVE_TERMS_URL_STEP, url: STRIPE_PUBLIC_DETAILS });
4790
+ if (!input.liveKeyConnected)
4791
+ out.push({
4792
+ key: "live_stripe_key",
4793
+ who: "person",
4794
+ text: "Switch the dashboard to Live mode and connect your live Stripe key (rk_live_\u2026) with the same permissions as your test key (the checklist next to the key field: Read for the checks; Write only for fixes you approve and to add Checkbox\u2019s webhook endpoint). Only a person adds live keys; an agent never does."
4795
+ });
4796
+ if (input.checkoutKind === "payment_link")
4797
+ out.push({ key: "live_checkout", who: "person", text: "Create the live Payment Link in Stripe live mode with the same settings as the test link Checkbox checked (renewal terms, consent checkbox, cancellation link); then your agent puts the live link on your site in place of the test link." });
4798
+ else if (input.checkoutKind === "stripe_checkout")
4799
+ out.push({ key: "live_checkout", who: "agent", text: "Ship the same Checkout Session params Checkbox checked with your live Price ids (the live Stripe secret key in your production settings is set by a person)." });
4800
+ out.push({
4801
+ key: "promote_site",
4802
+ who: "person",
4803
+ 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."}`
4804
+ });
4805
+ return out;
4806
+ }
4807
+ var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_STEPS;
4746
4808
  var init_go_live = __esm({
4747
4809
  "../core/src/go-live.ts"() {
4748
4810
  "use strict";
4811
+ STRIPE_PUBLIC_DETAILS = "https://dashboard.stripe.com/settings/public";
4812
+ 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.';
4813
+ GO_LIVE_STEPS = goLiveSteps().map((s2) => s2.text);
4749
4814
  }
4750
4815
  });
4751
4816
 
@@ -4988,7 +5053,7 @@ var init_california = __esm({
4988
5053
  notes: notes(
4989
5054
  AB2863_APPLICABILITY,
4990
5055
  "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).',
5056
+ '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
5057
  '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
5058
  )
4994
5059
  }),
@@ -12106,6 +12171,13 @@ function checkoutSessionsOf(config) {
12106
12171
  if (!Array.isArray(raw)) return [];
12107
12172
  return raw.filter((s2) => !!s2 && typeof s2 === "object" && typeof s2.id === "string" && Array.isArray(s2.lineItems));
12108
12173
  }
12174
+ function withTestRunSessions(sessions, runSessionIds) {
12175
+ if (!runSessionIds.size) return [...sessions];
12176
+ return sessions.map((s2) => !s2.checkboxTestRun && runSessionIds.has(s2.id) ? { ...s2, checkboxTestRun: true } : s2);
12177
+ }
12178
+ function testRunSessionIds(runs) {
12179
+ return new Set(runs.map((r) => r.state?.sessionId).filter((x) => typeof x === "string" && x.startsWith("cs_")));
12180
+ }
12109
12181
  function newestFirst(sessions) {
12110
12182
  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
12183
  }
@@ -16376,6 +16448,73 @@ var init_intro_terms = __esm({
16376
16448
  }
16377
16449
  });
16378
16450
 
16451
+ // ../rules/src/checks/charge-claims.ts
16452
+ function sentenceSpan(text, index) {
16453
+ const dot = text.lastIndexOf(". ", index);
16454
+ const start = Math.max(dot >= 0 ? dot + 2 : 0, text.lastIndexOf("\n", index) + 1);
16455
+ const ends = [text.indexOf(". ", index), text.indexOf("\n", index)].filter((n) => n >= 0);
16456
+ return { start: Math.min(start, index), end: ends.length ? Math.min(...ends) + 1 : text.length };
16457
+ }
16458
+ function misstatedRecurringCharge(text, prices, plans, others = []) {
16459
+ const usable = plans.filter((p) => p.amount > 0 && p.interval);
16460
+ if (!usable.length) return void 0;
16461
+ const known = [...usable.map((p) => p.amount), ...others].filter((n) => n > 0);
16462
+ const same = (a, b) => Math.abs(a - b) < 5e-3;
16463
+ const statesPlanPrice = prices.some((r) => r.money.amount !== null && known.some((k) => same(k, r.money.amount)));
16464
+ if (!statesPlanPrice) return void 0;
16465
+ return prices.find((r) => {
16466
+ const amount = r.money.amount;
16467
+ const unit2 = r.frequency.unit;
16468
+ if (amount === null || !(amount > 0) || !unit2) return false;
16469
+ if (known.some((k) => same(k, amount))) return false;
16470
+ const cur = (r.money.currency || "").toLowerCase();
16471
+ const plansHere = usable.filter((p) => !cur || !p.currency || p.currency.toLowerCase() === cur);
16472
+ if (!plansHere.some((p) => frequencyMatchesPlan(unit2, r.frequency.count, p))) return false;
16473
+ if (plansHere.some((p) => {
16474
+ const k = amount / p.amount;
16475
+ return k >= 2 && Math.abs(k - Math.round(k)) < 1e-3;
16476
+ })) return false;
16477
+ if (isPerPeriodEquivalent(r, plansHere)) return false;
16478
+ const at = r.money.index;
16479
+ if (!(at >= 0) || text.slice(at, at + r.money.text.length) !== r.money.text) return false;
16480
+ {
16481
+ const { start, end } = sentenceSpan(text, at);
16482
+ const sentence = text.slice(start, end);
16483
+ const around = text.slice(Math.max(start, at - 24), Math.min(end, at + r.money.text.length + 40));
16484
+ if (NOT_THE_CHARGE_AROUND_RE.test(around) || NOT_A_PRICE_AROUND_RE.test(around)) return false;
16485
+ if (INTRO_CUE_RE.test(sentence) || INTRO_SENTENCE_RE.test(sentence)) return false;
16486
+ const before = text.slice(Math.max(start, at - 32), at);
16487
+ const adjacent = prices.some((o) => {
16488
+ if (o === r || o.money.amount === null || o.money.index < 0 || !known.some((k) => same(k, o.money.amount))) return false;
16489
+ const [a, b] = o.money.index < at ? [o.money, r.money] : [r.money, o.money];
16490
+ return /^[\s()[\]/,:–—-]*$/.test(text.slice(a.index + a.text.length, b.index));
16491
+ });
16492
+ if (!adjacent && !CHARGE_CUE_BEFORE_RE.test(before)) return false;
16493
+ }
16494
+ return true;
16495
+ });
16496
+ }
16497
+ function isPerPeriodEquivalent(r, plans) {
16498
+ const amount = r.money.amount;
16499
+ const unit2 = r.frequency.unit;
16500
+ if (amount === null || !(amount > 0) || !unit2) return false;
16501
+ const cur = (r.money.currency || "").toLowerCase();
16502
+ return plans.some(
16503
+ (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)
16504
+ );
16505
+ }
16506
+ var NOT_THE_CHARGE_AROUND_RE, INTRO_SENTENCE_RE, CHARGE_CUE_BEFORE_RE;
16507
+ var init_charge_claims = __esm({
16508
+ "../rules/src/checks/charge-claims.ts"() {
16509
+ "use strict";
16510
+ init_plan_terms();
16511
+ init_intro_terms();
16512
+ 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;
16513
+ 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;
16514
+ 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;
16515
+ }
16516
+ });
16517
+
16379
16518
  // ../rules/src/checks/notice-content.ts
16380
16519
  function offlineOnlyCancelIn(text, cancelMethods) {
16381
16520
  const offline = contactOnlySentence(text);
@@ -16414,11 +16553,21 @@ function noticeContent(e, opts = {}) {
16414
16553
  ];
16415
16554
  const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
16416
16555
  const stated = positiveAmounts(a).filter((m) => m.amount !== null);
16556
+ const toMajor = (p, minor) => minor / 10 ** currencyDigitsOf(p.currency);
16557
+ const chargePlans = plans.map((p) => ({ amount: toMajor(p, p.amount), currency: p.currency, interval: p.interval, intervalCount: p.intervalCount }));
16558
+ const misstated = plans.length ? misstatedRecurringCharge(
16559
+ a.text,
16560
+ a.recurringPrices,
16561
+ chargePlans,
16562
+ [...expected, ...plans.flatMap((p) => typeof p.introAmount === "number" && p.introAmount > 0 ? [toMajor(p, p.introAmount)] : [])]
16563
+ ) : void 0;
16417
16564
  if (amount && expected.length && stated.length && !stated.some((m) => expected.some((x) => Math.abs(x - m.amount) < 5e-3))) wrong.amount = quote(amount);
16565
+ else if (misstated) wrong.amount = quote(misstated.money);
16418
16566
  else if (amount) quotes.amount = quote(amount);
16419
16567
  const freq = a.recurringPrices[0]?.frequency ?? a.frequencies[0];
16420
16568
  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);
16569
+ const planPriceStated = stated.some((m) => chargePlans.some((p) => Math.abs(p.amount - m.amount) < 5e-3));
16570
+ const charged = a.recurringPrices.filter((r) => !!r.frequency?.unit && !(planPriceStated && isPerPeriodEquivalent(r, chargePlans))).map((r) => r.frequency);
16422
16571
  const misses = (f) => !plans.some((p) => frequencyMatchesPlan(f.unit, f.count, p));
16423
16572
  const wrongCharged = plans.length ? charged.find(misses) : void 0;
16424
16573
  if (wrongCharged) wrong.frequency = quote(wrongCharged);
@@ -16485,6 +16634,7 @@ var init_notice_content = __esm({
16485
16634
  init_context();
16486
16635
  init_intro_terms();
16487
16636
  init_result();
16637
+ init_charge_claims();
16488
16638
  ELEMENT_LABELS = {
16489
16639
  amount: "the amount charged",
16490
16640
  frequency: "how often the subscription is charged",
@@ -19223,6 +19373,7 @@ var init_signup = __esm({
19223
19373
  init_offer_view();
19224
19374
  init_plan_terms();
19225
19375
  init_cancellation();
19376
+ init_charge_claims();
19226
19377
  init_intro_terms();
19227
19378
  init_intro_terms();
19228
19379
  init_result();
@@ -19388,6 +19539,29 @@ var init_signup = __esm({
19388
19539
  const pl = view.plan?.plan;
19389
19540
  return !!pl && hasIntro(pl) && r.money.amount !== null && Math.abs(r.money.amount - pl.introAmount / 10 ** currencyDigits(pl.currency || "usd")) < 5e-3;
19390
19541
  };
19542
+ if (!tied && plans.length) {
19543
+ const sitePrices = withAccountPrices(referencePlans(input.ctx), input.ctx).map((p) => p.amount / 10 ** currencyDigits((p.currency || "usd").toLowerCase()));
19544
+ const intros = [...plans.map((p) => p.plan), ...view.plan ? [view.plan.plan] : []].flatMap(
19545
+ (pl) => hasIntro(pl) ? [pl.introAmount / 10 ** currencyDigits((pl.currency || "usd").toLowerCase())] : []
19546
+ );
19547
+ const misstated = misstatedRecurringCharge(
19548
+ near2.text,
19549
+ parsed,
19550
+ plans.map((p) => ({ amount: p.amount, currency: p.currency, interval: p.plan.interval, intervalCount: p.plan.intervalCount })),
19551
+ [...sitePrices, ...intros]
19552
+ );
19553
+ if (misstated && !NON_CHARGE_AMOUNT_RE.test(quote(misstated) ?? "") && !INTRO_PRICE_RE.test(quote(misstated) ?? "")) {
19554
+ const said = misstated.money.text;
19555
+ const own = quote(parsed.find(recurringMatches) ?? rp) ?? "";
19556
+ const firm = decisive && input.ctx.facts?.partialPlans !== true && !otherPlanCard(view, input);
19557
+ return unit(
19558
+ firm ? "fail" : "needs_review",
19559
+ `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`,
19560
+ firm ? 0.85 : 0.6,
19561
+ { quote: said, meta: { ...meta(misstated.money.amount, misstated.money.currency), priceMismatch: true, secondCharge: true } }
19562
+ );
19563
+ }
19564
+ }
19391
19565
  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
19566
  if (foreign) {
19393
19567
  const own = parsed.find(recurringMatches) ?? rp;
@@ -23865,6 +24039,10 @@ function fixTextsFor(t, style2) {
23865
24039
  if (style2 === "full") return fixTexts(t);
23866
24040
  return { disclosure: compactDisclosureText(t), consentLabel: COMPACT_CONSENT_LABEL, cta: compactCtaLabel(t) };
23867
24041
  }
24042
+ function stripeCompactConsent(t) {
24043
+ const trial = t.trialDays && t.trialDays > 0 ? ` after the ${t.trialDays}-day free trial` : "";
24044
+ return `I agree that my subscription renews automatically at ${formatMoney(t.amount, t.currency)} every ${intervalPhrase(t.interval, t.intervalCount)}${trial} until I cancel.`;
24045
+ }
23868
24046
  var COMPACT_CONSENT_LABEL;
23869
24047
  var init_compact = __esm({
23870
24048
  "../rules/src/fixes/compact.ts"() {
@@ -23881,9 +24059,10 @@ function shortDisclosureText(t) {
23881
24059
  const trial = !!t.trialDays && t.trialDays > 0;
23882
24060
  const intro = introPhrase(t);
23883
24061
  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}.`;
24062
+ const tax = " (plus applicable taxes)";
24063
+ 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.`;
24064
+ if (intro) return `${intro[0].toUpperCase()}${intro.slice(1)}${tax}. Renews automatically until you cancel. ${cancel}.`;
24065
+ return `Renews automatically every ${every} at ${price3}${tax} until you cancel. ${cancel}.`;
23887
24066
  }
23888
24067
  var init_short = __esm({
23889
24068
  "../rules/src/fixes/short.ts"() {
@@ -24595,6 +24774,45 @@ var init_stripe = __esm({
24595
24774
  }
24596
24775
  });
24597
24776
 
24777
+ // ../rules/src/preflight/price-hint.ts
24778
+ function intervalHintOf(ref) {
24779
+ if (typeof ref !== "string" || !ref.trim() || ref.length > 200) return void 0;
24780
+ const s2 = ref.trim();
24781
+ if (GENERATED.test(s2)) return void 0;
24782
+ const tokens = s2.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
24783
+ const hits = /* @__PURE__ */ new Set();
24784
+ for (const t of tokens) for (const [k, re] of WORDS2) if (re.test(t)) hits.add(k);
24785
+ return hits.size === 1 ? [...hits][0] : void 0;
24786
+ }
24787
+ function priceIntervalConflict(ids, plan2) {
24788
+ const interval = plan2?.interval;
24789
+ if (!interval || !ids.length) return void 0;
24790
+ const count = Number(plan2?.interval_count ?? 1) || 1;
24791
+ for (const id of ids) {
24792
+ const hint = intervalHintOf(id);
24793
+ if (!hint) continue;
24794
+ const planUnit = interval === "month" && count === 12 ? "year" : interval;
24795
+ if (hint !== planUnit) {
24796
+ const name = plan2?.plan_name ?? plan2?.name;
24797
+ 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}`;
24798
+ }
24799
+ }
24800
+ return void 0;
24801
+ }
24802
+ var WORDS2, GENERATED;
24803
+ var init_price_hint = __esm({
24804
+ "../rules/src/preflight/price-hint.ts"() {
24805
+ "use strict";
24806
+ WORDS2 = [
24807
+ ["year", /^(?:annual|annually|yearly|year|years|yr|yrly|12mo|12m|1y|1yr)$/],
24808
+ ["month", /^(?:monthly|month|months|mo|mth|1m|1mo)$/],
24809
+ ["week", /^(?:weekly|week|weeks|wk)$/],
24810
+ ["day", /^(?:daily|day|days)$/]
24811
+ ];
24812
+ GENERATED = /^(?:price|plan|prod)_1[A-Za-z0-9]{12,}$/;
24813
+ }
24814
+ });
24815
+
24598
24816
  // ../rules/src/preflight/stripe.ts
24599
24817
  function clone(v) {
24600
24818
  return JSON.parse(JSON.stringify(v));
@@ -24987,10 +25205,6 @@ function correct(params, shape, checks, forLink, style2 = "full") {
24987
25205
  }
24988
25206
  return { patch, corrected: out, texts, needText, needConsent };
24989
25207
  }
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
25208
  async function bestCorrection(params, shape, checks, forLink, judgeAfter) {
24995
25209
  const none = () => ({ fix: correct(params, shape, [], forLink), after: checks });
24996
25210
  const first2 = correct(params, shape, checks, forLink, "compact");
@@ -25071,6 +25285,12 @@ function withResolvedPrices(input, knownPrices, amountSource = "merchant.price")
25071
25285
  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
25286
  };
25073
25287
  }
25288
+ function pricePairingConflict(input, resolved) {
25289
+ if (!resolved.warnings.some((w) => w.code === "price_amount_unverified")) return void 0;
25290
+ const conflict = priceIntervalConflict(lineItemPriceIds(input.params), input.merchant);
25291
+ if (!conflict) return void 0;
25292
+ 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" };
25293
+ }
25074
25294
  async function checkStripeCheckoutSession(input0, opts = {}) {
25075
25295
  const { jurisdictions, asOf, now } = common(input0, opts.now);
25076
25296
  const resolvedPrices = withResolvedPrices(input0, opts.knownPrices, opts.amountSource);
@@ -25086,7 +25306,8 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
25086
25306
  const shape = readParams(params, input.merchant, "session", asOf);
25087
25307
  const url = "https://checkout.stripe.com/c/pay/cs_test_preflight";
25088
25308
  const checks = await judge2(shape, url, jurisdictions, asOf, now, "preflight-stripe-before");
25089
- const { fix, after } = await bestCorrection(
25309
+ const pairing = pricePairingConflict(input0, resolvedPrices);
25310
+ const { fix, after } = pairing ? { fix: correct(params, shape, [], false), after: checks } : await bestCorrection(
25090
25311
  params,
25091
25312
  shape,
25092
25313
  checks,
@@ -25101,12 +25322,12 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
25101
25322
  verification: { checks: d.checks, passes: d.passes, remaining_checks: d.remaining, note: HOSTED_STYLING_NOTE },
25102
25323
  placeholders
25103
25324
  };
25104
- const warnings = [...resolvedPrices.warnings, ...shape.warnings];
25325
+ const warnings = [...resolvedPrices.warnings, ...pairing ? [pairing] : [], ...shape.warnings];
25105
25326
  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
25327
  const channel2 = noticeChannelField(params);
25107
25328
  if (channel2) warnings.push(channel2);
25108
25329
  return {
25109
- ...baseReport("stripe_checkout_session", checks, jurisdictions, asOf, warnings, nextAction("session", checks, correction), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25330
+ ...baseReport("stripe_checkout_session", checks, jurisdictions, asOf, warnings, pairing ? PAIRING_ACTION(pairing) : nextAction("session", checks, correction), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25110
25331
  kind: "stripe_checkout_session",
25111
25332
  correction
25112
25333
  };
@@ -25124,7 +25345,8 @@ async function checkStripePaymentLink(input0, opts = {}) {
25124
25345
  const id = typeof params.id === "string" ? params.id : void 0;
25125
25346
  const url = typeof params.url === "string" && /^https:\/\/buy\.stripe\.com\//.test(params.url) ? params.url : "https://buy.stripe.com/test_preflight";
25126
25347
  const checks = await judge2(shape, url, jurisdictions, asOf, now, "preflight-link-before");
25127
- const { fix, after } = await bestCorrection(
25348
+ const pairing = pricePairingConflict(input0, resolvedPrices);
25349
+ const { fix, after } = pairing ? { fix: correct(params, shape, [], true), after: checks } : await bestCorrection(
25128
25350
  params,
25129
25351
  shape,
25130
25352
  checks,
@@ -25163,11 +25385,11 @@ async function checkStripePaymentLink(input0, opts = {}) {
25163
25385
  ...update_params ? { update_params } : {},
25164
25386
  needs_new_link_for_consent: needsNew
25165
25387
  };
25166
- const warnings = [...resolvedPrices.warnings, ...shape.warnings];
25388
+ const warnings = [...resolvedPrices.warnings, ...pairing ? [pairing] : [], ...shape.warnings];
25167
25389
  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
25390
  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
25391
  return {
25170
- ...baseReport("stripe_payment_link", checks, jurisdictions, asOf, warnings, nextAction("link", checks, correction, extra), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25392
+ ...baseReport("stripe_payment_link", checks, jurisdictions, asOf, warnings, pairing ? PAIRING_ACTION(pairing) : nextAction("link", checks, correction, extra), void 0, { cancelUrl: shape.plan?.cancelUrl }),
25171
25393
  kind: "stripe_payment_link",
25172
25394
  correction
25173
25395
  };
@@ -25246,7 +25468,7 @@ function noticeChannelField(params) {
25246
25468
  param: `params.custom_fields[${i}]`
25247
25469
  };
25248
25470
  }
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;
25471
+ 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
25472
  var init_stripe2 = __esm({
25251
25473
  "../rules/src/preflight/stripe.ts"() {
25252
25474
  "use strict";
@@ -25259,6 +25481,7 @@ var init_stripe2 = __esm({
25259
25481
  init_common();
25260
25482
  init_disclosure();
25261
25483
  init_model();
25484
+ init_price_hint();
25262
25485
  TEXT_CHECKS = [
25263
25486
  "signup.disclosure.autorenew_language",
25264
25487
  "signup.disclosure.price",
@@ -25277,6 +25500,7 @@ var init_stripe2 = __esm({
25277
25500
  STRIPE_GREY = { fontSizePx: 13, fontWeight: 400, color: "rgba(26, 26, 26, 0.7)", backgroundColor: "rgb(255, 255, 255)" };
25278
25501
  DAY_MS3 = 864e5;
25279
25502
  open = (c) => !!c && (c.status === "fail" || c.status === "needs_review");
25503
+ PAIRING_ACTION = (w) => ({ type: "human_review", message: `${w.message}` });
25280
25504
  STRIPE_HOSTED_STYLING_NOTE = HOSTED_STYLING_NOTE;
25281
25505
  }
25282
25506
  });
@@ -25286,6 +25510,8 @@ function stripeParamsVerdict(r) {
25286
25510
  if (r.verdict === "not_applicable") return { verdict: "not_applicable", open: [] };
25287
25511
  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
25512
  if (r.verdict === "fail" || r.compliance_status === "needs_fix" || open2.some((c) => c.status === "fail")) return { verdict: "fail", open: open2 };
25513
+ const pairing = (r.warnings ?? []).find((w) => w.code === "price_plan_conflict");
25514
+ if (pairing) return { verdict: "needs_review", open: open2, reason: pairing.message };
25289
25515
  const coupon = (r.warnings ?? []).find((w) => w.code === "coupon_unverified");
25290
25516
  if (open2.length) return { verdict: "needs_review", open: open2, ...coupon ? { reason: coupon.message } : {} };
25291
25517
  if (coupon) return { verdict: "needs_review", open: [], reason: coupon.message };
@@ -25686,7 +25912,8 @@ ${html ?? ""}`, kind);
25686
25912
  text: t.text,
25687
25913
  html: htmlOf(t.text),
25688
25914
  merge_tags: t.tags,
25689
- placeholders: [...plan2.cancelUrl ? [] : [CANCEL_URL_PLACEHOLDER], ...t.text.includes(SUPPORT_CONTACT_PLACEHOLDER) ? [SUPPORT_CONTACT_PLACEHOLDER] : []],
25915
+ // Round 32: only the placeholders the template really contains (a cancellation confirmation has no {{cancel_url}}).
25916
+ placeholders: [CANCEL_URL_PLACEHOLDER, SUPPORT_CONTACT_PLACEHOLDER].filter((p) => t.text.includes(p) || t.subject.includes(p)),
25690
25917
  verified: remaining.length === 0,
25691
25918
  remaining_checks: remaining
25692
25919
  };
@@ -26114,6 +26341,7 @@ __export(preflight_exports, {
26114
26341
  checkoutSessionsOf: () => checkoutSessionsOf,
26115
26342
  emailTemplate: () => emailTemplate,
26116
26343
  extractStaticPage: () => extractStaticPage,
26344
+ intervalHintOf: () => intervalHintOf,
26117
26345
  lineItemPriceIds: () => lineItemPriceIds,
26118
26346
  modelCheckoutConfig: () => modelCheckoutConfig,
26119
26347
  modelContext: () => modelContext,
@@ -26121,6 +26349,7 @@ __export(preflight_exports, {
26121
26349
  parseHtml: () => parseHtml,
26122
26350
  parseJurisdictionList: () => parseJurisdictionList,
26123
26351
  paymentLinksOf: () => paymentLinksOf,
26352
+ priceIntervalConflict: () => priceIntervalConflict,
26124
26353
  resolvePlan: () => resolvePlan,
26125
26354
  resolvePriceIds: () => resolvePriceIds,
26126
26355
  runChecks: () => runChecks,
@@ -26128,7 +26357,9 @@ __export(preflight_exports, {
26128
26357
  signupStripeObjects: () => signupStripeObjects,
26129
26358
  stripeParamsVerdict: () => stripeParamsVerdict,
26130
26359
  supportContactLine: () => supportContactLine,
26131
- withIntroOffers: () => withIntroOffers
26360
+ testRunSessionIds: () => testRunSessionIds,
26361
+ withIntroOffers: () => withIntroOffers,
26362
+ withTestRunSessions: () => withTestRunSessions
26132
26363
  });
26133
26364
  var init_preflight = __esm({
26134
26365
  "../rules/src/preflight/index.ts"() {
@@ -26136,6 +26367,7 @@ var init_preflight = __esm({
26136
26367
  init_common();
26137
26368
  init_disclosure();
26138
26369
  init_stripe2();
26370
+ init_price_hint();
26139
26371
  init_stripe_verdict();
26140
26372
  init_email();
26141
26373
  init_html_page();
@@ -26321,7 +26553,8 @@ var init_check = __esm({
26321
26553
  }
26322
26554
  const hf = str(f, "html-file");
26323
26555
  if (hf) html = await readInput(ctx, hf);
26324
- if (!subject) throw new UsageError('--subject is required (or a "Subject: \u2026" first line in --file)');
26556
+ if (!subject && html) subject = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.replace(/\s+/g, " ").trim() || void 0;
26557
+ if (!subject) throw new UsageError('--subject is required (or a "Subject: \u2026" first line in --file, or an HTML <title>)');
26325
26558
  if (!text && !html) throw new UsageError("Give the email body with --text, --file or --html-file");
26326
26559
  const terms2 = planFrom(f);
26327
26560
  const pp = str(f, "previous-price");
@@ -26483,7 +26716,7 @@ function loadConfig(path) {
26483
26716
  function validateConfig(value) {
26484
26717
  const is = new Issues();
26485
26718
  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"]);
26719
+ is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "notices", "merge_tags", "checks", "coverage"]);
26487
26720
  const cov = value.coverage;
26488
26721
  if (cov !== void 0 && cov !== false) {
26489
26722
  if (!isObj2(cov)) is.add("coverage", `must be false or an object like { "ignore": ["scripts/**"] } (got ${show(cov)})`);
@@ -26551,6 +26784,15 @@ function validateConfig(value) {
26551
26784
  }
26552
26785
  }
26553
26786
  }
26787
+ const mergeTags = (at, m2) => {
26788
+ if (m2 === void 0) return;
26789
+ 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)})`);
26790
+ for (const [k, v] of Object.entries(m2)) {
26791
+ if (!/^[A-Za-z_][\w.-]{0,60}$/.test(k)) is.add(`${at}.${k}`, "is not a merge tag name (letters, digits, _ . -; the name inside {{ }})");
26792
+ 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)})`);
26793
+ }
26794
+ };
26795
+ mergeTags("merge_tags", value.merge_tags);
26554
26796
  const checks = value.checks;
26555
26797
  if (!isObj2(checks)) {
26556
26798
  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 +26877,8 @@ function validateConfig(value) {
26635
26877
  entries(
26636
26878
  "emails",
26637
26879
  (at, e) => {
26638
- is.keys(at, e, ["name", "plan", "kind", "subject", "file", "text", "html", "module", "args", "previous_price"]);
26880
+ is.keys(at, e, ["name", "plan", "kind", "subject", "file", "text", "html", "module", "args", "previous_price", "merge_tags"]);
26881
+ mergeTags(`${at}.merge_tags`, e.merge_tags);
26639
26882
  is.oneOf(`${at}.kind`, e.kind, EMAIL_KINDS2, true);
26640
26883
  is.exactlyOne(at, e, ["file", "text", "html", "module"], [["text", "html"]]);
26641
26884
  if (e.file !== void 0) {
@@ -26668,7 +26911,7 @@ function entryLine(raw, name) {
26668
26911
  const i = lines.findIndex((l) => needle.test(l));
26669
26912
  return i >= 0 ? i + 1 : 1;
26670
26913
  }
26671
- var CONFIG_FILES, SCHEMA_URL, INTERVALS4, EMAIL_KINDS2, PAGE_KINDS2, ConfigError, isObj2, show, TODO, Issues, PLAN_KEYS;
26914
+ var CONFIG_FILES, SCHEMA_URL, INTERVALS4, EMAIL_KINDS2, PAGE_KINDS2, MERGE_TAG_FIELDS, ConfigError, isObj2, show, TODO, Issues, PLAN_KEYS;
26672
26915
  var init_config3 = __esm({
26673
26916
  "src/ci/config.ts"() {
26674
26917
  "use strict";
@@ -26677,6 +26920,7 @@ var init_config3 = __esm({
26677
26920
  INTERVALS4 = ["day", "week", "month", "year"];
26678
26921
  EMAIL_KINDS2 = ["acknowledgment", "trial_reminder", "renewal_reminder", "price_change", "annual_terms", "charge_notice", "cancellation_confirmation"];
26679
26922
  PAGE_KINDS2 = ["checkout", "pricing"];
26923
+ 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
26924
  ConfigError = class extends Error {
26681
26925
  constructor(file, issues) {
26682
26926
  super(`${file}: ${issues.length === 1 ? issues[0] : `${issues.length} problems:
@@ -27030,6 +27274,12 @@ function detectBrand(root, layoutFile, pkgName) {
27030
27274
  }
27031
27275
  return void 0;
27032
27276
  }
27277
+ function detectMailer(deps, framework, hasPackageJson) {
27278
+ const found = MAILER_PACKAGES.find((m) => m in deps);
27279
+ if (found) return found;
27280
+ if (!hasPackageJson && framework === "html") return "none";
27281
+ return null;
27282
+ }
27033
27283
  function readJson(path) {
27034
27284
  try {
27035
27285
  return JSON.parse(readFileSync5(path, "utf8"));
@@ -27089,12 +27339,49 @@ function walk2(root, visit) {
27089
27339
  }
27090
27340
  }
27091
27341
  }
27342
+ function hasAppRouteFile(dir, depth = 0) {
27343
+ if (depth > 3) return false;
27344
+ let names = [];
27345
+ try {
27346
+ names = readdirSync2(dir);
27347
+ } catch {
27348
+ return false;
27349
+ }
27350
+ if (names.some((n) => /^(?:page|route|layout)\.(?:tsx|jsx|ts|js|mdx)$/.test(n))) return true;
27351
+ return names.some((n) => !n.startsWith(".") && n !== "node_modules" && (() => {
27352
+ try {
27353
+ return statSync(join4(dir, n)).isDirectory();
27354
+ } catch {
27355
+ return false;
27356
+ }
27357
+ })() && hasAppRouteFile(join4(dir, n), depth + 1));
27358
+ }
27359
+ function hasTsFiles(dir, depth = 0) {
27360
+ if (depth > 3) return false;
27361
+ let names = [];
27362
+ try {
27363
+ names = readdirSync2(dir);
27364
+ } catch {
27365
+ return false;
27366
+ }
27367
+ if (names.some((n) => /\.tsx?$/.test(n))) return true;
27368
+ return names.some((n) => !n.startsWith(".") && (() => {
27369
+ try {
27370
+ return statSync(join4(dir, n)).isDirectory();
27371
+ } catch {
27372
+ return false;
27373
+ }
27374
+ })() && hasTsFiles(join4(dir, n), depth + 1));
27375
+ }
27092
27376
  function detectFramework(root, deps) {
27093
27377
  const has2 = (d) => d in deps;
27094
27378
  if (existsSync4(join4(root, "layout", "theme.liquid")) && existsSync4(join4(root, "config", "settings_schema.json"))) return { framework: "shopify-theme", layoutFile: "layout/theme.liquid" };
27095
27379
  if (has2("next")) {
27096
27380
  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
27381
  if (app) return { framework: "nextjs-app", layoutFile: app };
27382
+ const appDir = ["src/app", "app"].find((d) => existsSync4(join4(root, d)) && hasAppRouteFile(join4(root, d)));
27383
+ const pagesExists = existsSync4(join4(root, "pages")) || existsSync4(join4(root, "src", "pages"));
27384
+ if (appDir && !pagesExists) return { framework: "nextjs-app", layoutFile: `${appDir}/layout.${existsSync4(join4(root, "tsconfig.json")) || hasTsFiles(join4(root, appDir)) ? "tsx" : "js"}` };
27098
27385
  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
27386
  const pagesDir = existsSync4(join4(root, "src", "pages")) ? "src/pages" : "pages";
27100
27387
  return { framework: "nextjs-pages", layoutFile: doc ?? `${pagesDir}/_document.${existsSync4(join4(root, "tsconfig.json")) ? "tsx" : "js"}` };
@@ -27181,11 +27468,11 @@ function routeOf(rel2) {
27181
27468
  }
27182
27469
  return null;
27183
27470
  }
27184
- function findStripeKey(env2, processEnv) {
27471
+ function findStripeKey(env2, processEnv, envFile = {}) {
27185
27472
  const sources = [];
27186
27473
  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
27474
  sources.push([`${k} (environment)`, processEnv[k]]);
27188
- sources.push([`${k} (.env)`, env2[k]]);
27475
+ sources.push([`${k} (${envFile[k] ?? ".env"})`, env2[k]]);
27189
27476
  }
27190
27477
  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
27478
  const hint = (key) => `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
@@ -27285,11 +27572,15 @@ function detectProject(root, processEnv = {}) {
27285
27572
  const { framework, layoutFile } = detectFramework(root, deps);
27286
27573
  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
27574
  const env2 = {};
27575
+ const envFile = {};
27288
27576
  for (const f of [".env", ".env.production", ".env.local", ".env.development", ".env.development.local"]) {
27289
27577
  const t = readText(join4(root, f));
27290
- if (t) Object.assign(env2, parseDotenv(t));
27578
+ if (!t) continue;
27579
+ const vars = parseDotenv(t);
27580
+ Object.assign(env2, vars);
27581
+ for (const k of Object.keys(vars)) envFile[k] = f;
27291
27582
  }
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";
27583
+ 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
27584
  const checkoutCode = [];
27294
27585
  const payButtons = [];
27295
27586
  const checkoutSessions = [];
@@ -27319,8 +27610,9 @@ function detectProject(root, processEnv = {}) {
27319
27610
  checkoutSessions.push({ file: rel2, line: line2, ...parsed.value ? { params: redactSecretsDeep(parsed.value) } : {}, unresolved: parsed.unresolved });
27320
27611
  }
27321
27612
  });
27613
+ if (billingPlatform === "custom" && checkoutCode.some((c) => c.kind.startsWith("stripe_"))) billingPlatform = "stripe";
27322
27614
  const site = detectSiteUrl(root, env2, pkg);
27323
- const stripeKey = findStripeKey(env2, processEnv);
27615
+ const stripeKey = findStripeKey(env2, processEnv, envFile);
27324
27616
  const brand = detectBrand(root, layoutFile, typeof pkg?.name === "string" ? pkg.name : void 0);
27325
27617
  return {
27326
27618
  root,
@@ -27341,16 +27633,18 @@ function detectProject(root, processEnv = {}) {
27341
27633
  ...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
27342
27634
  ...brand ? { brand } : {},
27343
27635
  plans: plansFromSessions(checkoutSessions),
27344
- env: env2
27636
+ env: env2,
27637
+ mailer: detectMailer(deps, framework, !!pkg)
27345
27638
  };
27346
27639
  }
27347
- var GENERIC_NAMES, IGNORE_DIRS, CODE_EXT, MAX_FILES, MAX_FILE_BYTES, first, SITE_URL_KEYS, POLICY_ROUTES, CHECKOUT_PATTERNS, PAY_BUTTON;
27640
+ 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
27641
  var init_detect = __esm({
27349
27642
  "src/commands/init/detect.ts"() {
27350
27643
  "use strict";
27351
27644
  init_src2();
27352
27645
  init_literal();
27353
27646
  GENERIC_NAMES = /^(?:web|app|site|website|frontend|client|my-app|my-site|demo|example|store|shop|starter|template|project|monorepo|root)$/i;
27647
+ 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
27648
  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
27649
  CODE_EXT = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|liquid|html|ejs|hbs|erb)$/i;
27356
27650
  MAX_FILES = 4e3;
@@ -27366,6 +27660,7 @@ var init_detect = __esm({
27366
27660
  CHECKOUT_PATTERNS = [
27367
27661
  { kind: "stripe_checkout_session", re: /checkout\.sessions\.create\s*\(/ },
27368
27662
  { kind: "stripe_payment_link", re: /paymentLinks\.create\s*\(|https:\/\/buy\.stripe\.com\// },
27663
+ { kind: "stripe_checkout_link", re: /https:\/\/checkout\.stripe\.com\// },
27369
27664
  { kind: "stripe_pricing_table", re: /<stripe-pricing-table\b/ },
27370
27665
  { kind: "stripe_subscription_api", re: /subscriptions\.create\s*\(/ },
27371
27666
  { kind: "stripe_elements", re: /<(PaymentElement|CardElement|ExpressCheckoutElement)\b|elements\.create\(\s*['"]payment['"]/ },
@@ -27380,6 +27675,47 @@ var init_detect = __esm({
27380
27675
  // src/ci/scaffold.ts
27381
27676
  import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27382
27677
  import { basename, dirname as dirname4, join as join5, relative as relative2 } from "node:path";
27678
+ function countTodoValues(v) {
27679
+ if (typeof v === "string") return /^TODO\b/.test(v) ? 1 : 0;
27680
+ if (Array.isArray(v)) return v.reduce((n, x) => n + countTodoValues(x), 0);
27681
+ if (v && typeof v === "object") return Object.values(v).reduce((n, x) => n + countTodoValues(x), 0);
27682
+ return 0;
27683
+ }
27684
+ function mergeTagsIn(text) {
27685
+ const out = [];
27686
+ for (const m of text.matchAll(/\{\{\{?\s*([A-Za-z_][\w.-]*)\s*\}?\}\}|\*\|([A-Za-z_][\w.-]*)\|\*/g)) {
27687
+ const t = m[1] ?? m[2];
27688
+ if (!out.includes(t)) out.push(t);
27689
+ }
27690
+ return out;
27691
+ }
27692
+ function mergeTagField(tag) {
27693
+ const t = tag.toLowerCase().replace(/[.-]/g, "_").replace(/^(?:subscription|sub)_/, "");
27694
+ if (/^(?:plan_)?(?:price|amount|cost|recurring_price|price_formatted|formatted_price|renewal_price|renewal_amount)$/.test(t)) return "plan.price";
27695
+ if (/^(?:plan_)?(?:interval|billing_interval|period|billing_period|frequency|billing_cycle|cycle|interval_name)$/.test(t)) return "plan.interval";
27696
+ if (/^(?:plan|plan_name|product|product_name|plan_title|tier)$/.test(t)) return "plan.name";
27697
+ if (/^(?:plan_)?(?:trial_days|trial_length|trial_period_days)$/.test(t)) return "plan.trial_days";
27698
+ if (/^(?:cancel_url|cancellation_url|manage_url|billing_url|account_url|cancel_link)$/.test(t)) return "merchant.cancel_url";
27699
+ if (/^(?:company|company_name|merchant|merchant_name|brand|brand_name|business_name|app_name)$/.test(t)) return "merchant.name";
27700
+ if (/^(?:support_email|support_contact|support|help_email|contact_email)$/.test(t)) return "merchant.support_contact";
27701
+ return void 0;
27702
+ }
27703
+ function pageSources(root) {
27704
+ const out = [];
27705
+ walk3(root, (rel2) => {
27706
+ if (out.length >= 4) return;
27707
+ let m = /^(?:src\/)?app\/((?:\([^/]+\)\/)*)([^/()]+)\/page\.(?:tsx|jsx|ts|js|mdx)$/.exec(rel2);
27708
+ if (m && PAGE_ROUTE.test(m[2])) {
27709
+ out.push({ source: rel2, file: `.next/server/app/${m[2]}.html`, route: m[2] });
27710
+ return;
27711
+ }
27712
+ m = /^(?:src\/)?pages\/([^/]+?)(?:\/index)?\.(tsx|jsx|ts|js|astro)$/.exec(rel2);
27713
+ if (m && PAGE_ROUTE.test(m[1])) {
27714
+ out.push({ source: rel2, file: m[2] === "astro" ? `dist/${m[1]}/index.html` : `.next/server/pages/${m[1]}.html`, route: m[1] });
27715
+ }
27716
+ });
27717
+ return out;
27718
+ }
27383
27719
  function walk3(root, visit) {
27384
27720
  let n = 0;
27385
27721
  const go = (dir, depth) => {
@@ -27429,7 +27765,8 @@ function plansFromLiterals(text, file) {
27429
27765
  const trial = /\btrial(?:_period)?_?days\s*:\s*(\d+)/i.exec(body)?.[1];
27430
27766
  if (out.some((p) => p.name === name)) continue;
27431
27767
  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 });
27768
+ const ids = [...new Set([...body.matchAll(/['"`](price_[A-Za-z0-9_]+)['"`]/g)].map((x) => x[1]))];
27769
+ out.push({ name, ...key ? { key } : {}, price: major, currency, interval, ...count && Number(count) > 1 ? { interval_count: Number(count) } : {}, ...trial && Number(trial) > 0 ? { trial_days: Number(trial) } : {}, ...ids.length ? { price_ids: ids } : {}, source: file });
27433
27770
  }
27434
27771
  return out;
27435
27772
  }
@@ -27480,10 +27817,84 @@ function pageSource(root, built) {
27480
27817
  const hits = bases.flatMap((b) => [".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".astro", ".mdx"].map((e) => `${b}${e}`)).filter((f) => existsSync5(join5(root, f)));
27481
27818
  return hits.length === 1 ? hits[0] : void 0;
27482
27819
  }
27820
+ function priceExpressions(root, file, line2) {
27821
+ let text;
27822
+ try {
27823
+ text = readFileSync6(join5(root, file), "utf8");
27824
+ } catch {
27825
+ return [];
27826
+ }
27827
+ const from = text.split("\n").slice(line2 - 1).join("\n");
27828
+ const at = from.search(/checkout\.sessions\.create|paymentLinks\.create/);
27829
+ const open2 = at < 0 ? -1 : from.indexOf("(", at);
27830
+ if (open2 < 0) return [];
27831
+ let depth = 0;
27832
+ let end = from.length;
27833
+ for (let i = open2; i < from.length; i++) {
27834
+ const c = from[i];
27835
+ if (c === "(" || c === "{" || c === "[") depth++;
27836
+ else if (c === ")" || c === "}" || c === "]") {
27837
+ depth--;
27838
+ if (depth === 0) {
27839
+ end = i;
27840
+ break;
27841
+ }
27842
+ }
27843
+ }
27844
+ const call = from.slice(open2, end);
27845
+ const li = call.indexOf("line_items");
27846
+ if (li < 0) return [];
27847
+ const items = call.slice(li);
27848
+ const out = [];
27849
+ for (const m of items.matchAll(/\bprice\s*:\s*/g)) {
27850
+ let d = 0;
27851
+ let j = m.index + m[0].length;
27852
+ const start = j;
27853
+ for (; j < items.length; j++) {
27854
+ const c = items[j];
27855
+ if (c === "(" || c === "{" || c === "[") d++;
27856
+ else if (c === ")" || c === "}" || c === "]") {
27857
+ if (d === 0) break;
27858
+ d--;
27859
+ } else if ((c === "," || c === "\n") && d === 0) break;
27860
+ }
27861
+ const expr = items.slice(start, j).trim();
27862
+ if (expr) out.push(expr);
27863
+ if (out.length >= 5) break;
27864
+ }
27865
+ return out;
27866
+ }
27867
+ function pairCall(root, s2, plans, priceIds, calls) {
27868
+ if (plans.length === 1) return { plan: String(plans[0].name) };
27869
+ if (!plans.length || plans.some((p) => String(p.name).startsWith("TODO"))) return {};
27870
+ const sold = s2.params ? plansSoldBy(s2.params, plans) : [];
27871
+ if (sold.length === 1) return { plan: String(sold[0].name) };
27872
+ const items = Array.isArray(s2.params?.line_items) ? s2.params.line_items : [];
27873
+ const literalIds = items.map((li) => li && typeof li.price === "string" ? li.price : void 0).filter((x) => !!x);
27874
+ const exprs = priceExpressions(root, s2.file, s2.line).filter((e) => !/^['"`][^'"`]*['"`]$/.test(e));
27875
+ for (const id of literalIds) {
27876
+ const owner = plans.filter((p) => priceIds.get(String(p.name))?.includes(id));
27877
+ if (owner.length === 1) return { plan: String(owner[0].name), why: `${id} is the Price id of that plan in the plans module` };
27878
+ }
27879
+ const runtime = exprs.find((e) => /\?|\|\||&&|\[[^\]'"`]*\]|\(/.test(e));
27880
+ if (runtime && !literalIds.length) return calls === 1 ? { several: true } : { ref: runtime };
27881
+ for (const ref of [...literalIds, ...exprs]) {
27882
+ const hint = intervalHintOf(ref);
27883
+ if (!hint) continue;
27884
+ const match = plans.filter((p) => {
27885
+ const count = Number(p.interval_count ?? 1) || 1;
27886
+ const unit2 = p.interval === "month" && count === 12 ? "year" : count === 1 ? p.interval : void 0;
27887
+ return unit2 === hint;
27888
+ });
27889
+ 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` };
27890
+ }
27891
+ return { ref: literalIds[0] ?? exprs[0] };
27892
+ }
27483
27893
  function scaffoldConfig(root, env2 = {}, facts = {}) {
27484
27894
  const det = detectProject(root, env2);
27485
27895
  const todos = [];
27486
27896
  const found = [];
27897
+ const notes2 = [];
27487
27898
  const names = /* @__PURE__ */ new Set();
27488
27899
  const merchant = {};
27489
27900
  const fact = (k) => typeof facts[k] === "string" && facts[k].trim() ? facts[k].trim() : void 0;
@@ -27511,8 +27922,21 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27511
27922
  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
27923
  return m ? String(m.name) : void 0;
27513
27924
  };
27925
+ const literals = [];
27926
+ walk3(root, (rel2) => {
27927
+ if (!PLAN_FILE.test(rel2) || literals.length >= 8) return;
27928
+ let text = "";
27929
+ try {
27930
+ text = readFileSync6(join5(root, rel2), "utf8");
27931
+ } catch {
27932
+ return;
27933
+ }
27934
+ for (const p of plansFromLiterals(text, rel2)) if (!literals.some((x) => x.name === p.name)) literals.push(p);
27935
+ });
27936
+ const minorOf = (p) => typeof p.amount === "number" ? p.amount : typeof p.price === "number" ? Math.round(p.price * 100) : NaN;
27937
+ 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
27938
  const plans = det.plans.map((p, i) => ({
27515
- name: p.name ?? initName(p) ?? `Plan ${i + 1}`,
27939
+ name: p.name ?? initName(p) ?? literalFor(p)?.name ?? `Plan ${i + 1}`,
27516
27940
  amount: p.amount,
27517
27941
  currency: p.currency,
27518
27942
  interval: p.interval,
@@ -27520,31 +27944,28 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27520
27944
  ...p.trial_days ? { trial_days: p.trial_days } : {}
27521
27945
  }));
27522
27946
  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
27947
  if (!plans.length && Array.isArray(facts.plans)) {
27525
27948
  for (const [i, p] of facts.plans.entries()) {
27526
27949
  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 } : {} });
27950
+ 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
27951
  found.push(`plan ${String(plans[plans.length - 1].name)} (from checkbox init --plan)`);
27529
27952
  }
27530
27953
  }
27531
27954
  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
- });
27955
+ for (const p of literals) {
27956
+ const { source, key: _key, price_ids: _ids, ...plan2 } = p;
27957
+ plans.push(plan2);
27958
+ found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
27959
+ }
27960
+ }
27961
+ const planKeys = /* @__PURE__ */ new Map();
27962
+ const planPriceIds = /* @__PURE__ */ new Map();
27963
+ for (const p of plans) {
27964
+ const lit = literals.find((x) => x.name === p.name) ?? literalFor(p);
27965
+ if (lit?.key) planKeys.set(String(p.name), lit.key);
27966
+ if (lit?.price_ids?.length) planPriceIds.set(String(p.name), lit.price_ids);
27967
+ 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));
27968
+ if (known) planPriceIds.set(String(p.name), [...planPriceIds.get(String(p.name)) ?? [], ...known.stripe_price_ids]);
27548
27969
  }
27549
27970
  if (!plans.length) {
27550
27971
  plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
@@ -27588,11 +28009,20 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27588
28009
  } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
27589
28010
  else found.push(`${s2.file}:${s2.line}: Checkout Session`);
27590
28011
  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"))) {
28012
+ const pair = pairCall(root, s2, plans, planPriceIds, det.checkoutSessions.length);
28013
+ if (pair.plan) {
28014
+ if (plans.length > 1) {
28015
+ names.delete(String(entry.name));
28016
+ entry.name = unique(names, `${String(entry.name).replace(/\)$/, "")}: ${pair.plan})`);
28017
+ }
28018
+ stripe.push({ ...entry, plan: pair.plan });
28019
+ if (pair.why) found.push(`${s2.file}:${s2.line}: sells plan "${pair.plan}" (${pair.why})`);
28020
+ } else if (pair.several) {
27594
28021
  for (const p of plans) stripe.push({ ...entry, name: unique(names, `${String(entry.name).replace(/\)$/, "")}: ${String(p.name)})`), plan: String(p.name) });
27595
28022
  names.delete(String(entry.name));
28023
+ } else if (plans.length > 1 && !plans.some((p) => String(p.name).startsWith("TODO"))) {
28024
+ stripe.push({ ...entry, plan: `TODO: which plan does this call sell? one of ${oneOf}` });
28025
+ 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
28026
  } else stripe.push(entry);
27597
28027
  }
27598
28028
  const emails = [];
@@ -27602,12 +28032,22 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27602
28032
  if (IN_EMAIL_DIR.test(rel2)) {
27603
28033
  const kind = emailKindOf(rel2);
27604
28034
  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`);
28035
+ let text = "";
28036
+ try {
28037
+ text = readFileSync6(join5(root, rel2), "utf8");
28038
+ } catch {
28039
+ }
28040
+ const tags = Object.fromEntries(mergeTagsIn(text).flatMap((t) => mergeTagField(t) ? [[t, mergeTagField(t)]] : []));
28041
+ const planTagged = Object.values(tags).some((v) => v.startsWith("plan."));
28042
+ const hint = intervalHintOf(basename(rel2).replace(/\.[^.]+$/, ""));
28043
+ const byName = hint && plans.length > 1 ? plans.filter((p) => p.interval === hint && (Number(p.interval_count ?? 1) || 1) === 1) : [];
28044
+ const plan2 = plans.length > 1 && planTagged ? { plan: "all" } : byName.length === 1 ? { plan: String(byName[0].name) } : planFor();
28045
+ emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...plan2, ...Object.keys(tags).length ? { merge_tags: tags } : {} });
28046
+ 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
28047
  return;
27608
28048
  }
27609
28049
  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)`);
28050
+ 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
28051
  return;
27612
28052
  }
27613
28053
  }
@@ -27631,8 +28071,22 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27631
28071
  }
27632
28072
  }
27633
28073
  });
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`);
28074
+ for (const ps of pageSources(root)) {
28075
+ if (pages.some((p) => p.source === ps.source || p.file === ps.file)) continue;
28076
+ let text = "";
28077
+ try {
28078
+ text = readFileSync6(join5(root, ps.source), "utf8");
28079
+ } catch {
28080
+ }
28081
+ const kind = /checkout|subscribe/i.test(ps.route) ? "checkout" : "pricing";
28082
+ 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}`);
28083
+ const showsAll = plans.length > 1 && plans.every(shows);
28084
+ pages.push({ name: unique(names, `Page: ${ps.route}`), file: ps.file, source: ps.source, kind, ...showsAll ? { plan: "all" } : planFor("page") });
28085
+ found.push(`${ps.source}: ${kind} page (checked as the built ${ps.file}; the workflow builds it first)`);
28086
+ }
28087
+ const inPages = (f) => pages.some((p) => p.source === f);
28088
+ if (!disclosures.length && det.payButtons.length && !det.payButtons.some((b) => inPages(b.file))) {
28089
+ 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
28090
  }
27637
28091
  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
28092
  const checks = {};
@@ -27642,20 +28096,24 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27642
28096
  if (emails.length) checks.emails = emails;
27643
28097
  checks.notice_schedule = true;
27644
28098
  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)');
28099
+ notes2.push('No checkout code, email templates or pages were found: add entries under "checks" (https://checkbox.site/docs/guides/ci#config)');
27646
28100
  }
27647
28101
  return {
27648
28102
  config: { $schema: SCHEMA_URL, merchant, jurisdictions: "all", plans, checks },
27649
28103
  todos,
27650
- found
28104
+ found,
28105
+ notes: notes2,
28106
+ todoValues: countTodoValues({ merchant, plans, checks })
27651
28107
  };
27652
28108
  }
27653
- var IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
28109
+ var PAGE_ROUTE, IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
27654
28110
  var init_scaffold = __esm({
27655
28111
  "src/ci/scaffold.ts"() {
27656
28112
  "use strict";
27657
28113
  init_detect();
28114
+ init_preflight();
27658
28115
  init_config3();
28116
+ PAGE_ROUTE = /^(pricing|checkout|subscribe|plans)$/i;
27659
28117
  IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify", ".turbo", ".cache", "coverage", ".astro", "vendor", ".venv", "__pycache__", ".github"]);
27660
28118
  MAX_FILES2 = 5e3;
27661
28119
  EMAIL_NAMES = [
@@ -28161,6 +28619,53 @@ function pageJob(loaded, e, o, root) {
28161
28619
  }
28162
28620
  return job;
28163
28621
  }
28622
+ function planMoney(plan2) {
28623
+ if (!plan2) return void 0;
28624
+ const cur = (plan2.currency ?? "usd").toUpperCase();
28625
+ const major = typeof plan2.amount === "number" ? plan2.amount / 100 : typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
28626
+ if (major === void 0) return void 0;
28627
+ try {
28628
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major);
28629
+ } catch {
28630
+ return `${major.toFixed(2)} ${cur}`;
28631
+ }
28632
+ }
28633
+ function fillMergeTags(msg2, tags, config, plan2) {
28634
+ const field = (v) => {
28635
+ if (!MERGE_TAG_FIELDS.includes(v)) return v;
28636
+ switch (v) {
28637
+ case "plan.price":
28638
+ case "plan.amount":
28639
+ return planMoney(plan2);
28640
+ case "plan.interval":
28641
+ return plan2 ? (plan2.interval_count ?? 1) > 1 ? `${plan2.interval_count} ${plan2.interval}s` : plan2.interval : void 0;
28642
+ case "plan.name":
28643
+ return plan2?.name;
28644
+ case "plan.trial_days":
28645
+ return plan2?.trial_days !== void 0 ? String(plan2.trial_days) : void 0;
28646
+ case "plan.currency":
28647
+ return (plan2?.currency ?? "usd").toUpperCase();
28648
+ case "merchant.name":
28649
+ return config.merchant?.name;
28650
+ case "merchant.cancel_url":
28651
+ return config.merchant?.cancel_url;
28652
+ case "merchant.support_contact":
28653
+ return config.merchant?.support_contact;
28654
+ }
28655
+ return void 0;
28656
+ };
28657
+ const values = /* @__PURE__ */ new Map();
28658
+ for (const [k, v] of Object.entries(tags)) {
28659
+ const val = field(v);
28660
+ if (val !== void 0) values.set(k.toLowerCase(), val);
28661
+ }
28662
+ const esc3 = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28663
+ const fill2 = (t, html) => t?.replace(/\{\{\{\s*([\w.-]+)\s*\}\}\}|\{\{\s*([\w.-]+)\s*\}\}|\*\|([\w.-]+)\|\*/g, (m, a, b, c) => {
28664
+ const v = values.get(String(a ?? b ?? c).toLowerCase());
28665
+ return v === void 0 ? m : html ? esc3(v) : v;
28666
+ });
28667
+ 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) } : {} };
28668
+ }
28164
28669
  async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
28165
28670
  const { config, dir } = loaded;
28166
28671
  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 +28686,8 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
28181
28686
  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
28687
  } else throw new InputError(`${e.module} must return { subject, text | html } or a string`);
28183
28688
  } else msg2 = { subject: e.subject, ...e.text ? { text: e.text } : {}, ...e.html ? { html: e.html } : {} };
28689
+ const tags = { ...config.merge_tags ?? {}, ...e.merge_tags ?? {} };
28690
+ if (Object.keys(tags).length) msg2 = fillMergeTags(msg2, tags, config, pickPlan(config, planName));
28184
28691
  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
28692
  if (!msg2.text && !msg2.html) throw new InputError("the email body is empty");
28186
28693
  const terms2 = { ...planTerms(config, pickPlan(config, planName)) };
@@ -28606,6 +29113,7 @@ var init_timing = __esm({
28606
29113
  var notice_schedule_exports = {};
28607
29114
  __export(notice_schedule_exports, {
28608
29115
  computeNoticeSchedule: () => computeNoticeSchedule,
29116
+ noticesKindsNote: () => noticesKindsNote,
28609
29117
  singleDayNote: () => singleDayNote
28610
29118
  });
28611
29119
  function singleDayNote(requirementIds, days) {
@@ -28714,7 +29222,12 @@ function computeNoticeSchedule(input, now = /* @__PURE__ */ new Date()) {
28714
29222
  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
29223
  else notRequired.push({ kind: "annual_terms", reason: "No applicable requirement demands a yearly terms reminder under these jurisdictions." });
28716
29224
  }
28717
- if (!input.price_change) {
29225
+ if (!input.price_change && plan2.introAmount !== void 0 && plan2.introAmount < plan2.amount) {
29226
+ const stepUp = addInterval(firstCharge, plan2.interval, plan2.intervalCount * Math.max(1, plan2.introPeriods ?? 1));
29227
+ const t = timing("price_change", stepUp);
29228
+ if (t.governed) push("price_change", `Intro price ends: the regular price is first charged`, stepUp, t);
29229
+ 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." });
29230
+ } else if (!input.price_change) {
28718
29231
  notRequired.push({ kind: "price_change", reason: "No price change planned. Pass price_change (effective_date, new_price) to schedule its advance notice." });
28719
29232
  } else {
28720
29233
  const eff = parseDate(input.price_change.effective_date, "price_change.effective_date", start);
@@ -28741,6 +29254,24 @@ function computeNoticeSchedule(input, now = /* @__PURE__ */ new Date()) {
28741
29254
  }
28742
29255
  };
28743
29256
  }
29257
+ function noticesKindsNote(kinds, settings) {
29258
+ const scheduled = [...new Set(kinds)];
29259
+ if (!scheduled.length) return null;
29260
+ if (!settings || settings.mode !== "checkbox") {
29261
+ return {
29262
+ code: "notices_you_send",
29263
+ 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)} }.`,
29264
+ param: "site"
29265
+ };
29266
+ }
29267
+ const off = scheduled.filter((k) => !settings.enabledKinds.includes(k));
29268
+ if (!off.length) return null;
29269
+ return {
29270
+ code: "notices_kinds_off",
29271
+ 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).`,
29272
+ param: "site"
29273
+ };
29274
+ }
28744
29275
  var CITATION, BY_ID2, day, unix;
28745
29276
  var init_notice_schedule = __esm({
28746
29277
  "../../apps/api/src/v1/checks/notice-schedule.ts"() {
@@ -28781,8 +29312,15 @@ function operationFor2(kind) {
28781
29312
  function onlineEngine(client) {
28782
29313
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28783
29314
  }
29315
+ function resultOf(status, exit, strict) {
29316
+ if (exit === 0) return status === "needs_review" ? "passed with items to review (they do not fail this run)" : "passed";
29317
+ 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})`;
29318
+ if (status === "skipped") return `failed: nothing was checked (exit ${exit})`;
29319
+ if (status === "fail") return `failed: fixes needed (exit ${exit})`;
29320
+ return `failed: could not run every check (exit ${exit})`;
29321
+ }
28784
29322
  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).');
29323
+ 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).');
28786
29324
  }
28787
29325
  function pricingPageFixes(fixes) {
28788
29326
  const reworded = fixes.map(
@@ -28833,8 +29371,8 @@ function fixesOf(r, onlyNeedsFix = false) {
28833
29371
  const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
28834
29372
  if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
28835
29373
  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);
29374
+ const standOut = `Plan card ${plain.map((x) => `\u201C${x}\u201D`).join(", ")}: ${STAND_OUT_DECISION}`;
29375
+ if (!onlyNeedsFix && !out.includes(standOut)) out.push(standOut);
28838
29376
  }
28839
29377
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
28840
29378
  if (text && !out.includes(text)) out.push(text);
@@ -28862,7 +29400,7 @@ function withPlaceholders(params, placeholders) {
28862
29400
  }
28863
29401
  return copy;
28864
29402
  }
28865
- function correctedOf(type, r, partial = false, placeholders) {
29403
+ function correctedOf(type, r, partial = false, placeholders, pricingPage = false) {
28866
29404
  if (type === "stripe_checkout" && r.correction?.patch?.length && partial) {
28867
29405
  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
29406
  }
@@ -28875,8 +29413,10 @@ function correctedOf(type, r, partial = false, placeholders) {
28875
29413
  return {
28876
29414
  format: "text",
28877
29415
  title: `Suggested replacement per plan, each directly above its own plan's button${all ? " (each passes the checks, verified by the rules engine)" : ""}`,
29416
+ // Round 32 (pilot 31, ci-pr-agent): a pricing page links to the checkout, where consent is asked (Stripe Checkout
29417
+ // shows its own checkbox), so no consent checkbox label is suggested for it.
28878
29418
  body: perPlan.map((p) => `${p.plan}:
28879
- ${p.disclosure_text}${p.consent_label ? `
29419
+ ${p.disclosure_text}${p.consent_label && !pricingPage ? `
28880
29420
  Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
28881
29421
  };
28882
29422
  }
@@ -28893,7 +29433,8 @@ Consent checkbox label: ${r.replacement.consent_label}` : ""}`
28893
29433
  };
28894
29434
  }
28895
29435
  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}
29436
+ const toFill = r.template.placeholders ?? [];
29437
+ 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
29438
 
28898
29439
  ${r.template.text}` };
28899
29440
  }
@@ -28937,13 +29478,20 @@ function noticeCoverage(config, planName, schedule, opts = {}) {
28937
29478
  const required = /* @__PURE__ */ new Map();
28938
29479
  for (const n of schedule.notices ?? []) {
28939
29480
  const k = NOTICE_TO_EMAIL[n.kind];
28940
- if (k && !required.has(k)) required.set(k, { occasion: n.occasion, days: n.days_before });
28941
- }
29481
+ if (k && !required.has(k)) required.set(k, { occasion: n.occasion, days: n.days_before, citations: n.citations ?? [], jurisdictions: n.jurisdictions ?? [] });
29482
+ }
29483
+ const law = (w) => {
29484
+ if (!w.citations.length) return "";
29485
+ const where2 = w.jurisdictions.map((j) => j.replace(/^US-/, "")).filter((j) => j !== "FED");
29486
+ const fed = w.jurisdictions.includes("US-FED");
29487
+ const places = [...fed ? ["federal"] : [], ...where2];
29488
+ 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(", ")})` : ""}.`;
29489
+ };
28942
29490
  const missing = [...required].filter(([k]) => !have.has(k));
28943
29491
  if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
28944
29492
  const sent2 = [...required.keys()].filter((k) => byCheckbox.has(k)).length;
28945
29493
  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)" : ""}.`);
29494
+ 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
29495
  return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
28948
29496
  }
28949
29497
  function unverifiedOffer(r) {
@@ -28970,6 +29518,11 @@ async function runJob(job, engine, config, opts = {}) {
28970
29518
  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
29519
  }
28972
29520
  if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report, opts), report };
29521
+ const pairing = (report.warnings ?? []).find((w) => w.code === "price_plan_conflict");
29522
+ if (pairing) {
29523
+ const names = (config.plans ?? []).map((p) => JSON.stringify(p.name)).join(", ");
29524
+ 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 };
29525
+ }
28973
29526
  const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
28974
29527
  for (const w of report.warnings ?? []) {
28975
29528
  const text = ciVocabulary(w.message);
@@ -28980,6 +29533,12 @@ async function runJob(job, engine, config, opts = {}) {
28980
29533
  const status = ciStatusOf(report);
28981
29534
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28982
29535
  let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
29536
+ const tagsLeft = job.type === "email" && status !== "pass" ? (report.warnings ?? []).find((w) => w.code === "unresolved_placeholders") : void 0;
29537
+ if (tagsLeft) {
29538
+ const tags = [...new Set(tagsLeft.message.match(/\{\{\{?\s*[\w.-]+\s*\}?\}\}|\*\|[\w.-]+\|\*/g) ?? [])];
29539
+ 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"}"`);
29540
+ fixes.unshift(`Checkbox cannot read the terms behind ${tags.join(", ")}. Add "merge_tags": { ${sample.join(", ")} } to this entry in checkbox.json (plan.price, plan.interval, \u2026 take the entry's plan), then run checkbox ci again; no render module is needed.`);
29541
+ }
28983
29542
  const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
28984
29543
  if (pricingPage) fixes = pricingPageFixes(fixes);
28985
29544
  const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
@@ -29024,7 +29583,7 @@ async function runJob(job, engine, config, opts = {}) {
29024
29583
  );
29025
29584
  }
29026
29585
  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);
29586
+ const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders, pricingPage);
29028
29587
  if (corrected?.format === "json" && job.placeholders) {
29029
29588
  const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
29030
29589
  if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
@@ -29104,10 +29663,11 @@ async function runCi(jobs, engine, opts) {
29104
29663
  checks,
29105
29664
  ...versioned?.rules_version ? { rules_version: versioned.rules_version } : {},
29106
29665
  ...versioned?.catalog_version ? { catalog_version: versioned.catalog_version } : {},
29107
- disclaimer: CI_DISCLAIMER
29666
+ disclaimer: CI_DISCLAIMER,
29667
+ result: resultOf(status, exit, opts.strict)
29108
29668
  };
29109
29669
  }
29110
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, STAND_OUT_FIX, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
29670
+ var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, CI_DISCLAIMER, RANK2, OK_STATUSES, STAND_OUT_DECISION, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
29111
29671
  var init_runner = __esm({
29112
29672
  "src/ci/runner.ts"() {
29113
29673
  "use strict";
@@ -29124,10 +29684,11 @@ var init_runner = __esm({
29124
29684
  email: "email",
29125
29685
  notice_schedule: "notice_schedule"
29126
29686
  };
29687
+ STRIPE_OBJECTS_OFFLINE_NOTICE = "Stripe objects (Payment Links, and the trial or price set on a Stripe Price) are not checked offline: this change touches a trial, price or interval, so run checkbox ci --online (CHECKBOX_SECRET_KEY, with Stripe connected on the site) to check that Stripe sells the same terms.";
29127
29688
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
29128
29689
  RANK2 = { fail: 0, needs_review: 1 };
29129
29690
  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.";
29691
+ STAND_OUT_DECISION = "Decision for you (not a fix): its renewal line is there and legible but looks like the text around it. California asks for renewal terms that stand out, so consider larger type, bold (weight 600 or more), a contrasting colour, or a border.";
29131
29692
  NOTICE_TO_EMAIL = {
29132
29693
  acknowledgment: "acknowledgment",
29133
29694
  trial_ending: "trial_reminder",
@@ -29172,21 +29733,28 @@ function renderHuman(run2, c, opts = {}) {
29172
29733
  const lines = [];
29173
29734
  const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
29174
29735
  lines.push(`${c.bold("Checkbox CI")} ${run2.config} \xB7 ${run2.mode}${versions} \xB7 ${run2.checks.length} check${run2.checks.length === 1 ? "" : "s"}`);
29736
+ const unreadPrices = [];
29175
29737
  const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
29176
29738
  for (const x of run2.checks) {
29177
29739
  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
29740
  lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
29179
29741
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
29180
29742
  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, " ")}`);
29743
+ if (opts.verbose) for (const f of x.fixes.slice(1)) lines.push(/^Plan card [^:]+: Decision for you\b/.test(f) ? ` decision: ${wrap(f.replace(/Decision for you \(not a fix\): /, ""), 110, " ")}` : ` fix: ${wrap(f, 110, " ")}`);
29182
29744
  const alerts = x.alerts ?? [];
29183
29745
  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, " ")}`));
29746
+ for (const w of x.warnings.filter((w2) => !alerts.includes(w2) && !PRICE_UNREAD.test(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
29747
+ for (const w of x.warnings) {
29748
+ const id = PRICE_UNREAD.exec(w)?.[1];
29749
+ if (id && !unreadPrices.includes(id)) unreadPrices.push(id);
29750
+ }
29185
29751
  if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
29186
29752
  }
29753
+ 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
29754
  const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
29188
29755
  if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
29189
- lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
29756
+ if (run2.offline_notice) lines.push("", c.yellow(wrap(`Not checked offline: ${run2.offline_notice}`, 110, " ")));
29757
+ lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}${run2.result ? ` (${run2.result.replace(/ \(exit \d+\)$/, "")})` : ""}`);
29190
29758
  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
29759
  lines.push(c.dim(run2.disclaimer));
29192
29760
  return lines;
@@ -29211,6 +29779,8 @@ function stepSummary(run2) {
29211
29779
  const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
29212
29780
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
29213
29781
  if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
29782
+ if (run2.result) lines.push(`Result: ${run2.result}.`, "");
29783
+ if (run2.offline_notice) lines.push(`\u26A0\uFE0F ${run2.offline_notice}`, "");
29214
29784
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
29215
29785
  for (const x of run2.checks) {
29216
29786
  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 : "";
@@ -29233,7 +29803,7 @@ function stepSummary(run2) {
29233
29803
  function escapeHtml(s2) {
29234
29804
  return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29235
29805
  }
29236
- var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
29806
+ var TYPE_LABEL, where, PRICE_UNREAD, escData, escProp, cell, STATUS_MD;
29237
29807
  var init_report = __esm({
29238
29808
  "src/ci/report.ts"() {
29239
29809
  "use strict";
@@ -29247,6 +29817,7 @@ var init_report = __esm({
29247
29817
  notice_schedule: "Notice schedule"
29248
29818
  };
29249
29819
  where = (c) => `${c.target.file}${c.target.line && c.target.line > 1 ? `:${c.target.line}` : ""}`;
29820
+ PRICE_UNREAD = /^line_items use (\S+), which Checkbox could not read\b/;
29250
29821
  escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
29251
29822
  escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
29252
29823
  cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
@@ -29429,6 +30000,8 @@ __export(ci_exports, {
29429
30000
  attachSiteForCoupons: () => attachSiteForCoupons,
29430
30001
  couponIdsIn: () => couponIdsIn,
29431
30002
  default: () => ci_default,
30003
+ siteFactsFor: () => siteFactsFor,
30004
+ termsChanged: () => termsChanged,
29432
30005
  touchesConfig: () => touchesConfig,
29433
30006
  undiffableInputs: () => undiffableInputs
29434
30007
  });
@@ -29436,6 +30009,16 @@ import { execFileSync as execFileSync2 } from "node:child_process";
29436
30009
  import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
29437
30010
  import { createInterface } from "node:readline";
29438
30011
  import { dirname as dirname7, join as join9, relative as relative5, resolve as resolve7 } from "node:path";
30012
+ function termsChanged(cwd, base, jobs) {
30013
+ if (jobs.some((j) => j.type === "stripe_checkout" && (j.sold || j.warnings.some((w) => /^The params and checkbox\.json disagree/.test(w))))) return true;
30014
+ try {
30015
+ const args = ["diff", "-U0", "--no-color", ...base ? [`${base}...HEAD`] : ["HEAD"], "--", ".", ":(exclude)*.md", ":(exclude)**/*.test.*", ":(exclude)**/*.lock", ":(exclude)package-lock.json", ":(exclude)pnpm-lock.yaml"];
30016
+ const out = execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
30017
+ 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));
30018
+ } catch {
30019
+ return false;
30020
+ }
30021
+ }
29439
30022
  function gitChanged(cwd, base) {
29440
30023
  try {
29441
30024
  const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -29505,6 +30088,36 @@ async function confirm(question) {
29505
30088
  rl.close();
29506
30089
  }
29507
30090
  }
30091
+ async function siteFactsFor(ctx, setupSession) {
30092
+ if (!setupSession) return {};
30093
+ try {
30094
+ if (!ctx.credentials()) return {};
30095
+ const client = ctx.client();
30096
+ const sess = await Promise.race([client.sdk.setupSessions.retrieve(setupSession), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 8e3))]);
30097
+ const out = {};
30098
+ const b = sess?.business ?? {};
30099
+ if (b.merchant_name) out.merchant_name = b.merchant_name;
30100
+ if (b.site_url) out.site_url = b.site_url;
30101
+ const cancel = b.cancellation_url ?? b.account_url;
30102
+ if (cancel) out.account_url = cancel;
30103
+ const plans = (sess?.site_plans ?? []).filter((p) => typeof p.amount === "number" && p.amount > 0 && typeof p.interval === "string");
30104
+ if (plans.length) {
30105
+ out.plans = plans.map((p) => ({
30106
+ // A name that is only the price ("$29.00 / month") is left out: the plans module names the plan better.
30107
+ ...p.name && !/^\W?\s*[\d.,]+/.test(p.name) ? { name: p.name } : {},
30108
+ amount: p.amount,
30109
+ currency: p.currency ?? "usd",
30110
+ interval: p.interval,
30111
+ ...p.interval_count && p.interval_count > 1 ? { interval_count: p.interval_count } : {},
30112
+ ...p.trial_days ? { trial_days: p.trial_days } : {},
30113
+ ...p.stripe_price_ids?.length ? { stripe_price_ids: p.stripe_price_ids } : {}
30114
+ }));
30115
+ }
30116
+ return out;
30117
+ } catch {
30118
+ return {};
30119
+ }
30120
+ }
29508
30121
  async function runInit(ctx, argv) {
29509
30122
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
29510
30123
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
@@ -29515,13 +30128,24 @@ async function runInit(ctx, argv) {
29515
30128
  const withWorkflow = bool(p.flags, "workflow") !== false;
29516
30129
  const projects = ctx.config.load().init?.projects ?? {};
29517
30130
  let facts = {};
30131
+ let setupSession;
29518
30132
  for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
29519
- if (projects[dir]?.facts) {
29520
- facts = projects[dir].facts;
30133
+ const proj = projects[dir];
30134
+ if (proj?.facts || proj?.setup_session) {
30135
+ facts = proj.facts ?? {};
30136
+ setupSession = proj.setup_session;
29521
30137
  break;
29522
30138
  }
29523
30139
  if (dirname7(dir) === dir) break;
29524
30140
  }
30141
+ const fromSite = await siteFactsFor(ctx, setupSession);
30142
+ facts = { ...fromSite, ...facts, ...fromSite.plans && !facts.plans ? { plans: fromSite.plans } : {} };
30143
+ if (Array.isArray(facts.plans) && Array.isArray(fromSite.plans)) {
30144
+ facts.plans = facts.plans.map((p2) => {
30145
+ const m = fromSite.plans.find((x) => x.amount === p2.amount && x.interval === p2.interval);
30146
+ return m && !p2.stripe_price_ids ? { ...p2, stripe_price_ids: m.stripe_price_ids } : p2;
30147
+ });
30148
+ }
29525
30149
  const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
29526
30150
  const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
29527
30151
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
@@ -29553,8 +30177,13 @@ async function runInit(ctx, argv) {
29553
30177
  found: s2.found,
29554
30178
  ...withWorkflow ? { workflow_steps: workflowSteps } : {},
29555
30179
  todos: s2.todos,
30180
+ // Round 32 (pilot 31, ci-pr-agent: "Fill in the 2 TODO item(s)" with 1 TODO in the file): suggestions are notes;
30181
+ // the count is the number of "TODO" values in the file.
30182
+ todo_values: s2.todoValues,
30183
+ notes: s2.notes,
30184
+ ...Object.keys(fromSite).length ? { from_site: Object.keys(fromSite) } : {},
29556
30185
  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." }
30186
+ 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
30187
  };
29559
30188
  ctx.out.result(result, () => {
29560
30189
  const lines = [];
@@ -29564,6 +30193,7 @@ async function runInit(ctx, argv) {
29564
30193
  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
30194
  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
30195
  if (s2.todos.length) lines.push("", "To do:", ...s2.todos.map((t) => ` - ${t}`));
30196
+ if (s2.notes.length) lines.push("", "Also:", ...s2.notes.map((t) => ` - ${t}`));
29567
30197
  lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
29568
30198
  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
30199
  return lines;
@@ -29728,13 +30358,17 @@ ${message}
29728
30358
  const engine = client ? onlineEngine(client) : offlineEngine();
29729
30359
  const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29730
30360
  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 } } : {} });
30361
+ const run0 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra, ...notices ? { notices, noticesTarget: { file: relative5(workspace, path).split("\\").join("/") || configName, line: noticesLine } } : {} });
30362
+ const baseRef = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
30363
+ const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? { ...run0, offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE } : run0;
29732
30364
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
29733
30365
  if (gha) {
29734
30366
  for (const a of annotations(run2)) emit(a);
29735
30367
  writeGithubFile(ctx.env.GITHUB_STEP_SUMMARY, stepSummary(run2));
30368
+ if (run2.offline_notice) emit(`::notice title=Checkbox%3A Stripe not checked offline::${run2.offline_notice}`);
29736
30369
  writeGithubFile(ctx.env.GITHUB_OUTPUT, `status=${run2.status}
29737
30370
  exit_code=${run2.exit_code}
30371
+ result=${run2.result}
29738
30372
  `);
29739
30373
  }
29740
30374
  return run2.exit_code;
@@ -29794,6 +30428,14 @@ function findAnchor(src, target) {
29794
30428
  }
29795
30429
  }
29796
30430
  }
30431
+ if (!text && !id && tag) {
30432
+ const all = [...src.matchAll(tagRe)];
30433
+ if (all.length === 1) return openAt(all[0].index + 1, `the only <${tag}> in this file`);
30434
+ if (tag === "button" || tag === "input") {
30435
+ const submit = all.filter((m) => /^[^>]*\btype\s*=\s*(?:"submit"|'submit'|\{\s*["']submit["']\s*\})/i.test(src.slice(m.index, src.indexOf(">", m.index) + 1)));
30436
+ if (submit.length === 1) return openAt(submit[0].index + 1, `the only <${tag} type="submit"> in this file`);
30437
+ }
30438
+ }
29797
30439
  return null;
29798
30440
  }
29799
30441
  function lineStart(src, idx) {
@@ -30243,6 +30885,10 @@ function planSnippet(d, s2) {
30243
30885
  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
30886
  }
30245
30887
  if (existing === null) {
30888
+ if (d.framework === "nextjs-app") {
30889
+ const contents = NEXT_APP_LAYOUT(s2, /\.tsx$/.test(file));
30890
+ return { change: { file, action: "create", summary: "Root layout with the snippet in <head>", contents, added: contents.trim().split("\n") } };
30891
+ }
30246
30892
  if (d.framework === "nextjs-pages") {
30247
30893
  const contents = NEXT_DOCUMENT(s2);
30248
30894
  return { change: { file, action: "create", summary: "Custom _document with the snippet in <Head>", contents, added: contents.trim().split("\n") } };
@@ -30371,7 +31017,7 @@ function applyChange(root, c) {
30371
31017
  mkdirSync3(dirname8(abs), { recursive: true });
30372
31018
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
30373
31019
  }
30374
- var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
31020
+ var MARK_START, MARK_END, NEXT_DOCUMENT, NEXT_APP_LAYOUT, NUXT_PLUGIN;
30375
31021
  var init_patch2 = __esm({
30376
31022
  "src/commands/init/patch.ts"() {
30377
31023
  "use strict";
@@ -30392,6 +31038,17 @@ ${jsxBlock(s2, " ").join("\n")}
30392
31038
  </Html>
30393
31039
  );
30394
31040
  }
31041
+ `;
31042
+ NEXT_APP_LAYOUT = (s2, ts) => `${ts ? "import type { ReactNode } from 'react';\n\n" : ""}export default function RootLayout({ children }${ts ? ": { children: ReactNode }" : ""}) {
31043
+ return (
31044
+ <html lang="en">
31045
+ <head>
31046
+ ${jsxBlock(s2, " ").join("\n")}
31047
+ </head>
31048
+ <body>{children}</body>
31049
+ </html>
31050
+ );
31051
+ }
30395
31052
  `;
30396
31053
  NUXT_PLUGIN = (s2) => `// ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet)
30397
31054
  export default defineNuxtPlugin(() => {
@@ -30412,8 +31069,11 @@ __export(init_exports, {
30412
31069
  configLines: () => configLines,
30413
31070
  configNameOf: () => configNameOf,
30414
31071
  default: () => init_default,
31072
+ liveKeyNextAction: () => liveKeyNextAction,
30415
31073
  parsePlanFlag: () => parsePlanFlag,
30416
- syncCheckoutConfigs: () => syncCheckoutConfigs
31074
+ snippetWords: () => snippetWords,
31075
+ syncCheckoutConfigs: () => syncCheckoutConfigs,
31076
+ withEvaluatedAt: () => withEvaluatedAt
30417
31077
  });
30418
31078
  import { existsSync as existsSync11 } from "node:fs";
30419
31079
  import { join as join11 } from "node:path";
@@ -30433,6 +31093,10 @@ function saveState(ctx, root, patch) {
30433
31093
  }
30434
31094
  ctx.config.save({ init: { ...cfg, projects } });
30435
31095
  }
31096
+ function withEvaluatedAt(c) {
31097
+ const t = c.evaluated;
31098
+ return typeof t === "number" && t > 0 ? { evaluated_at: new Date(t * 1e3).toISOString(), ...c } : c;
31099
+ }
30436
31100
  async function ask2(question) {
30437
31101
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
30438
31102
  try {
@@ -30460,6 +31124,7 @@ function projectFacts(d) {
30460
31124
  ...d.layoutFile ? { layout_file: d.layoutFile } : {},
30461
31125
  env_file: d.envFile,
30462
31126
  ...d.publicDir ? { public_dir: d.publicDir } : {},
31127
+ ...d.mailer ? { mailer: d.mailer } : {},
30463
31128
  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
31129
  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
31130
  };
@@ -30547,6 +31212,20 @@ function registrable(params, unresolved) {
30547
31212
  if (unresolved.some((u) => /^(mode|line_items|subscription_data|custom_text|consent_collection)\b/.test(u))) return false;
30548
31213
  return params.mode === "subscription" && Array.isArray(params.line_items) && params.line_items.length > 0;
30549
31214
  }
31215
+ function snippetWords(homepage, live) {
31216
+ if (homepage) return "detected";
31217
+ if (live) return "live (seen through the page captures it sends; not in the homepage HTML Checkbox loaded)";
31218
+ return "NOT detected yet (deploy, then verify again)";
31219
+ }
31220
+ function liveKeyNextAction(dashboard) {
31221
+ const url = `${dashboard.replace(/\/+$/, "")}/settings/developers?mode=live&create=key`;
31222
+ return {
31223
+ type: "ask_human_for_live_key",
31224
+ 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.`,
31225
+ command: "checkbox init --live --relogin --yes --json",
31226
+ url
31227
+ };
31228
+ }
30550
31229
  function detectionSummary(d, siteUrl) {
30551
31230
  return {
30552
31231
  framework: d.framework,
@@ -30569,9 +31248,14 @@ function detectionSummary(d, siteUrl) {
30569
31248
  function fileOut(c) {
30570
31249
  return { file: c.file, action: c.action, summary: c.summary, added: c.added };
30571
31250
  }
30572
- function resultOf(status, d, session, changes, extra, apiBase) {
31251
+ function resultOf2(status, d, session, changes, extra, apiBase) {
30573
31252
  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;
31253
+ const next = session.next_action.type === "done" ? {
31254
+ type: "done",
31255
+ // 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".
31256
+ 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.",
31257
+ command: "checkbox plan --json"
31258
+ } : session.next_action;
30575
31259
  return {
30576
31260
  object: "checkbox_init",
30577
31261
  status,
@@ -30586,7 +31270,10 @@ function resultOf(status, d, session, changes, extra, apiBase) {
30586
31270
  install: session.install,
30587
31271
  stripe: { ...session.stripe, ...extra.stripe ?? {} },
30588
31272
  ...extra.live && Object.keys(extra.live).length ? { live: extra.live } : {},
30589
- compliance: session.compliance,
31273
+ // Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp): the CLI says
31274
+ // when, as evaluated_at (ISO 8601), next to the API's field.
31275
+ compliance: session.compliance ? withEvaluatedAt(session.compliance) : session.compliance,
31276
+ ...session.go_live ? { go_live: session.go_live } : {},
30590
31277
  steps: session.steps.map((s2) => ({ key: s2.key, status: s2.status, summary: s2.summary })),
30591
31278
  warnings: extra.warnings ?? [],
30592
31279
  owner_notified: session.owner_notified,
@@ -30609,6 +31296,10 @@ function renderSession(ctx, s2) {
30609
31296
  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
31297
  if (todo.length > 5) lines.push(c.dim(` \u2026 ${todo.length - 5} more: checkbox plan`));
30611
31298
  }
31299
+ if (s2.go_live?.steps.length) {
31300
+ 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"}`));
31301
+ s2.go_live.steps.forEach((st, i) => lines.push(` ${i + 1}. ${c.dim(st.who === "person" ? "[owner]" : "[agent]")} ${st.text}`));
31302
+ }
30612
31303
  lines.push("", `${c.cyan("next")} ${s2.next_action.message}${s2.next_action.command ? `
30613
31304
  ${s2.next_action.command}` : ""}`, `${c.dim("review")} ${s2.review_url}`);
30614
31305
  return lines;
@@ -30634,6 +31325,7 @@ var init_init = __esm({
30634
31325
  "use strict";
30635
31326
  init_args();
30636
31327
  init_config();
31328
+ init_src2();
30637
31329
  init_http();
30638
31330
  init_types2();
30639
31331
  init_detect();
@@ -30733,7 +31425,7 @@ var init_init = __esm({
30733
31425
  if (bool(flags, "verify")) {
30734
31426
  const v = await client2.sdk.setupSessions.verify(state.setup_session);
30735
31427
  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}`);
31428
+ 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
31429
  } else {
30738
31430
  session2 = await client2.sdk.setupSessions.retrieve(state.setup_session);
30739
31431
  }
@@ -30743,7 +31435,7 @@ var init_init = __esm({
30743
31435
  const configs = await syncCheckoutConfigs(client2, session2.site, d.checkoutSessions, root).catch((err) => [{ file: "", line: 0, skipped: err instanceof Error ? err.message : String(err) }]);
30744
31436
  if (configs.length) stripeOut.checkout_configs = configs;
30745
31437
  }
30746
- out.result(resultOf("verified", d, session2, [], Object.keys(stripeOut).length ? { stripe: stripeOut } : {}, apiBase), () => [...renderSession(ctx, session2), ...configLines(stripeOut.checkout_configs)]);
31438
+ out.result(resultOf2("verified", d, session2, [], Object.keys(stripeOut).length ? { stripe: stripeOut } : {}, apiBase), () => [...renderSession(ctx, session2), ...configLines(stripeOut.checkout_configs)]);
30747
31439
  return EXIT.ok;
30748
31440
  }
30749
31441
  if (dryRun && (!state.setup_session || !creds)) {
@@ -30772,10 +31464,11 @@ var init_init = __esm({
30772
31464
  let token = null;
30773
31465
  let apiKey = creds?.apiKey;
30774
31466
  let sessionId = state.setup_session;
30775
- const reuse = !!(state.setup_session && creds && bool(flags, "relogin") !== true);
31467
+ const pendingLogin = state.pending && Date.parse(state.pending.expires_at) > Date.now() + 1e4 && state.pending.api_base === apiBase ? state.pending : void 0;
31468
+ const reuse = !!(state.setup_session && creds && bool(flags, "relogin") !== true && !pendingLogin);
30776
31469
  if (!reuse) {
30777
31470
  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;
31471
+ let pending = pendingLogin;
30779
31472
  if (!pending) {
30780
31473
  const agent2 = detectAgent(ctx.env, agentName);
30781
31474
  const wantStripe = bool(flags, "stripe") !== false && d.billingPlatform === "stripe" && !!d.stripeTestKey;
@@ -30788,7 +31481,8 @@ var init_init = __esm({
30788
31481
  ...merchantName ? { merchant_name: merchantName.slice(0, 100) } : {},
30789
31482
  framework: d.framework,
30790
31483
  ...d.packageManager ? { package_manager: d.packageManager } : {},
30791
- billing_platform: d.billingPlatform,
31484
+ // "custom" means nothing was detected: never sent as a declared platform (the API then looks for Stripe itself).
31485
+ ...d.billingPlatform !== "custom" ? { billing_platform: d.billingPlatform } : {},
30792
31486
  ...bool(flags, "live") ? { live: true } : {},
30793
31487
  ...wantStripe ? { stripe_test_key_hint: d.stripeTestKey.hint } : {}
30794
31488
  });
@@ -30819,7 +31513,7 @@ var init_init = __esm({
30819
31513
  expires_at: pending.expires_at,
30820
31514
  next_action: {
30821
31515
  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"}).`,
31516
+ 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
31517
  command: "checkbox init --yes --json",
30824
31518
  url: pending.verification_uri_complete
30825
31519
  }
@@ -30962,9 +31656,12 @@ var init_init = __esm({
30962
31656
  }
30963
31657
  const live = {};
30964
31658
  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.");
31659
+ const liveKey = token?.live_secret_key ?? ctx.env.CHECKBOX_LIVE_SECRET_KEY ?? d.env.CHECKBOX_LIVE_SECRET_KEY ?? void 0;
31660
+ if (!liveKey || !/^ck_live_/.test(liveKey)) {
31661
+ const ask3 = liveKeyNextAction(session.review_url.replace(/\/setup\/[^/]*$/, ""));
31662
+ live.skipped = "no_live_key";
31663
+ live.next_action = ask3;
31664
+ 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
31665
  } else {
30969
31666
  try {
30970
31667
  const lc = ctx.client({ apiKey: liveKey, apiBase });
@@ -30978,7 +31675,7 @@ var init_init = __esm({
30978
31675
  changes.push(wk);
30979
31676
  }
30980
31677
  }
30981
- session = await client.sdk.setupSessions.update(sessionId, { live_site: liveSite.id });
31678
+ session = await lc.sdk.setupSessions.update(sessionId, { live_site: liveSite.id });
30982
31679
  saveState(ctx, root, { live_site: liveSite.id });
30983
31680
  live.site = liveSite.id;
30984
31681
  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 +31688,8 @@ var init_init = __esm({
30991
31688
  out.info("Running the first checks\u2026");
30992
31689
  session = (await client.sdk.setupSessions.verify(sessionId)).setup_session;
30993
31690
  }
30994
- const doc = resultOf(dryRun ? "dry_run" : "configured", d, session, changes, { stripe, live, warnings, manualSnippet, token, envNotWritten: envTracked && !!token }, apiBase);
31691
+ const doc = resultOf2(dryRun ? "dry_run" : "configured", d, session, changes, { stripe, live, warnings, manualSnippet, token, envNotWritten: envTracked && !!token }, apiBase);
31692
+ if (live.next_action && (session.next_action.type === "done" || session.next_action.type === "complete_setup")) doc.next_action = live.next_action;
30995
31693
  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
31694
  return EXIT.ok;
30997
31695
  }