@checkbox-site/cli 0.4.5 → 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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/checkbox.js +1126 -146
  3. package/package.json +16 -13
package/dist/checkbox.js CHANGED
@@ -3335,7 +3335,7 @@ var init_operations = __esm({
3335
3335
  /**
3336
3336
  * Register the Checkout Session params your code uses.
3337
3337
  *
3338
- * Registers exactly the object your code passes to stripe.checkout.sessions.create (customer, URLs and secrets are not needed and are ignored; `warnings` says what was not stored). It is judged exactly like checkStripeCheckoutSession, discounts included. A config with the same name, or for the same Price ids, replaces the older one (`replaced`). Test runs mirror it into Stripe test mode; when it matches your latest live checkout, the scanned test page counts as an observation of your live checkout. Re-register after changing your checkout code, then call createTestRun.
3338
+ * Registers exactly the object your code passes to stripe.checkout.sessions.create, as `params` (that object sent alone as the body is accepted too; customer, URLs and secrets are not needed and are ignored; `warnings` says what was not stored). It is judged exactly like checkStripeCheckoutSession, discounts included. A config with the same name, or for the same Price ids, replaces the older one (`replaced`). Test runs mirror it into Stripe test mode; when it matches your latest live checkout, the scanned test page counts as an observation of your live checkout. Re-register after changing your checkout code, then call createTestRun.
3339
3339
  *
3340
3340
  * @param site Unique identifier of the site (site_…).
3341
3341
  * @see POST /v1/sites/{site}/stripe_checkout_configs (operation `createStripeCheckoutConfig`, scope `test_runs:write`)
@@ -3617,7 +3617,7 @@ var SDK_VERSION;
3617
3617
  var init_version = __esm({
3618
3618
  "../sdk-node/src/version.ts"() {
3619
3619
  "use strict";
3620
- SDK_VERSION = "0.2.3";
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
  }),
@@ -11321,6 +11386,9 @@ function introAmountOf(amount, o) {
11321
11386
  return void 0;
11322
11387
  }
11323
11388
  function unverifiedReason(ctx) {
11389
+ const known = ctx.referencePlans ?? [];
11390
+ if (known.length) return `none of your plans (${known.slice(0, 4).map((p) => p.label ?? p.name ?? p.id).join(", ")}) was matched to this checkout's price (the Price id it sells is not one Checkbox has for them)`;
11391
+ if ((ctx.checkoutConfigs?.length ?? 0) > 0) return "the price this checkout sells is not in your registered Checkout configs or your billing platform's prices";
11324
11392
  const chosen = ctx.facts?.planPriceId;
11325
11393
  if (chosen && ctx.config) return `the chosen price ${chosen} is not among the active recurring prices read from your billing platform`;
11326
11394
  if (chosen) return `the chosen price ${chosen} could not be read (no billing platform connected)`;
@@ -11461,8 +11529,8 @@ function sameProductName(a, b) {
11461
11529
  function declaredPriceIds(x) {
11462
11530
  return [x.price_id, x.stripe_price, x.priceId, x.id].filter((v) => typeof v === "string" && v.trim().length > 0);
11463
11531
  }
11464
- function reconcileDeclaredPlans(raw, billing, platform = "Stripe") {
11465
- const prices = billing.filter((p) => p && p.amount > 0 && p.interval);
11532
+ function reconcileDeclaredPlans(raw, billing, platform = "Stripe", checkoutTrials) {
11533
+ const prices = billing.filter((p) => p && p.amount > 0 && p.interval).map((p) => checkoutTrials?.has(p.id) ? { ...p, trialDays: checkoutTrials.get(p.id) } : p);
11466
11534
  const out = [...raw];
11467
11535
  const conflicts = [];
11468
11536
  if (!prices.length || !raw.length) return { plans: out, conflicts };
@@ -12103,6 +12171,13 @@ function checkoutSessionsOf(config) {
12103
12171
  if (!Array.isArray(raw)) return [];
12104
12172
  return raw.filter((s2) => !!s2 && typeof s2 === "object" && typeof s2.id === "string" && Array.isArray(s2.lineItems));
12105
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
+ }
12106
12181
  function newestFirst(sessions) {
12107
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);
12108
12183
  }
@@ -14917,7 +14992,8 @@ function inAppPath(input) {
14917
14992
  return { kind: "in_app", state: "works", detail: "a test cancellation completed online" };
14918
14993
  }
14919
14994
  if (hostedStep2) return void 0;
14920
- const kinds = (flow.blockers ?? []).map(classifyBlocker);
14995
+ const onHosted = (flow.steps ?? []).some((s2) => s2.source !== "snippet" && isHostedCancelUrl(s2.url));
14996
+ const kinds = (flow.blockers ?? []).map(classifyBlocker).filter((k) => !(onHosted && k === "contact"));
14921
14997
  if (kinds.some((k) => k === "contact" || k === "not_found" || k === "obstacle")) {
14922
14998
  return { kind: "in_app", state: "broken", detail: `the test cancellation did not complete (${flow.blockers.join("; ")})` };
14923
14999
  }
@@ -14994,7 +15070,7 @@ function offlineOnlyCancellation(input) {
14994
15070
  const hosted = input.ctx.hostedCancel;
14995
15071
  const helpHits = [];
14996
15072
  for (const c of captures) {
14997
- if (isFetchedDocument(c) || c.pageProblem || c.pageKind === "checkout") continue;
15073
+ if (isFetchedDocument(c) || c.pageProblem || c.pageKind === "checkout" || isHostedCancelUrl(c.url)) continue;
14998
15074
  const sentence = contactOnlySentence(c.text);
14999
15075
  if (!sentence) continue;
15000
15076
  if (ownOnlineCancelControls(c, hosted).length) continue;
@@ -15005,7 +15081,7 @@ function offlineOnlyCancellation(input) {
15005
15081
  }
15006
15082
  return { where: `${c.pageKind === "account" ? "account page" : c.pageKind === "cancel" ? "cancel page" : "page"} ${c.url.split("#")[0]}`, quote: sentence, capture: c };
15007
15083
  }
15008
- const contactOnlyUrls = new Set(captures.filter((c) => !isFetchedDocument(c) && contactOnlySentence(c.text)).map((c) => stripUrl(c.url)));
15084
+ const contactOnlyUrls = new Set(captures.filter((c) => !isFetchedDocument(c) && !isHostedCancelUrl(c.url) && contactOnlySentence(c.text)).map((c) => stripUrl(c.url)));
15009
15085
  const onlineControl = captures.some(
15010
15086
  (c) => !isFetchedDocument(c) && ownOnlineCancelControls(c, hosted).some((el) => !contactOnlyUrls.has(stripUrl(hrefOf(el) ?? "", c.url)))
15011
15087
  );
@@ -16372,6 +16448,73 @@ var init_intro_terms = __esm({
16372
16448
  }
16373
16449
  });
16374
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
+
16375
16518
  // ../rules/src/checks/notice-content.ts
16376
16519
  function offlineOnlyCancelIn(text, cancelMethods) {
16377
16520
  const offline = contactOnlySentence(text);
@@ -16410,11 +16553,21 @@ function noticeContent(e, opts = {}) {
16410
16553
  ];
16411
16554
  const amount = a.recurringPrices[0]?.money ?? positiveAmounts(a)[0];
16412
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;
16413
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);
16414
16566
  else if (amount) quotes.amount = quote(amount);
16415
16567
  const freq = a.recurringPrices[0]?.frequency ?? a.frequencies[0];
16416
16568
  const freqs = [...a.recurringPrices.map((r) => r.frequency), ...a.frequencies].filter((f) => f.unit);
16417
- 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);
16418
16571
  const misses = (f) => !plans.some((p) => frequencyMatchesPlan(f.unit, f.count, p));
16419
16572
  const wrongCharged = plans.length ? charged.find(misses) : void 0;
16420
16573
  if (wrongCharged) wrong.frequency = quote(wrongCharged);
@@ -16481,6 +16634,7 @@ var init_notice_content = __esm({
16481
16634
  init_context();
16482
16635
  init_intro_terms();
16483
16636
  init_result();
16637
+ init_charge_claims();
16484
16638
  ELEMENT_LABELS = {
16485
16639
  amount: "the amount charged",
16486
16640
  frequency: "how often the subscription is charged",
@@ -16851,6 +17005,17 @@ function cancelBeforeTrialMatch(text) {
16851
17005
  if (byDate && TRIAL_ENDS_DATE_RE.test(text)) return byDate[0];
16852
17006
  return null;
16853
17007
  }
17008
+ function statedTrialMismatch(a, p, offers) {
17009
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
17010
+ if (!stated.length) return void 0;
17011
+ const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
17012
+ if (!own.length) return void 0;
17013
+ const fits = (days, trial) => days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
17014
+ if (stated.some((t) => own.some((o) => fits(t.days, o.trialDays)))) return void 0;
17015
+ const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
17016
+ const plan2 = p?.offer ? `${offerLabel(p.offer)} has a ${p.offer.trialDays}-day free trial` : `the plan${lengths.length > 1 ? "s give" : " gives"} a ${lengths.join("- or ")}-day free trial`;
17017
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
17018
+ }
16854
17019
  var ACK_MAX_DELAY_HOURS, NO_ACK_EMAIL_MESSAGE, ackCache, SIGNUP_LINK_TOLERANCE_MS, sent, terms, cancellationInstructions, CANCELLING_RE, CANCEL_BEFORE_TRIAL_RE, MONTH, DATE, CANCEL_BEFORE_NO_CHARGE_RE, CANCEL_BEFORE_DATE_RE, TRIAL_ENDS_DATE_RE, trialTerms, CONFIRMATION_CHECKS;
16855
17020
  var init_confirmation = __esm({
16856
17021
  "../rules/src/checks/confirmation.ts"() {
@@ -17160,6 +17325,15 @@ ${v.pageText}`);
17160
17325
  );
17161
17326
  const units = trialDocs.map((doc) => {
17162
17327
  const a = doc.analysis;
17328
+ const wrongTrial = statedTrialMismatch(a, ackPlan(doc.email, input, offers), offers);
17329
+ if (wrongTrial) {
17330
+ return docUnit(doc, {
17331
+ status: "fail",
17332
+ detail: `says ${wrongTrial.stated}, but ${wrongTrial.plan}: state the trial the subscriber really gets`,
17333
+ confidence: 0.8,
17334
+ meta: { trialLengthMismatch: true }
17335
+ });
17336
+ }
17163
17337
  const method = a.cancelMethod[0];
17164
17338
  const timing = a.trialLengths[0] ?? a.postTrialCharge[0];
17165
17339
  const beforeMatch = cancelBeforeTrialMatch(a.text);
@@ -19199,6 +19373,7 @@ var init_signup = __esm({
19199
19373
  init_offer_view();
19200
19374
  init_plan_terms();
19201
19375
  init_cancellation();
19376
+ init_charge_claims();
19202
19377
  init_intro_terms();
19203
19378
  init_intro_terms();
19204
19379
  init_result();
@@ -19364,6 +19539,29 @@ var init_signup = __esm({
19364
19539
  const pl = view.plan?.plan;
19365
19540
  return !!pl && hasIntro(pl) && r.money.amount !== null && Math.abs(r.money.amount - pl.introAmount / 10 ** currencyDigits(pl.currency || "usd")) < 5e-3;
19366
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
+ }
19367
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;
19368
19566
  if (foreign) {
19369
19567
  const own = parsed.find(recurringMatches) ?? rp;
@@ -22221,7 +22419,7 @@ function classifyRaw(input) {
22221
22419
  }
22222
22420
  }
22223
22421
  function fixFromFail(c) {
22224
- return withPlanTerms(contentFixLine(String(c.checkId), c.data) ?? contentFixFromText(String(c.checkId), c.detail) ?? introFixFromText(c) ?? planFrequencyFix(c) ?? FAIL_FIX[String(c.checkId)] ?? "Apply the change in the task for this check.", c.data);
22422
+ return withPlanTerms(contentFixLine(String(c.checkId), c.data) ?? contentFixFromText(String(c.checkId), c.detail) ?? introFixFromText(c) ?? trialLengthFixFromText(c) ?? planFrequencyFix(c) ?? FAIL_FIX[String(c.checkId)] ?? "Apply the change in the task for this check.", c.data);
22225
22423
  }
22226
22424
  function withPlanTerms(fix, data) {
22227
22425
  if (!/<(?:price|interval)>/.test(fix)) return fix;
@@ -22241,6 +22439,11 @@ function introFixFromText(c) {
22241
22439
  const m = /introductory price of (\S+?)(?: \(\d+% off\))? for (the first [^—,]+?)\s*(?:,|—)/i.exec(c.detail);
22242
22440
  return m ? `State the introductory price (${m[1]} for ${m[2]}) and when it ends, then the regular price from the next charge, in the renewal line.` : "State the introductory price and how long it lasts, then the regular price from the next charge, in the renewal line.";
22243
22441
  }
22442
+ function trialLengthFixFromText(c) {
22443
+ if (c.checkId !== "confirmation.email.trial_terms") return void 0;
22444
+ const m = /says “([^”]+)”, but .*?\b(?:has|gives?) a ([\d -]+?or [\d]+|\d+)-day free trial/.exec(c.detail);
22445
+ return m ? `The acknowledgment says \u201C${m[1]}\u201D, but the plan has a ${m[2]}-day free trial: state the trial the subscriber really gets, and how to cancel before it ends.` : void 0;
22446
+ }
22244
22447
  function planFrequencyFix(c) {
22245
22448
  if (c.checkId !== "signup.disclosure.frequency") return void 0;
22246
22449
  const every = /\bplans? (?:is|are) billed (every [a-z0-9 ]+?)(?= \/ | —|[.;,]|$)/i.exec(c.detail)?.[1];
@@ -22326,7 +22529,7 @@ function classifyFindings(findings) {
22326
22529
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22327
22530
  }));
22328
22531
  }
22329
- var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22532
+ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, RENEWAL_LINE_EXAMPLE, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22330
22533
  var init_status = __esm({
22331
22534
  "../rules/src/status.ts"() {
22332
22535
  "use strict";
@@ -22522,7 +22725,7 @@ var init_status = __esm({
22522
22725
  },
22523
22726
  { code: "receipt_without_terms", category: "content", when: has(/only a receipt without subscription terms/), build: () => ({ status: "needs_fix", fix: "Send an acknowledgment that restates the renewal terms (price, frequency, renews until cancelled) and how to cancel; a plain receipt is not enough." }) },
22524
22727
  // Audit P2: Stripe wording (custom_text) only for Stripe inputs; any other checkout gets the same line, platform-neutral.
22525
- { code: "processor_default_line", category: "content", when: has(/payment processor.s default line|authorization line is not the renewal terms|no automatic-renewal statement was found/), build: (c) => ({ status: "needs_fix", fix: withPlanTerms(`Add your own renewal line next to the pay button${isStripeInput(c) ? " (Stripe custom_text.submit)" : ""}: "Renews automatically every <interval> at <price> until you cancel. Cancel anytime with your cancel link."`, c.data) }) },
22728
+ { code: "processor_default_line", category: "content", when: has(/payment processor.s default line|authorization line is not the renewal terms|no automatic-renewal statement was found/), build: (c) => ({ status: "needs_fix", fix: withPlanTerms(`Add your own renewal line next to the pay button${isStripeInput(c) ? " (Stripe custom_text.submit)" : ""}: "${RENEWAL_LINE_EXAMPLE} Cancel anytime with your cancel link."`, c.data) }) },
22526
22729
  { code: "renewal_without_until_cancel", category: "content", when: has(/but not that it continues until the customer cancels/), build: () => ({ status: "needs_fix", fix: 'Add "until you cancel" to the renewal line.' }) },
22527
22730
  { code: "terms_hidden", category: "content", when: has(/collapsed <details>|\(nearly\) the colour of its background|nearly the colour of the background|hidden by a stylesheet|sr-only/), build: () => ({ status: "needs_fix", fix: "Show the renewal line as plain visible text next to the button (not collapsed, not faded into the background)." }) },
22528
22731
  // Pilot round 11 (change-guard): an intro / discounted price the Stripe session does not apply.
@@ -22985,8 +23188,9 @@ var init_status = __esm({
22985
23188
  PORTAL_OFFLINE_FIX = 'Turn on "Cancel subscriptions" in the Stripe customer portal and link it from your account page, then replace the "email / call us to cancel" line with "Cancel anytime online in your account".';
22986
23189
  PAYMENT_LINK_PORTAL_FIX = `Turn on "Cancel subscriptions" in the Stripe customer portal (Stripe Dashboard \u2192 Settings \u2192 Billing \u2192 Customer portal), copy its billing.stripe.com login link and add it to your site's footer as a "Cancel subscription" link.`;
22987
23190
  PAYMENT_LINK_OFFLINE_FIX = `${PAYMENT_LINK_PORTAL_FIX} Then replace the "email / call us to cancel" line with "Cancel anytime online" and that link.`;
23191
+ RENEWAL_LINE_EXAMPLE = "<price> every <interval> (plus applicable taxes). Renews automatically until you cancel.";
22988
23192
  FAIL_FIX = {
22989
- "signup.disclosure.autorenew_language": 'Add one line under the price, before the card and email fields: "Renews automatically every <interval> at <price> until you cancel."',
23193
+ "signup.disclosure.autorenew_language": `Add one line under the price, before the card and email fields: "${RENEWAL_LINE_EXAMPLE}"`,
22990
23194
  "signup.disclosure.price": "State the recurring price in the renewal line (the amount actually charged each time).",
22991
23195
  "signup.disclosure.frequency": `State how often it is charged, in the plan's own interval ("every month", "every year").`,
22992
23196
  "signup.disclosure.cancellation_policy": 'Add "Cancel anytime online" to the renewal line, with your cancel link.',
@@ -23835,6 +24039,10 @@ function fixTextsFor(t, style2) {
23835
24039
  if (style2 === "full") return fixTexts(t);
23836
24040
  return { disclosure: compactDisclosureText(t), consentLabel: COMPACT_CONSENT_LABEL, cta: compactCtaLabel(t) };
23837
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
+ }
23838
24046
  var COMPACT_CONSENT_LABEL;
23839
24047
  var init_compact = __esm({
23840
24048
  "../rules/src/fixes/compact.ts"() {
@@ -23851,9 +24059,10 @@ function shortDisclosureText(t) {
23851
24059
  const trial = !!t.trialDays && t.trialDays > 0;
23852
24060
  const intro = introPhrase(t);
23853
24061
  const cancel = `Cancel anytime online at ${t.cancelUrl}`;
23854
- 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.`;
23855
- if (intro) return `${intro[0].toUpperCase()}${intro.slice(1)}. Renews automatically until you cancel. ${cancel}.`;
23856
- 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}.`;
23857
24066
  }
23858
24067
  var init_short = __esm({
23859
24068
  "../rules/src/fixes/short.ts"() {
@@ -24565,6 +24774,45 @@ var init_stripe = __esm({
24565
24774
  }
24566
24775
  });
24567
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
+
24568
24816
  // ../rules/src/preflight/stripe.ts
24569
24817
  function clone(v) {
24570
24818
  return JSON.parse(JSON.stringify(v));
@@ -24957,10 +25205,6 @@ function correct(params, shape, checks, forLink, style2 = "full") {
24957
25205
  }
24958
25206
  return { patch, corrected: out, texts, needText, needConsent };
24959
25207
  }
24960
- function stripeCompactConsent(t) {
24961
- const trial = t.trialDays && t.trialDays > 0 ? ` after the ${t.trialDays}-day free trial` : "";
24962
- return `I agree that my subscription renews automatically at ${formatMoney(t.amount, t.currency)} every ${intervalPhrase(t.interval, t.intervalCount)}${trial} until I cancel.`;
24963
- }
24964
25208
  async function bestCorrection(params, shape, checks, forLink, judgeAfter) {
24965
25209
  const none = () => ({ fix: correct(params, shape, [], forLink), after: checks });
24966
25210
  const first2 = correct(params, shape, checks, forLink, "compact");
@@ -25041,6 +25285,12 @@ function withResolvedPrices(input, knownPrices, amountSource = "merchant.price")
25041
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" }]
25042
25286
  };
25043
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
+ }
25044
25294
  async function checkStripeCheckoutSession(input0, opts = {}) {
25045
25295
  const { jurisdictions, asOf, now } = common(input0, opts.now);
25046
25296
  const resolvedPrices = withResolvedPrices(input0, opts.knownPrices, opts.amountSource);
@@ -25056,7 +25306,8 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
25056
25306
  const shape = readParams(params, input.merchant, "session", asOf);
25057
25307
  const url = "https://checkout.stripe.com/c/pay/cs_test_preflight";
25058
25308
  const checks = await judge2(shape, url, jurisdictions, asOf, now, "preflight-stripe-before");
25059
- 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(
25060
25311
  params,
25061
25312
  shape,
25062
25313
  checks,
@@ -25071,12 +25322,12 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
25071
25322
  verification: { checks: d.checks, passes: d.passes, remaining_checks: d.remaining, note: HOSTED_STYLING_NOTE },
25072
25323
  placeholders
25073
25324
  };
25074
- const warnings = [...resolvedPrices.warnings, ...shape.warnings];
25325
+ const warnings = [...resolvedPrices.warnings, ...pairing ? [pairing] : [], ...shape.warnings];
25075
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.' });
25076
25327
  const channel2 = noticeChannelField(params);
25077
25328
  if (channel2) warnings.push(channel2);
25078
25329
  return {
25079
- ...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 }),
25080
25331
  kind: "stripe_checkout_session",
25081
25332
  correction
25082
25333
  };
@@ -25094,7 +25345,8 @@ async function checkStripePaymentLink(input0, opts = {}) {
25094
25345
  const id = typeof params.id === "string" ? params.id : void 0;
25095
25346
  const url = typeof params.url === "string" && /^https:\/\/buy\.stripe\.com\//.test(params.url) ? params.url : "https://buy.stripe.com/test_preflight";
25096
25347
  const checks = await judge2(shape, url, jurisdictions, asOf, now, "preflight-link-before");
25097
- 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(
25098
25350
  params,
25099
25351
  shape,
25100
25352
  checks,
@@ -25133,11 +25385,11 @@ async function checkStripePaymentLink(input0, opts = {}) {
25133
25385
  ...update_params ? { update_params } : {},
25134
25386
  needs_new_link_for_consent: needsNew
25135
25387
  };
25136
- const warnings = [...resolvedPrices.warnings, ...shape.warnings];
25388
+ const warnings = [...resolvedPrices.warnings, ...pairing ? [pairing] : [], ...shape.warnings];
25137
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.' });
25138
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).` : "";
25139
25391
  return {
25140
- ...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 }),
25141
25393
  kind: "stripe_payment_link",
25142
25394
  correction
25143
25395
  };
@@ -25216,7 +25468,7 @@ function noticeChannelField(params) {
25216
25468
  param: `params.custom_fields[${i}]`
25217
25469
  };
25218
25470
  }
25219
- 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;
25220
25472
  var init_stripe2 = __esm({
25221
25473
  "../rules/src/preflight/stripe.ts"() {
25222
25474
  "use strict";
@@ -25229,6 +25481,7 @@ var init_stripe2 = __esm({
25229
25481
  init_common();
25230
25482
  init_disclosure();
25231
25483
  init_model();
25484
+ init_price_hint();
25232
25485
  TEXT_CHECKS = [
25233
25486
  "signup.disclosure.autorenew_language",
25234
25487
  "signup.disclosure.price",
@@ -25247,10 +25500,31 @@ var init_stripe2 = __esm({
25247
25500
  STRIPE_GREY = { fontSizePx: 13, fontWeight: 400, color: "rgba(26, 26, 26, 0.7)", backgroundColor: "rgb(255, 255, 255)" };
25248
25501
  DAY_MS3 = 864e5;
25249
25502
  open = (c) => !!c && (c.status === "fail" || c.status === "needs_review");
25503
+ PAIRING_ACTION = (w) => ({ type: "human_review", message: `${w.message}` });
25250
25504
  STRIPE_HOSTED_STYLING_NOTE = HOSTED_STYLING_NOTE;
25251
25505
  }
25252
25506
  });
25253
25507
 
25508
+ // ../rules/src/preflight/stripe-verdict.ts
25509
+ function stripeParamsVerdict(r) {
25510
+ if (r.verdict === "not_applicable") return { verdict: "not_applicable", open: [] };
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));
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 };
25515
+ const coupon = (r.warnings ?? []).find((w) => w.code === "coupon_unverified");
25516
+ if (open2.length) return { verdict: "needs_review", open: open2, ...coupon ? { reason: coupon.message } : {} };
25517
+ if (coupon) return { verdict: "needs_review", open: [], reason: coupon.message };
25518
+ return { verdict: "pass", open: [] };
25519
+ }
25520
+ var SATISFIED_COMPLIANCE;
25521
+ var init_stripe_verdict = __esm({
25522
+ "../rules/src/preflight/stripe-verdict.ts"() {
25523
+ "use strict";
25524
+ SATISFIED_COMPLIANCE = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
25525
+ }
25526
+ });
25527
+
25254
25528
  // ../rules/src/preflight/email.ts
25255
25529
  function linkify(escaped) {
25256
25530
  return escaped.replace(/https?:\/\/[^\s<]+/g, (raw) => {
@@ -25434,6 +25708,10 @@ function contextFor(kind, occ, plan2, jurisdictions, asOf, email) {
25434
25708
  function longDate2(d) {
25435
25709
  return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
25436
25710
  }
25711
+ function supportContactLine(contact, opts = {}) {
25712
+ if (contact || opts.required) return `Questions? Reply to this email or contact us at ${contact || SUPPORT_CONTACT_PLACEHOLDER}.`;
25713
+ return "Questions? Reply to this email.";
25714
+ }
25437
25715
  function emailTemplate(kind, plan2, opts = {}) {
25438
25716
  const brand = plan2.merchantName ?? "";
25439
25717
  const sub = `${brand ? `${brand} ` : ""}subscription`;
@@ -25441,7 +25719,7 @@ function emailTemplate(kind, plan2, opts = {}) {
25441
25719
  const every = intervalPhrase(plan2.interval, plan2.intervalCount);
25442
25720
  const cancel = plan2.cancelUrl ?? CANCEL_URL_PLACEHOLDER;
25443
25721
  const effect = plan2.cancelTiming === "immediately" ? "cancellation takes effect immediately" : "cancellation takes effect at the end of your current billing period";
25444
- const contact = opts.supportContact ? `Questions? Contact us at ${opts.supportContact}.` : "Questions? Reply to this email.";
25722
+ const contact = supportContactLine(opts.supportContact);
25445
25723
  const lines = (...l) => l.filter(Boolean).join("\n\n");
25446
25724
  const intro = plan2.introAmount !== void 0 && plan2.introAmount < plan2.amount ? `${formatMoney(plan2.introAmount, plan2.currency)} for ${plan2.introPeriods && plan2.introPeriods > 1 ? `your first ${plan2.introPeriods} billing periods` : `your first ${every}`}` : "";
25447
25725
  const introLine = intro ? `Introductory price: ${intro}, then ${money3} every ${every}.` : "";
@@ -25449,11 +25727,13 @@ function emailTemplate(kind, plan2, opts = {}) {
25449
25727
  case "acknowledgment":
25450
25728
  return {
25451
25729
  subject: `Your ${sub} is confirmed`,
25730
+ // Round 31 (pilot 30 item 22): the same sentences, in the same order, as the "You send" acknowledgment template
25731
+ // (apps/api notices/merchant-templates.ts), so the plan, checkEmail and the due notice give one email.
25452
25732
  text: lines(
25453
- `Thanks for subscribing${brand ? ` to ${brand}` : ""}! This email confirms your subscription terms.`,
25454
- introLine,
25733
+ `Thanks for subscribing${brand ? ` to ${brand}` : ""}. This email confirms your subscription terms.`,
25455
25734
  plan2.trialDays ? `Your free trial lasts ${plan2.trialDays} days and ends on {{trial_end_date}}. To avoid being charged, cancel before your trial ends at ${cancel} \u2014 it takes less than a minute. If you do nothing, your subscription starts automatically on {{trial_end_date}} and you will be charged ${money3} every ${every}.` : "",
25456
- `Your ${sub} automatically renews every ${every} at ${money3} until you cancel. Your next charge is on {{next_charge_date}}.`,
25735
+ `Your ${sub} automatically renews every ${every} at ${money3} until you cancel. Your ${plan2.trialDays ? "first" : "next"} charge is on {{next_charge_date}}.`,
25736
+ introLine,
25457
25737
  `How to cancel: go to ${cancel} \u2014 you can cancel anytime online; ${effect}.`,
25458
25738
  contact
25459
25739
  ),
@@ -25509,7 +25789,7 @@ function emailTemplate(kind, plan2, opts = {}) {
25509
25789
  `To stop future charges, cancel online anytime at ${cancel}.`,
25510
25790
  // Pilot round 11 (invariant test): Georgia's charge notice must give the business's contact information;
25511
25791
  // "reply to this email" is not one, so without a support contact the template asks for it.
25512
- opts.supportContact ? contact : `Questions? Contact us at ${SUPPORT_CONTACT_PLACEHOLDER}.`
25792
+ supportContactLine(opts.supportContact, { required: true })
25513
25793
  ),
25514
25794
  // Round 12: the template used {{support_contact}} without listing it as a merge tag to fill.
25515
25795
  tags: ["{{charge_date}}", "{{next_charge_date}}", ...opts.supportContact ? [] : [SUPPORT_CONTACT_PLACEHOLDER]]
@@ -25632,7 +25912,8 @@ ${html ?? ""}`, kind);
25632
25912
  text: t.text,
25633
25913
  html: htmlOf(t.text),
25634
25914
  merge_tags: t.tags,
25635
- 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)),
25636
25917
  verified: remaining.length === 0,
25637
25918
  remaining_checks: remaining
25638
25919
  };
@@ -26050,6 +26331,7 @@ __export(preflight_exports, {
26050
26331
  PREFLIGHT_DISCLAIMER: () => PREFLIGHT_DISCLAIMER,
26051
26332
  PREFLIGHT_DOCS_URL: () => PREFLIGHT_DOCS_URL,
26052
26333
  PreflightInputError: () => PreflightInputError,
26334
+ SATISFIED_COMPLIANCE: () => SATISFIED_COMPLIANCE,
26053
26335
  STRIPE_HOSTED_STYLING_NOTE: () => STRIPE_HOSTED_STYLING_NOTE,
26054
26336
  checkDisclosure: () => checkDisclosure,
26055
26337
  checkEmail: () => checkEmail,
@@ -26059,6 +26341,7 @@ __export(preflight_exports, {
26059
26341
  checkoutSessionsOf: () => checkoutSessionsOf,
26060
26342
  emailTemplate: () => emailTemplate,
26061
26343
  extractStaticPage: () => extractStaticPage,
26344
+ intervalHintOf: () => intervalHintOf,
26062
26345
  lineItemPriceIds: () => lineItemPriceIds,
26063
26346
  modelCheckoutConfig: () => modelCheckoutConfig,
26064
26347
  modelContext: () => modelContext,
@@ -26066,12 +26349,17 @@ __export(preflight_exports, {
26066
26349
  parseHtml: () => parseHtml,
26067
26350
  parseJurisdictionList: () => parseJurisdictionList,
26068
26351
  paymentLinksOf: () => paymentLinksOf,
26352
+ priceIntervalConflict: () => priceIntervalConflict,
26069
26353
  resolvePlan: () => resolvePlan,
26070
26354
  resolvePriceIds: () => resolvePriceIds,
26071
26355
  runChecks: () => runChecks,
26072
26356
  shapeChecks: () => shapeChecks,
26073
26357
  signupStripeObjects: () => signupStripeObjects,
26074
- withIntroOffers: () => withIntroOffers
26358
+ stripeParamsVerdict: () => stripeParamsVerdict,
26359
+ supportContactLine: () => supportContactLine,
26360
+ testRunSessionIds: () => testRunSessionIds,
26361
+ withIntroOffers: () => withIntroOffers,
26362
+ withTestRunSessions: () => withTestRunSessions
26075
26363
  });
26076
26364
  var init_preflight = __esm({
26077
26365
  "../rules/src/preflight/index.ts"() {
@@ -26079,6 +26367,8 @@ var init_preflight = __esm({
26079
26367
  init_common();
26080
26368
  init_disclosure();
26081
26369
  init_stripe2();
26370
+ init_price_hint();
26371
+ init_stripe_verdict();
26082
26372
  init_email();
26083
26373
  init_html_page();
26084
26374
  init_page();
@@ -26263,7 +26553,8 @@ var init_check = __esm({
26263
26553
  }
26264
26554
  const hf = str(f, "html-file");
26265
26555
  if (hf) html = await readInput(ctx, hf);
26266
- 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>)');
26267
26558
  if (!text && !html) throw new UsageError("Give the email body with --text, --file or --html-file");
26268
26559
  const terms2 = planFrom(f);
26269
26560
  const pp = str(f, "previous-price");
@@ -26425,7 +26716,7 @@ function loadConfig(path) {
26425
26716
  function validateConfig(value) {
26426
26717
  const is = new Issues();
26427
26718
  if (!isObj2(value)) return ["must be a JSON object (see https://checkbox.site/docs/guides/ci#config)"];
26428
- is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "notices", "checks", "coverage"]);
26719
+ is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "notices", "merge_tags", "checks", "coverage"]);
26429
26720
  const cov = value.coverage;
26430
26721
  if (cov !== void 0 && cov !== false) {
26431
26722
  if (!isObj2(cov)) is.add("coverage", `must be false or an object like { "ignore": ["scripts/**"] } (got ${show(cov)})`);
@@ -26493,6 +26784,15 @@ function validateConfig(value) {
26493
26784
  }
26494
26785
  }
26495
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);
26496
26796
  const checks = value.checks;
26497
26797
  if (!isObj2(checks)) {
26498
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)})`);
@@ -26577,7 +26877,8 @@ function validateConfig(value) {
26577
26877
  entries(
26578
26878
  "emails",
26579
26879
  (at, e) => {
26580
- 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);
26581
26882
  is.oneOf(`${at}.kind`, e.kind, EMAIL_KINDS2, true);
26582
26883
  is.exactlyOne(at, e, ["file", "text", "html", "module"], [["text", "html"]]);
26583
26884
  if (e.file !== void 0) {
@@ -26610,7 +26911,7 @@ function entryLine(raw, name) {
26610
26911
  const i = lines.findIndex((l) => needle.test(l));
26611
26912
  return i >= 0 ? i + 1 : 1;
26612
26913
  }
26613
- 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;
26614
26915
  var init_config3 = __esm({
26615
26916
  "src/ci/config.ts"() {
26616
26917
  "use strict";
@@ -26619,6 +26920,7 @@ var init_config3 = __esm({
26619
26920
  INTERVALS4 = ["day", "week", "month", "year"];
26620
26921
  EMAIL_KINDS2 = ["acknowledgment", "trial_reminder", "renewal_reminder", "price_change", "annual_terms", "charge_notice", "cancellation_confirmation"];
26621
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"];
26622
26924
  ConfigError = class extends Error {
26623
26925
  constructor(file, issues) {
26624
26926
  super(`${file}: ${issues.length === 1 ? issues[0] : `${issues.length} problems:
@@ -26699,8 +27001,8 @@ var init_config3 = __esm({
26699
27001
  });
26700
27002
 
26701
27003
  // src/commands/init/literal.ts
26702
- function parseObjectLiteral(source, env2 = {}) {
26703
- const p = new Parser(source, env2);
27004
+ function parseObjectLiteral(source, env2 = {}, consts = {}) {
27005
+ const p = new Parser(source, env2, consts);
26704
27006
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
26705
27007
  const v = p.object("");
26706
27008
  return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
@@ -26710,10 +27012,11 @@ var init_literal = __esm({
26710
27012
  "src/commands/init/literal.ts"() {
26711
27013
  "use strict";
26712
27014
  UNKNOWN = Symbol("unknown");
26713
- Parser = class {
26714
- constructor(s2, env2) {
27015
+ Parser = class _Parser {
27016
+ constructor(s2, env2, consts = {}) {
26715
27017
  this.s = s2;
26716
27018
  this.env = env2;
27019
+ this.consts = consts;
26717
27020
  }
26718
27021
  i = 0;
26719
27022
  unresolved = [];
@@ -26803,6 +27106,12 @@ var init_literal = __esm({
26803
27106
  if (name === "false") return false;
26804
27107
  if (name === "null") return null;
26805
27108
  if (name === "undefined") return void 0;
27109
+ if (Object.prototype.hasOwnProperty.call(this.consts, name)) {
27110
+ const sub = new _Parser(this.consts[name], this.env);
27111
+ const v = sub.value("");
27112
+ if (v !== UNKNOWN && v !== void 0 && path) this.fromEnv[path] = name;
27113
+ return v;
27114
+ }
26806
27115
  const env2 = /^(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
26807
27116
  if (env2) {
26808
27117
  if (this.env[env2[1]] !== void 0 && path) this.fromEnv[path] = name;
@@ -26965,6 +27274,12 @@ function detectBrand(root, layoutFile, pkgName) {
26965
27274
  }
26966
27275
  return void 0;
26967
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
+ }
26968
27283
  function readJson(path) {
26969
27284
  try {
26970
27285
  return JSON.parse(readFileSync5(path, "utf8"));
@@ -27024,12 +27339,49 @@ function walk2(root, visit) {
27024
27339
  }
27025
27340
  }
27026
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
+ }
27027
27376
  function detectFramework(root, deps) {
27028
27377
  const has2 = (d) => d in deps;
27029
27378
  if (existsSync4(join4(root, "layout", "theme.liquid")) && existsSync4(join4(root, "config", "settings_schema.json"))) return { framework: "shopify-theme", layoutFile: "layout/theme.liquid" };
27030
27379
  if (has2("next")) {
27031
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"]);
27032
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"}` };
27033
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"]);
27034
27386
  const pagesDir = existsSync4(join4(root, "src", "pages")) ? "src/pages" : "pages";
27035
27387
  return { framework: "nextjs-pages", layoutFile: doc ?? `${pagesDir}/_document.${existsSync4(join4(root, "tsconfig.json")) ? "tsx" : "js"}` };
@@ -27116,11 +27468,11 @@ function routeOf(rel2) {
27116
27468
  }
27117
27469
  return null;
27118
27470
  }
27119
- function findStripeKey(env2, processEnv) {
27471
+ function findStripeKey(env2, processEnv, envFile = {}) {
27120
27472
  const sources = [];
27121
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"]) {
27122
27474
  sources.push([`${k} (environment)`, processEnv[k]]);
27123
- sources.push([`${k} (.env)`, env2[k]]);
27475
+ sources.push([`${k} (${envFile[k] ?? ".env"})`, env2[k]]);
27124
27476
  }
27125
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()]);
27126
27478
  const hint = (key) => `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
@@ -27147,7 +27499,13 @@ function sessionLiteral(text, index) {
27147
27499
  else if (ch === quote2) quote2 = null;
27148
27500
  continue;
27149
27501
  }
27150
- if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27502
+ if (ch === "/" && text[i + 1] === "/") {
27503
+ const nl = text.indexOf("\n", i);
27504
+ i = nl === -1 ? text.length : nl;
27505
+ } else if (ch === "/" && text[i + 1] === "*") {
27506
+ const end = text.indexOf("*/", i + 2);
27507
+ i = end === -1 ? text.length : end + 1;
27508
+ } else if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27151
27509
  else if (ch === "{") depth++;
27152
27510
  else if (ch === "}") {
27153
27511
  depth--;
@@ -27156,17 +27514,73 @@ function sessionLiteral(text, index) {
27156
27514
  }
27157
27515
  return null;
27158
27516
  }
27517
+ function declaredExpr(text, from) {
27518
+ let depth = 0;
27519
+ let quote2 = null;
27520
+ for (let i = from; i < text.length && i - from < 2e3; i++) {
27521
+ const ch = text[i];
27522
+ if (quote2) {
27523
+ if (ch === "\\") i++;
27524
+ else if (ch === quote2) quote2 = null;
27525
+ continue;
27526
+ }
27527
+ if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27528
+ else if (ch === "(" || ch === "[" || ch === "{") depth++;
27529
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
27530
+ else if ((ch === ";" || ch === "\n") && depth <= 0) return text.slice(from, i).trim();
27531
+ }
27532
+ return text.slice(from, from + 2e3).trim();
27533
+ }
27534
+ function constDeclarations(text, exportedOnly) {
27535
+ const out = {};
27536
+ const re = exportedOnly ? /(?:^|\n)[ \t]*export\s+const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g : /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g;
27537
+ for (const m of text.matchAll(re)) if (!(m[1] in out)) out[m[1]] = declaredExpr(text, m.index + m[0].length);
27538
+ return out;
27539
+ }
27540
+ function resolveImport(root, fromFile, spec) {
27541
+ const dir = fromFile.includes("/") ? fromFile.slice(0, fromFile.lastIndexOf("/")) : "";
27542
+ const bases = spec.startsWith(".") ? [join4(dir, spec)] : /^[@~]\//.test(spec) ? [spec.slice(2), join4("src", spec.slice(2))] : [];
27543
+ for (const b of bases) {
27544
+ const base = b.split("\\").join("/").replace(/\.(?:[cm]?js|jsx)$/, "");
27545
+ for (const f of [b, ...[".ts", ".tsx", ".js", ".mjs", ".jsx", "/index.ts", "/index.js"].map((e) => `${base}${e}`)]) {
27546
+ if (/\.[cm]?[jt]sx?$/.test(f) && existsSync4(join4(root, f))) return f.split("\\").join("/");
27547
+ }
27548
+ }
27549
+ return void 0;
27550
+ }
27551
+ function constantsFor(root, relFile, text) {
27552
+ const out = {};
27553
+ for (const m of text.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
27554
+ const target = resolveImport(root, relFile, m[2]);
27555
+ if (!target) continue;
27556
+ let exported;
27557
+ try {
27558
+ exported = constDeclarations(readFileSync5(join4(root, target), "utf8"), true);
27559
+ } catch {
27560
+ continue;
27561
+ }
27562
+ for (const part of m[1].split(",")) {
27563
+ const [name, alias] = part.trim().split(/\s+as\s+/).map((x) => x.trim());
27564
+ if (name && exported[name] !== void 0) out[alias || name] = exported[name];
27565
+ }
27566
+ }
27567
+ return { ...out, ...constDeclarations(text, false) };
27568
+ }
27159
27569
  function detectProject(root, processEnv = {}) {
27160
27570
  const pkg = readJson(join4(root, "package.json"));
27161
27571
  const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
27162
27572
  const { framework, layoutFile } = detectFramework(root, deps);
27163
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;
27164
27574
  const env2 = {};
27575
+ const envFile = {};
27165
27576
  for (const f of [".env", ".env.production", ".env.local", ".env.development", ".env.development.local"]) {
27166
27577
  const t = readText(join4(root, f));
27167
- 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;
27168
27582
  }
27169
- 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";
27170
27584
  const checkoutCode = [];
27171
27585
  const payButtons = [];
27172
27586
  const checkoutSessions = [];
@@ -27192,12 +27606,13 @@ function detectProject(root, processEnv = {}) {
27192
27606
  checkoutSessions.push({ file: rel2, line: line2, unresolved: ["<params are not an object literal>"] });
27193
27607
  continue;
27194
27608
  }
27195
- const parsed = parseObjectLiteral(lit, { ...env2, ...Object.fromEntries(Object.entries(processEnv).filter((e) => typeof e[1] === "string" && /^(STRIPE_|NEXT_PUBLIC_STRIPE_|PRICE_)/.test(e[0]))) });
27609
+ const parsed = parseObjectLiteral(lit, { ...env2, ...Object.fromEntries(Object.entries(processEnv).filter((e) => typeof e[1] === "string" && /^(STRIPE_|NEXT_PUBLIC_STRIPE_|PRICE_)/.test(e[0]))) }, constantsFor(root, rel2, text));
27196
27610
  checkoutSessions.push({ file: rel2, line: line2, ...parsed.value ? { params: redactSecretsDeep(parsed.value) } : {}, unresolved: parsed.unresolved });
27197
27611
  }
27198
27612
  });
27613
+ if (billingPlatform === "custom" && checkoutCode.some((c) => c.kind.startsWith("stripe_"))) billingPlatform = "stripe";
27199
27614
  const site = detectSiteUrl(root, env2, pkg);
27200
- const stripeKey = findStripeKey(env2, processEnv);
27615
+ const stripeKey = findStripeKey(env2, processEnv, envFile);
27201
27616
  const brand = detectBrand(root, layoutFile, typeof pkg?.name === "string" ? pkg.name : void 0);
27202
27617
  return {
27203
27618
  root,
@@ -27218,16 +27633,18 @@ function detectProject(root, processEnv = {}) {
27218
27633
  ...stripeKey.refused ? { stripeKeyRefused: stripeKey.refused } : {},
27219
27634
  ...brand ? { brand } : {},
27220
27635
  plans: plansFromSessions(checkoutSessions),
27221
- env: env2
27636
+ env: env2,
27637
+ mailer: detectMailer(deps, framework, !!pkg)
27222
27638
  };
27223
27639
  }
27224
- 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;
27225
27641
  var init_detect = __esm({
27226
27642
  "src/commands/init/detect.ts"() {
27227
27643
  "use strict";
27228
27644
  init_src2();
27229
27645
  init_literal();
27230
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"];
27231
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__"]);
27232
27649
  CODE_EXT = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|liquid|html|ejs|hbs|erb)$/i;
27233
27650
  MAX_FILES = 4e3;
@@ -27243,6 +27660,7 @@ var init_detect = __esm({
27243
27660
  CHECKOUT_PATTERNS = [
27244
27661
  { kind: "stripe_checkout_session", re: /checkout\.sessions\.create\s*\(/ },
27245
27662
  { kind: "stripe_payment_link", re: /paymentLinks\.create\s*\(|https:\/\/buy\.stripe\.com\// },
27663
+ { kind: "stripe_checkout_link", re: /https:\/\/checkout\.stripe\.com\// },
27246
27664
  { kind: "stripe_pricing_table", re: /<stripe-pricing-table\b/ },
27247
27665
  { kind: "stripe_subscription_api", re: /subscriptions\.create\s*\(/ },
27248
27666
  { kind: "stripe_elements", re: /<(PaymentElement|CardElement|ExpressCheckoutElement)\b|elements\.create\(\s*['"]payment['"]/ },
@@ -27257,6 +27675,47 @@ var init_detect = __esm({
27257
27675
  // src/ci/scaffold.ts
27258
27676
  import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
27259
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
+ }
27260
27719
  function walk3(root, visit) {
27261
27720
  let n = 0;
27262
27721
  const go = (dir, depth) => {
@@ -27306,7 +27765,8 @@ function plansFromLiterals(text, file) {
27306
27765
  const trial = /\btrial(?:_period)?_?days\s*:\s*(\d+)/i.exec(body)?.[1];
27307
27766
  if (out.some((p) => p.name === name)) continue;
27308
27767
  const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
27309
- 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 });
27310
27770
  }
27311
27771
  return out;
27312
27772
  }
@@ -27330,10 +27790,111 @@ function paramsBuilder(root, file, line2) {
27330
27790
  } else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
27331
27791
  return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
27332
27792
  }
27793
+ function plansSoldBy(params, plans) {
27794
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
27795
+ const out = [];
27796
+ for (const li of items) {
27797
+ const pd = li?.price_data;
27798
+ if (!pd || typeof pd.unit_amount !== "number") continue;
27799
+ const match = plans.find((p) => (typeof p.amount === "number" ? p.amount : typeof p.price === "number" ? Math.round(p.price * 100) : NaN) === pd.unit_amount && p.interval === pd.recurring?.interval);
27800
+ if (match && !out.includes(match)) out.push(match);
27801
+ }
27802
+ return out;
27803
+ }
27804
+ function cancelUrlInCode(sessions) {
27805
+ const urls = /* @__PURE__ */ new Set();
27806
+ for (const s2 of sessions) {
27807
+ const text = JSON.stringify(s2.params?.custom_text ?? "");
27808
+ for (const m of text.matchAll(/cancel[^"]{0,80}?(https:\/\/[^\s"'<>)]+?)(?=[.,;:!?]?(?:[\s"'<>)]|$))/gi)) urls.add(m[1]);
27809
+ }
27810
+ return urls.size === 1 ? [...urls][0] : void 0;
27811
+ }
27812
+ function pageSource(root, built) {
27813
+ const route = /(?:^|\/)(?:public|dist|out|build|static|_site|www)\/(?:(.*)\/)?([^/]+?)(?:\/index)?\.html?$/i.exec(built);
27814
+ if (!route) return void 0;
27815
+ const path = [route[1], route[2]].filter(Boolean).join("/");
27816
+ const bases = [`app/${path}/page`, `src/app/${path}/page`, `app/(marketing)/${path}/page`, `pages/${path}`, `src/pages/${path}`, `src/routes/${path}/+page`, `pages/${path}/index`, `src/pages/${path}/index`];
27817
+ const hits = bases.flatMap((b) => [".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".astro", ".mdx"].map((e) => `${b}${e}`)).filter((f) => existsSync5(join5(root, f)));
27818
+ return hits.length === 1 ? hits[0] : void 0;
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
+ }
27333
27893
  function scaffoldConfig(root, env2 = {}, facts = {}) {
27334
27894
  const det = detectProject(root, env2);
27335
27895
  const todos = [];
27336
27896
  const found = [];
27897
+ const notes2 = [];
27337
27898
  const names = /* @__PURE__ */ new Set();
27338
27899
  const merchant = {};
27339
27900
  const fact = (k) => typeof facts[k] === "string" && facts[k].trim() ? facts[k].trim() : void 0;
@@ -27348,13 +27909,34 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27348
27909
  merchant.cancel_url = cancelFact;
27349
27910
  found.push(`cancel URL ${cancelFact} (from checkbox init)`);
27350
27911
  } else if (siteUrl && cancelPath) merchant.cancel_url = `${originOf(siteUrl)}${cancelPath}`;
27351
- else {
27912
+ else if (cancelUrlInCode(det.checkoutSessions)) {
27913
+ merchant.cancel_url = cancelUrlInCode(det.checkoutSessions);
27914
+ found.push(`cancel URL ${String(merchant.cancel_url)} (from the Checkout custom_text)`);
27915
+ } else {
27352
27916
  merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
27353
27917
  todos.push("merchant.cancel_url: the page where subscribers cancel online");
27354
27918
  }
27355
27919
  if (fact("support_email")) merchant.support_contact = fact("support_email");
27920
+ const initPlans = Array.isArray(facts.plans) ? facts.plans : [];
27921
+ const initName = (p) => {
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));
27923
+ return m ? String(m.name) : void 0;
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));
27356
27938
  const plans = det.plans.map((p, i) => ({
27357
- name: p.name ?? `Plan ${i + 1}`,
27939
+ name: p.name ?? initName(p) ?? literalFor(p)?.name ?? `Plan ${i + 1}`,
27358
27940
  amount: p.amount,
27359
27941
  currency: p.currency,
27360
27942
  interval: p.interval,
@@ -27362,39 +27944,41 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27362
27944
  ...p.trial_days ? { trial_days: p.trial_days } : {}
27363
27945
  }));
27364
27946
  for (const p of det.plans) found.push(`plan ${p.name ?? ""} ${p.amount / 100} ${p.currency}/${p.interval} (${p.source})`.replace(/\s+/g, " "));
27365
- const planKeys = /* @__PURE__ */ new Map();
27366
27947
  if (!plans.length && Array.isArray(facts.plans)) {
27367
27948
  for (const [i, p] of facts.plans.entries()) {
27368
27949
  if (typeof p.amount !== "number" || typeof p.interval !== "string") continue;
27369
- 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 } : {} });
27370
27951
  found.push(`plan ${String(plans[plans.length - 1].name)} (from checkbox init --plan)`);
27371
27952
  }
27372
27953
  }
27373
27954
  if (!plans.length) {
27374
- walk3(root, (rel2) => {
27375
- if (!PLAN_FILE.test(rel2) || plans.length >= 8) return;
27376
- let text = "";
27377
- try {
27378
- text = readFileSync6(join5(root, rel2), "utf8");
27379
- } catch {
27380
- return;
27381
- }
27382
- for (const p of plansFromLiterals(text, rel2)) {
27383
- if (plans.some((x) => x.name === p.name)) continue;
27384
- const { source, key, ...plan2 } = p;
27385
- plans.push(plan2);
27386
- if (key) planKeys.set(p.name, key);
27387
- found.push(`plan ${p.name} ${p.price} ${p.currency}/${p.interval} (${source})`);
27388
- }
27389
- });
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]);
27390
27969
  }
27391
27970
  if (!plans.length) {
27392
27971
  plans.push({ name: "TODO: plan name", price: "TODO: e.g. 19.99", currency: "usd", interval: "month" });
27393
27972
  todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
27394
27973
  }
27395
27974
  const planNames = plans.map((p) => String(p.name));
27396
- const planFor = () => plans.length > 1 ? { plan: `TODO: one of ${planNames.map((n) => JSON.stringify(n)).join(", ")}` } : {};
27397
- if (plans.length > 1) todos.push("plan: pick the plan of each disclosure, page and email entry");
27975
+ const oneOf = planNames.map((n) => JSON.stringify(n)).join(", ");
27976
+ let planTodo = false;
27977
+ const planFor = (kind = "entry") => {
27978
+ if (plans.length <= 1) return {};
27979
+ planTodo = true;
27980
+ return { plan: kind === "page" ? `TODO: "all" (the page shows every plan) or one of ${oneOf}` : `TODO: one of ${oneOf}` };
27981
+ };
27398
27982
  const stripe = [];
27399
27983
  const perFile = /* @__PURE__ */ new Map();
27400
27984
  for (const s2 of det.checkoutSessions) {
@@ -27425,7 +28009,21 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27425
28009
  } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
27426
28010
  else found.push(`${s2.file}:${s2.line}: Checkout Session`);
27427
28011
  entry.strict = true;
27428
- stripe.push(entry);
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) {
28021
+ for (const p of plans) stripe.push({ ...entry, name: unique(names, `${String(entry.name).replace(/\)$/, "")}: ${String(p.name)})`), plan: String(p.name) });
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})` : ""}`);
28026
+ } else stripe.push(entry);
27429
28027
  }
27430
28028
  const emails = [];
27431
28029
  const pages = [];
@@ -27434,18 +28032,29 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27434
28032
  if (IN_EMAIL_DIR.test(rel2)) {
27435
28033
  const kind = emailKindOf(rel2);
27436
28034
  if (/\.(?:html?|txt|md)$/i.test(rel2) && kind) {
27437
- emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...planFor() });
27438
- 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")` : ""}`);
27439
28047
  return;
27440
28048
  }
27441
28049
  if (/\.[jt]sx$|\.mjml$/i.test(rel2) && kind) {
27442
- 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)`);
27443
28051
  return;
27444
28052
  }
27445
28053
  }
27446
28054
  if (PAGE_FILE.test(rel2)) {
27447
28055
  const kind = /checkout|subscribe/i.test(rel2) ? "checkout" : "pricing";
27448
- pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor() });
28056
+ const src = pageSource(root, rel2);
28057
+ pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor("page"), ...src ? { source: src } : {} });
27449
28058
  found.push(`${rel2}: ${kind} page`);
27450
28059
  return;
27451
28060
  }
@@ -27462,9 +28071,24 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27462
28071
  }
27463
28072
  }
27464
28073
  });
27465
- if (!disclosures.length && det.payButtons.length) {
27466
- 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`);
27467
28090
  }
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)');
27468
28092
  const checks = {};
27469
28093
  if (stripe.length) checks.stripe_checkout = stripe;
27470
28094
  if (disclosures.length) checks.disclosures = disclosures;
@@ -27472,20 +28096,24 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27472
28096
  if (emails.length) checks.emails = emails;
27473
28097
  checks.notice_schedule = true;
27474
28098
  if (!stripe.length && !disclosures.length && !pages.length && !emails.length) {
27475
- 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)');
27476
28100
  }
27477
28101
  return {
27478
28102
  config: { $schema: SCHEMA_URL, merchant, jurisdictions: "all", plans, checks },
27479
28103
  todos,
27480
- found
28104
+ found,
28105
+ notes: notes2,
28106
+ todoValues: countTodoValues({ merchant, plans, checks })
27481
28107
  };
27482
28108
  }
27483
- 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;
27484
28110
  var init_scaffold = __esm({
27485
28111
  "src/ci/scaffold.ts"() {
27486
28112
  "use strict";
27487
28113
  init_detect();
28114
+ init_preflight();
27488
28115
  init_config3();
28116
+ PAGE_ROUTE = /^(pricing|checkout|subscribe|plans)$/i;
27489
28117
  IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify", ".turbo", ".cache", "coverage", ".astro", "vendor", ".venv", "__pycache__", ".github"]);
27490
28118
  MAX_FILES2 = 5e3;
27491
28119
  EMAIL_NAMES = [
@@ -27737,7 +28365,7 @@ function moduleErrorMessage(stderr, dir, flags = []) {
27737
28365
  const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
27738
28366
  return first2.replace(/file:\/\/\S+/g, (u) => rel2(u)).replace(/\s+/g, " ").slice(0, 400);
27739
28367
  }
27740
- function paramsFromSource(text, file, call = 1, env2 = {}) {
28368
+ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
27741
28369
  const calls = [...text.matchAll(/checkout\.sessions\.create\s*\(/g)];
27742
28370
  if (!calls.length) throw new InputError(`${file} has no stripe.checkout.sessions.create( call`);
27743
28371
  const m = calls[call - 1];
@@ -27745,7 +28373,7 @@ function paramsFromSource(text, file, call = 1, env2 = {}) {
27745
28373
  const line2 = text.slice(0, m.index).split("\n").length;
27746
28374
  const lit = sessionLiteral(text, m.index + m[0].length);
27747
28375
  if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
27748
- const parsed = parseObjectLiteral(lit, env2);
28376
+ const parsed = parseObjectLiteral(lit, env2, consts);
27749
28377
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
27750
28378
  return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
27751
28379
  }
@@ -27849,7 +28477,8 @@ async function stripeJob(loaded, e, o, root) {
27849
28477
  } else {
27850
28478
  const src = e.source;
27851
28479
  job.inputs.push(src);
27852
- const r = paramsFromSource(readRel(dir, src), src, e.call ?? 1, projectEnv(dir));
28480
+ const srcText = readRel(dir, src);
28481
+ const r = paramsFromSource(srcText, src, e.call ?? 1, projectEnv(dir), constantsFor(dir, src, srcText));
27853
28482
  job.target = { file: rel(loaded, root, src), line: r.line };
27854
28483
  job.fromSource = true;
27855
28484
  params = r.params;
@@ -27861,9 +28490,16 @@ async function stripeJob(loaded, e, o, root) {
27861
28490
  }
27862
28491
  }
27863
28492
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
28493
+ const items0 = Array.isArray(params.line_items) ? params.line_items : [];
28494
+ if (!e.plan && (config.plans?.length ?? 0) > 1 && !items0.some((li) => li && typeof li === "object" && li.price_data)) {
28495
+ throw new InputError(`"${e.name}" does not say which plan it sells, and its price is a Price id or an expression Checkbox cannot read: add "plan": one of ${(config.plans ?? []).map((p) => JSON.stringify(p.name)).join(", ")} to this entry (one entry per plan when one call serves several plans).`);
28496
+ }
27864
28497
  const merchant = planTerms(config, pickPlan(config, e.plan));
27865
28498
  const drift = planDrift(params, pickPlan(config, e.plan));
27866
28499
  if (drift) job.warnings.push(drift);
28500
+ const soldPlan = pickPlan(config, e.plan);
28501
+ const overrides = soldPlan ? planAsSold(params, soldPlan) : void 0;
28502
+ if (soldPlan && overrides) job.sold = { plan: soldPlan.name, overrides };
27867
28503
  const items = Array.isArray(params.line_items) ? params.line_items : [];
27868
28504
  if (items.length && items.every((li) => li && typeof li === "object" && li.price_data)) for (const k of ["price", "amount", "currency", "interval", "interval_count"]) delete merchant[k];
27869
28505
  job.body = { params, ...Object.keys(merchant).length ? { merchant } : {}, ...jurisdictionsOf(config) };
@@ -27872,6 +28508,29 @@ async function stripeJob(loaded, e, o, root) {
27872
28508
  }
27873
28509
  return job;
27874
28510
  }
28511
+ function planAsSold(params, plan2) {
28512
+ const sd = params.subscription_data ?? {};
28513
+ const trial = typeof sd.trial_period_days === "number" ? sd.trial_period_days : void 0;
28514
+ const out = {};
28515
+ if (trial !== void 0 && trial !== (plan2.trial_days ?? 0)) out.trial_days = trial;
28516
+ if (trial === void 0 && plan2.trial_days && plan2.trial_days > 0 && sd.trial_end === void 0) out.trial_days = 0;
28517
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
28518
+ const pd = items.map((li) => li && typeof li === "object" ? li.price_data : void 0).find((x) => x && typeof x === "object");
28519
+ const rec = pd && typeof pd.recurring === "object" ? pd.recurring : void 0;
28520
+ if (rec && typeof rec.interval === "string" && rec.interval !== plan2.interval && ["day", "week", "month", "year"].includes(rec.interval)) out.interval = rec.interval;
28521
+ const price3 = typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
28522
+ if (pd && typeof pd.unit_amount === "number" && price3 !== void 0 && /^(?:usd|eur|gbp|cad|aud|nzd)$/i.test(String(pd.currency ?? plan2.currency ?? "usd")) && (!pd.currency || pd.currency === (plan2.currency ?? "usd")) && Math.abs(pd.unit_amount - Math.round(price3 * 100)) >= 1) {
28523
+ out.price = pd.unit_amount / 100;
28524
+ }
28525
+ return Object.keys(out).length ? out : void 0;
28526
+ }
28527
+ function describeSold(o) {
28528
+ const parts = [];
28529
+ if (o.trial_days !== void 0) parts.push(o.trial_days ? `a ${o.trial_days}-day trial` : "no trial");
28530
+ const charge = [o.price !== void 0 ? Number(o.price).toFixed(2) : "", o.interval ? `every ${o.interval}` : ""].filter(Boolean).join(" ");
28531
+ if (charge) parts.push(charge);
28532
+ return parts.join(", ");
28533
+ }
27875
28534
  function planDrift(params, plan2) {
27876
28535
  if (!plan2) return void 0;
27877
28536
  const sd = params.subscription_data ?? {};
@@ -27887,7 +28546,7 @@ function planDrift(params, plan2) {
27887
28546
  if (pd && typeof pd.unit_amount === "number" && price3 !== void 0 && /^(?:usd|eur|gbp|cad|aud|nzd)$/i.test(String(pd.currency ?? plan2.currency ?? "usd")) && (!pd.currency || pd.currency === (plan2.currency ?? "usd")) && Math.abs(pd.unit_amount - Math.round(price3 * 100)) >= 1) {
27888
28547
  out.push(`the params charge ${(pd.unit_amount / 100).toFixed(2)}, plan "${plan2.name}" ${price3.toFixed(2)}`);
27889
28548
  }
27890
- return out.length ? `The params and checkbox.json disagree: ${out.join("; ")}. The checks use the params; update "plans" in checkbox.json so the required emails and pages are checked for the plan you really sell.` : void 0;
28549
+ return out.length ? `The params and checkbox.json disagree: ${out.join("; ")}. The Checkout check uses the params, and the emails, pages and disclosures of plan "${plan2.name}" are checked against both until they agree: update "plans" in checkbox.json to the plan you really sell.` : void 0;
27891
28550
  }
27892
28551
  function disclosureJob(loaded, e, root) {
27893
28552
  const { config, dir } = loaded;
@@ -27960,6 +28619,53 @@ function pageJob(loaded, e, o, root) {
27960
28619
  }
27961
28620
  return job;
27962
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
+ }
27963
28669
  async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27964
28670
  const { config, dir } = loaded;
27965
28671
  const job = { name, type: "email", ...planName ? { plan: planName } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
@@ -27980,6 +28686,8 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27980
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 } : {} };
27981
28687
  } else throw new InputError(`${e.module} must return { subject, text | html } or a string`);
27982
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));
27983
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>`);
27984
28692
  if (!msg2.text && !msg2.html) throw new InputError("the email body is empty");
27985
28693
  const terms2 = { ...planTerms(config, pickPlan(config, planName)) };
@@ -27993,9 +28701,35 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27993
28701
  }
27994
28702
  async function resolveJobs(loaded, o) {
27995
28703
  const root = o.root ?? loaded.dir;
28704
+ const jobs = [];
28705
+ for (const e of loaded.config.checks.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
28706
+ const dependent = await planJobs(loaded, o, root);
28707
+ jobs.push(...dependent);
28708
+ const seen = /* @__PURE__ */ new Set();
28709
+ for (const sj of jobs.filter((j) => j.type === "stripe_checkout" && j.sold)) {
28710
+ const key = JSON.stringify(sj.sold);
28711
+ if (seen.has(key)) continue;
28712
+ seen.add(key);
28713
+ const { plan: planName, overrides } = sj.sold;
28714
+ const config = { ...loaded.config, plans: (loaded.config.plans ?? []).map((p) => p.name === planName ? { ...p, ...overrides } : p) };
28715
+ const variant = await planJobs({ ...loaded, config }, o, root);
28716
+ const sold = describeSold(overrides);
28717
+ variant.forEach((v, i) => {
28718
+ const orig = dependent[i];
28719
+ if (orig.error || orig.skip || !v.body || JSON.stringify(v.body) === JSON.stringify(orig.body)) return;
28720
+ jobs.push({
28721
+ ...v,
28722
+ name: `${orig.name} (as "${sj.name}" sells it: ${sold})`,
28723
+ inputs: [.../* @__PURE__ */ new Set([...v.inputs, ...sj.inputs])],
28724
+ warnings: [`The params and checkbox.json disagree: "${sj.name}" sells plan "${planName}" with ${sold}, which checkbox.json does not say. This check judges ${orig.name} against what the params sell; the same check against checkbox.json runs too, and both must pass. Update "plans" in checkbox.json (or the params) so they agree.`, ...v.warnings]
28725
+ });
28726
+ });
28727
+ }
28728
+ return jobs;
28729
+ }
28730
+ async function planJobs(loaded, o, root) {
27996
28731
  const c = loaded.config.checks;
27997
28732
  const jobs = [];
27998
- for (const e of c.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
27999
28733
  for (const e of c.disclosures ?? []) jobs.push(disclosureJob(loaded, e, root));
28000
28734
  for (const e of c.pages ?? []) jobs.push(pageJob(loaded, e, o, root));
28001
28735
  for (const e of c.emails ?? []) {
@@ -28379,6 +29113,7 @@ var init_timing = __esm({
28379
29113
  var notice_schedule_exports = {};
28380
29114
  __export(notice_schedule_exports, {
28381
29115
  computeNoticeSchedule: () => computeNoticeSchedule,
29116
+ noticesKindsNote: () => noticesKindsNote,
28382
29117
  singleDayNote: () => singleDayNote
28383
29118
  });
28384
29119
  function singleDayNote(requirementIds, days) {
@@ -28487,7 +29222,12 @@ function computeNoticeSchedule(input, now = /* @__PURE__ */ new Date()) {
28487
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);
28488
29223
  else notRequired.push({ kind: "annual_terms", reason: "No applicable requirement demands a yearly terms reminder under these jurisdictions." });
28489
29224
  }
28490
- 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) {
28491
29231
  notRequired.push({ kind: "price_change", reason: "No price change planned. Pass price_change (effective_date, new_price) to schedule its advance notice." });
28492
29232
  } else {
28493
29233
  const eff = parseDate(input.price_change.effective_date, "price_change.effective_date", start);
@@ -28514,6 +29254,24 @@ function computeNoticeSchedule(input, now = /* @__PURE__ */ new Date()) {
28514
29254
  }
28515
29255
  };
28516
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
+ }
28517
29275
  var CITATION, BY_ID2, day, unix;
28518
29276
  var init_notice_schedule = __esm({
28519
29277
  "../../apps/api/src/v1/checks/notice-schedule.ts"() {
@@ -28554,8 +29312,15 @@ function operationFor2(kind) {
28554
29312
  function onlineEngine(client) {
28555
29313
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28556
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
+ }
28557
29322
  function ciVocabulary(text) {
28558
- 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(/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).');
28559
29324
  }
28560
29325
  function pricingPageFixes(fixes) {
28561
29326
  const reworded = fixes.map(
@@ -28565,10 +29330,18 @@ function pricingPageFixes(fixes) {
28565
29330
  const renewalLineFor = /* @__PURE__ */ new Set();
28566
29331
  for (const f of reworded) {
28567
29332
  const card = /^(Plan card [^:]+): /.exec(f)?.[1] ?? "";
28568
- const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price)/.test(f);
29333
+ const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price|Add your own renewal line next to the (?:pay|plan's) button)/.test(f);
28569
29334
  if (isRenewalLine) {
28570
- if (renewalLineFor.has(card)) continue;
28571
- renewalLineFor.add(card);
29335
+ const cards = [...card.matchAll(/“([^”]+)”/g)].map((m) => m[1]);
29336
+ const fresh = cards.filter((c) => !renewalLineFor.has(c));
29337
+ if (cards.length ? !fresh.length : renewalLineFor.has(card)) continue;
29338
+ if (!cards.length) renewalLineFor.add(card);
29339
+ for (const c of fresh) renewalLineFor.add(c);
29340
+ if (cards.length && fresh.length < cards.length) {
29341
+ const g = `Plan card ${fresh.map((c) => `\u201C${c}\u201D`).join(", ")}: ${f.slice(card.length + 2)}`;
29342
+ if (!out.includes(g)) out.push(g);
29343
+ continue;
29344
+ }
28572
29345
  }
28573
29346
  if (!out.includes(f)) out.push(f);
28574
29347
  }
@@ -28594,12 +29367,23 @@ function fixesOf(r, onlyNeedsFix = false) {
28594
29367
  failing.sort((a, b) => (a.compliance_status === "needs_fix" ? 0 : 1) - (b.compliance_status === "needs_fix" ? 0 : 1) || (RANK2[a.status] ?? 2) - (RANK2[b.status] ?? 2));
28595
29368
  for (const c of failing) {
28596
29369
  const base = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
28597
- const cards = failingCards(c.explanation);
29370
+ let cards = failingCards(c.explanation);
29371
+ const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
29372
+ if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
29373
+ cards = cards.filter((x) => !plain.includes(x));
29374
+ const standOut = `Plan card ${plain.map((x) => `\u201C${x}\u201D`).join(", ")}: ${STAND_OUT_DECISION}`;
29375
+ if (!onlyNeedsFix && !out.includes(standOut)) out.push(standOut);
29376
+ }
28598
29377
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
28599
29378
  if (text && !out.includes(text)) out.push(text);
28600
29379
  }
28601
29380
  return out;
28602
29381
  }
29382
+ function cardClauses(explanation) {
29383
+ if (!explanation) return [];
29384
+ const ms = [...explanation.matchAll(/plan button “([^”]{1,80})”(?: in the card “([^”]{1,80})”)?/g)];
29385
+ return ms.map((m, i) => ({ card: m[2] ?? m[1], clause: explanation.slice(m.index + m[0].length, ms[i + 1]?.index ?? explanation.length) }));
29386
+ }
28603
29387
  function failingCards(explanation) {
28604
29388
  if (!explanation) return [];
28605
29389
  if (/\(\d+ checkout page views? checked\) — e\.g\./.test(explanation)) return [];
@@ -28616,7 +29400,7 @@ function withPlaceholders(params, placeholders) {
28616
29400
  }
28617
29401
  return copy;
28618
29402
  }
28619
- function correctedOf(type, r, partial = false, placeholders) {
29403
+ function correctedOf(type, r, partial = false, placeholders, pricingPage = false) {
28620
29404
  if (type === "stripe_checkout" && r.correction?.patch?.length && partial) {
28621
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) };
28622
29406
  }
@@ -28629,8 +29413,10 @@ function correctedOf(type, r, partial = false, placeholders) {
28629
29413
  return {
28630
29414
  format: "text",
28631
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.
28632
29418
  body: perPlan.map((p) => `${p.plan}:
28633
- ${p.disclosure_text}${p.consent_label ? `
29419
+ ${p.disclosure_text}${p.consent_label && !pricingPage ? `
28634
29420
  Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
28635
29421
  };
28636
29422
  }
@@ -28647,7 +29433,8 @@ Consent checkbox label: ${r.replacement.consent_label}` : ""}`
28647
29433
  };
28648
29434
  }
28649
29435
  if (type === "email" && r.template && ciStatusOf(r) !== "pass" && r.verdict !== "not_applicable") {
28650
- 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}
28651
29438
 
28652
29439
  ${r.template.text}` };
28653
29440
  }
@@ -28691,13 +29478,20 @@ function noticeCoverage(config, planName, schedule, opts = {}) {
28691
29478
  const required = /* @__PURE__ */ new Map();
28692
29479
  for (const n of schedule.notices ?? []) {
28693
29480
  const k = NOTICE_TO_EMAIL[n.kind];
28694
- if (k && !required.has(k)) required.set(k, { occasion: n.occasion, days: n.days_before });
28695
- }
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
+ };
28696
29490
  const missing = [...required].filter(([k]) => !have.has(k));
28697
29491
  if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
28698
29492
  const sent2 = [...required.keys()].filter((k) => byCheckbox.has(k)).length;
28699
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: [] };
28700
- 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)}`);
28701
29495
  return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
28702
29496
  }
28703
29497
  function unverifiedOffer(r) {
@@ -28724,6 +29518,11 @@ async function runJob(job, engine, config, opts = {}) {
28724
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 } : {} };
28725
29519
  }
28726
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
+ }
28727
29526
  const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
28728
29527
  for (const w of report.warnings ?? []) {
28729
29528
  const text = ciVocabulary(w.message);
@@ -28734,6 +29533,12 @@ async function runJob(job, engine, config, opts = {}) {
28734
29533
  const status = ciStatusOf(report);
28735
29534
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
28736
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
+ }
28737
29542
  const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
28738
29543
  if (pricingPage) fixes = pricingPageFixes(fixes);
28739
29544
  const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b/.test(p));
@@ -28768,18 +29573,29 @@ async function runJob(job, engine, config, opts = {}) {
28768
29573
  engine.mode === "offline" ? `The intro price is declared in checkbox.json, not verified: offline, Checkbox cannot read ${what} from Stripe. Run with --online (CHECKBOX_SECRET_KEY, and the site's Stripe key connected) so Checkbox reads it and compares its percent / amount off and duration with the declared intro.` : `The intro price is declared in checkbox.json, not verified: Checkbox could not read ${what} through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?). Until it is read, its percent / amount off and duration are not compared with the declared intro.`
28769
29574
  );
28770
29575
  }
28771
- const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon ? "needs_review" : status;
28772
- const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
29576
+ const sharedReview = !unread && !declaredCoupon && status === "pass" && job.type === "stripe_checkout" ? stripeParamsVerdict(report) : void 0;
29577
+ const unreadCoupon = sharedReview?.verdict === "needs_review" && sharedReview.reason ? sharedReview.reason : void 0;
29578
+ if (unreadCoupon) {
29579
+ const id = /discounts use (coupon|promotion code) (\S+?),/.exec(unreadCoupon);
29580
+ const what = id ? `${id[1]} ${id[2]}` : "the coupon";
29581
+ fixes.unshift(
29582
+ engine.mode === "offline" ? `The params apply ${what}, which Checkbox cannot read offline, so its discount and duration (what the shopper is charged first) are not checked. Declare the intro on the plan in checkbox.json (intro_percent_off, intro_amount_off or intro_price, with intro_periods) and state it in custom_text.submit, or run with --online so Checkbox reads the coupon.` : `The params apply ${what}, which Checkbox could not read through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?), so its discount and duration are not checked. Declare the intro on the plan in checkbox.json and state it in custom_text.submit, or let Checkbox read the coupon.`
29583
+ );
29584
+ }
29585
+ const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon || unreadCoupon ? "needs_review" : status;
29586
+ const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders, pricingPage);
28773
29587
  if (corrected?.format === "json" && job.placeholders) {
28774
29588
  const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
28775
29589
  if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
28776
29590
  }
28777
- const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || status === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
29591
+ const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || // Round 31 (pilot 30 item 2, headless-agent-pr false pass: a "strict" entry with a declared-but-unverified coupon exited
29592
+ // 0): the entry's strictness applies to the status the check reports (finalStatus), not the engine's raw verdict.
29593
+ finalStatus === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
28778
29594
  return {
28779
29595
  ...base,
28780
29596
  status: finalStatus,
28781
29597
  ...blocking ? { blocking: true } : {},
28782
- label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
29598
+ label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : unreadCoupon ? "Needs review \xB7 coupon not read" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
28783
29599
  ...fixes[0] ? { fix: fixes[0] } : {},
28784
29600
  fixes,
28785
29601
  ...corrected ? { corrected } : {},
@@ -28847,10 +29663,11 @@ async function runCi(jobs, engine, opts) {
28847
29663
  checks,
28848
29664
  ...versioned?.rules_version ? { rules_version: versioned.rules_version } : {},
28849
29665
  ...versioned?.catalog_version ? { catalog_version: versioned.catalog_version } : {},
28850
- disclaimer: CI_DISCLAIMER
29666
+ disclaimer: CI_DISCLAIMER,
29667
+ result: resultOf(status, exit, opts.strict)
28851
29668
  };
28852
29669
  }
28853
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, 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;
28854
29671
  var init_runner = __esm({
28855
29672
  "src/ci/runner.ts"() {
28856
29673
  "use strict";
@@ -28858,6 +29675,7 @@ var init_runner = __esm({
28858
29675
  init_http();
28859
29676
  init_src();
28860
29677
  init_types2();
29678
+ init_stripe_verdict();
28861
29679
  init_config3();
28862
29680
  ENGINE_KIND = {
28863
29681
  stripe_checkout: "stripe_checkout_session",
@@ -28866,9 +29684,11 @@ var init_runner = __esm({
28866
29684
  email: "email",
28867
29685
  notice_schedule: "notice_schedule"
28868
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.";
28869
29688
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
28870
29689
  RANK2 = { fail: 0, needs_review: 1 };
28871
29690
  OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
29691
+ STAND_OUT_DECISION = "Decision for you (not a fix): its renewal line is there and legible but looks like the text around it. California asks for renewal terms that stand out, so consider larger type, bold (weight 600 or more), a contrasting colour, or a border.";
28872
29692
  NOTICE_TO_EMAIL = {
28873
29693
  acknowledgment: "acknowledgment",
28874
29694
  trial_ending: "trial_reminder",
@@ -28913,21 +29733,28 @@ function renderHuman(run2, c, opts = {}) {
28913
29733
  const lines = [];
28914
29734
  const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
28915
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 = [];
28916
29737
  const width = Math.min(40, Math.max(...run2.checks.map((x) => x.name.length), 10));
28917
29738
  for (const x of run2.checks) {
28918
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);
28919
29740
  lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
28920
29741
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
28921
29742
  if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
28922
- 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, " ")}`);
28923
29744
  const alerts = x.alerts ?? [];
28924
29745
  for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
28925
- 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
+ }
28926
29751
  if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
28927
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, " ")));
28928
29754
  const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
28929
29755
  if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
28930
- 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+\)$/, "")})` : ""}`);
28931
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."));
28932
29759
  lines.push(c.dim(run2.disclaimer));
28933
29760
  return lines;
@@ -28952,6 +29779,8 @@ function stepSummary(run2) {
28952
29779
  const versions = run2.rules_version ? ` \xB7 rules ${run2.rules_version} \xB7 catalog ${run2.catalog_version}` : "";
28953
29780
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
28954
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}`, "");
28955
29784
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
28956
29785
  for (const x of run2.checks) {
28957
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 : "";
@@ -28974,7 +29803,7 @@ function stepSummary(run2) {
28974
29803
  function escapeHtml(s2) {
28975
29804
  return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28976
29805
  }
28977
- var TYPE_LABEL, where, escData, escProp, cell, STATUS_MD;
29806
+ var TYPE_LABEL, where, PRICE_UNREAD, escData, escProp, cell, STATUS_MD;
28978
29807
  var init_report = __esm({
28979
29808
  "src/ci/report.ts"() {
28980
29809
  "use strict";
@@ -28988,6 +29817,7 @@ var init_report = __esm({
28988
29817
  notice_schedule: "Notice schedule"
28989
29818
  };
28990
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/;
28991
29821
  escData = (s2) => s2.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
28992
29822
  escProp = (s2) => escData(s2).replace(/:/g, "%3A").replace(/,/g, "%2C");
28993
29823
  cell = (s2) => s2.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
@@ -29170,6 +30000,8 @@ __export(ci_exports, {
29170
30000
  attachSiteForCoupons: () => attachSiteForCoupons,
29171
30001
  couponIdsIn: () => couponIdsIn,
29172
30002
  default: () => ci_default,
30003
+ siteFactsFor: () => siteFactsFor,
30004
+ termsChanged: () => termsChanged,
29173
30005
  touchesConfig: () => touchesConfig,
29174
30006
  undiffableInputs: () => undiffableInputs
29175
30007
  });
@@ -29177,6 +30009,16 @@ import { execFileSync as execFileSync2 } from "node:child_process";
29177
30009
  import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
29178
30010
  import { createInterface } from "node:readline";
29179
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
+ }
29180
30022
  function gitChanged(cwd, base) {
29181
30023
  try {
29182
30024
  const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -29246,6 +30088,36 @@ async function confirm(question) {
29246
30088
  rl.close();
29247
30089
  }
29248
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
+ }
29249
30121
  async function runInit(ctx, argv) {
29250
30122
  const p = parseArgs(argv, { boolean: ["yes", "force", "dry-run", "workflow"], string: ["config"], alias: { y: "yes" } });
29251
30123
  if (p.positionals.length) throw new UsageError(`Unexpected argument "${p.positionals[0]}"`);
@@ -29256,13 +30128,24 @@ async function runInit(ctx, argv) {
29256
30128
  const withWorkflow = bool(p.flags, "workflow") !== false;
29257
30129
  const projects = ctx.config.load().init?.projects ?? {};
29258
30130
  let facts = {};
30131
+ let setupSession;
29259
30132
  for (let dir = resolve7(ctx.cwd); ; dir = dirname7(dir)) {
29260
- if (projects[dir]?.facts) {
29261
- 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;
29262
30137
  break;
29263
30138
  }
29264
30139
  if (dirname7(dir) === dir) break;
29265
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
+ }
29266
30149
  const s2 = scaffoldConfig(ctx.cwd, ctx.env, facts);
29267
30150
  const actionFor = (f) => existsSync8(f) ? force ? "overwrite" : "keep" : "create";
29268
30151
  const files = [{ path: configPath, content: `${JSON.stringify(s2.config, null, 2)}
@@ -29294,8 +30177,13 @@ async function runInit(ctx, argv) {
29294
30177
  found: s2.found,
29295
30178
  ...withWorkflow ? { workflow_steps: workflowSteps } : {},
29296
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) } : {},
29297
30185
  config: s2.config,
29298
- 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." }
29299
30187
  };
29300
30188
  ctx.out.result(result, () => {
29301
30189
  const lines = [];
@@ -29305,6 +30193,7 @@ async function runInit(ctx, argv) {
29305
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.`);
29306
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".');
29307
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}`));
29308
30197
  lines.push("", `${ctx.out.c.cyan("Next:")} ${result.next_action.message}`);
29309
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}`)));
29310
30199
  return lines;
@@ -29469,13 +30358,17 @@ ${message}
29469
30358
  const engine = client ? onlineEngine(client) : offlineEngine();
29470
30359
  const notices = client && declaredByCheckbox(loaded.config).length ? await readNotices(ctx, client, str(p.flags, "site")) : void 0;
29471
30360
  const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
29472
- 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;
29473
30364
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
29474
30365
  if (gha) {
29475
30366
  for (const a of annotations(run2)) emit(a);
29476
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}`);
29477
30369
  writeGithubFile(ctx.env.GITHUB_OUTPUT, `status=${run2.status}
29478
30370
  exit_code=${run2.exit_code}
30371
+ result=${run2.result}
29479
30372
  `);
29480
30373
  }
29481
30374
  return run2.exit_code;
@@ -29535,6 +30428,14 @@ function findAnchor(src, target) {
29535
30428
  }
29536
30429
  }
29537
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
+ }
29538
30439
  return null;
29539
30440
  }
29540
30441
  function lineStart(src, idx) {
@@ -29884,6 +30785,16 @@ var init_device = __esm({
29884
30785
  import { execFileSync as execFileSync3 } from "node:child_process";
29885
30786
  import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
29886
30787
  import { dirname as dirname8, join as join10 } from "node:path";
30788
+ function pinnedRelease(body) {
30789
+ const b = body;
30790
+ if (!b || typeof b.version !== "string" || !/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(b.version)) return void 0;
30791
+ if (typeof b.integrity !== "string" || !/^sha384-[A-Za-z0-9+/]{64}$/.test(b.integrity)) return void 0;
30792
+ return { version: b.version, integrity: b.integrity };
30793
+ }
30794
+ function snippetScriptTag(s2, jsx = false, attrs = "") {
30795
+ const pin = s2.pinned ? ` integrity="${s2.pinned.integrity}" ${jsx ? "crossOrigin" : "crossorigin"}="anonymous"` : "";
30796
+ return `<script${attrs} async src="${srcOf(s2)}"${pin} data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`;
30797
+ }
29887
30798
  function read(root, rel2) {
29888
30799
  try {
29889
30800
  return readFileSync11(join10(root, rel2), "utf8");
@@ -29892,19 +30803,20 @@ function read(root, rel2) {
29892
30803
  }
29893
30804
  }
29894
30805
  function srcOf(s2) {
29895
- return `${s2.apiBase.replace(/\/+$/, "")}/s.js`;
30806
+ const api = s2.apiBase.replace(/\/+$/, "");
30807
+ return s2.pinned ? `${api}/s/${s2.pinned.version}.js` : `${api}/s.js`;
29896
30808
  }
29897
30809
  function jsxBlock(s2, indent) {
29898
30810
  return [
29899
30811
  `${indent}{/* ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet) */}`,
29900
- `${indent}<script async src="${srcOf(s2)}" data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`,
30812
+ `${indent}${snippetScriptTag(s2, true)}`,
29901
30813
  `${indent}{/* ${MARK_END} */}`
29902
30814
  ];
29903
30815
  }
29904
30816
  function htmlBlock(s2, indent, attrs = "") {
29905
30817
  return [
29906
30818
  `${indent}<!-- ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet) -->`,
29907
- `${indent}<script${attrs} async src="${srcOf(s2)}" data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`,
30819
+ `${indent}${snippetScriptTag(s2, false, attrs)}`,
29908
30820
  `${indent}<!-- ${MARK_END} -->`
29909
30821
  ];
29910
30822
  }
@@ -29963,7 +30875,7 @@ function planSnippet(d, s2) {
29963
30875
  const root = d.root;
29964
30876
  const file = d.layoutFile;
29965
30877
  const manual = `Add this tag inside <head> on every page (your root layout / template):
29966
- ${s2.scriptTag}`;
30878
+ ${s2.pinned ? snippetScriptTag(s2) : s2.scriptTag}`;
29967
30879
  if (!file) return { change: null, manual };
29968
30880
  const existing = read(root, file);
29969
30881
  const jsx = (f) => f === "nextjs-app" || f === "nextjs-pages" || f === "remix" || f === "react-router";
@@ -29973,21 +30885,25 @@ function planSnippet(d, s2) {
29973
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") } };
29974
30886
  }
29975
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
+ }
29976
30892
  if (d.framework === "nextjs-pages") {
29977
30893
  const contents = NEXT_DOCUMENT(s2);
29978
30894
  return { change: { file, action: "create", summary: "Custom _document with the snippet in <Head>", contents, added: contents.trim().split("\n") } };
29979
30895
  }
29980
30896
  return { change: null, manual };
29981
30897
  }
29982
- if (existing.includes(`data-key="${s2.snippetKey}"`) && existing.includes(MARK_START)) {
29983
- return { change: { file, action: "unchanged", summary: "Snippet already installed", added: [] } };
30898
+ if (existing.includes(`data-key="${s2.snippetKey}"`) && existing.includes(MARK_START) && existing.includes(`src="${srcOf(s2)}"`) && (!s2.pinned || existing.includes(s2.pinned.integrity))) {
30899
+ return { change: { file, action: "unchanged", summary: s2.pinned ? `Snippet already installed (pinned release ${s2.pinned.version} with SRI)` : "Snippet already installed", added: [] } };
29984
30900
  }
29985
30901
  const isJsx = jsx(d.framework);
29986
30902
  const indentRe = isJsx ? d.framework === "nextjs-pages" ? /<Head\b[^>]*>/ : /<head\b[^>]*>/ : /<\/head>/i;
29987
30903
  const indent = isJsx ? indentOf2(existing, indentRe) : indentOf2(existing, /<\/head>/i, " ");
29988
30904
  const block = isJsx ? jsxBlock(s2, indent) : htmlBlock(s2, indent, d.framework === "astro" ? " is:inline" : "");
29989
30905
  const replaced = replaceMarked(existing, block);
29990
- if (replaced !== null) return { change: { file, action: "modify", summary: "Snippet tag updated (new site key)", contents: replaced, added: block } };
30906
+ if (replaced !== null) return { change: { file, action: "modify", summary: existing.includes(`data-key="${s2.snippetKey}"`) ? s2.pinned ? `Snippet tag pinned to release ${s2.pinned.version} with SRI` : "Snippet tag updated" : "Snippet tag updated (new site key)", contents: replaced, added: block } };
29991
30907
  let next = null;
29992
30908
  switch (d.framework) {
29993
30909
  case "nextjs-app": {
@@ -30018,7 +30934,7 @@ ${indentOf2(existing, /<Head\s*\/>/, "")}</Head>`) : null);
30018
30934
  }
30019
30935
  if (!next) return { change: null, manual: `Could not find <head> in ${file}. ${manual}` };
30020
30936
  if (!snippetPlacedSafely(next, isJsx)) return { change: null, manual: `Could not place the snippet safely in ${file} (its <head> is not where Checkbox can patch it without breaking the file), so ${file} was NOT changed. ${manual}` };
30021
- return { change: { file, action: "modify", summary: `Snippet added to <head> in ${file}`, contents: next, added: block } };
30937
+ return { change: { file, action: "modify", summary: `Snippet added to <head> in ${file}${s2.pinned ? ` (pinned release ${s2.pinned.version} with SRI)` : ""}`, contents: next, added: block } };
30022
30938
  }
30023
30939
  function envVarNames(d) {
30024
30940
  return {
@@ -30101,7 +31017,7 @@ function applyChange(root, c) {
30101
31017
  mkdirSync3(dirname8(abs), { recursive: true });
30102
31018
  writeFileSync4(abs, c.contents, c.file.startsWith(".env") ? { mode: 384 } : void 0);
30103
31019
  }
30104
- var MARK_START, MARK_END, NEXT_DOCUMENT, NUXT_PLUGIN;
31020
+ var MARK_START, MARK_END, NEXT_DOCUMENT, NEXT_APP_LAYOUT, NUXT_PLUGIN;
30105
31021
  var init_patch2 = __esm({
30106
31022
  "src/commands/init/patch.ts"() {
30107
31023
  "use strict";
@@ -30122,11 +31038,22 @@ ${jsxBlock(s2, " ").join("\n")}
30122
31038
  </Html>
30123
31039
  );
30124
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
+ }
30125
31052
  `;
30126
31053
  NUXT_PLUGIN = (s2) => `// ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet)
30127
31054
  export default defineNuxtPlugin(() => {
30128
31055
  useHead({
30129
- script: [{ src: '${srcOf(s2)}', async: true, 'data-key': '${s2.snippetKey}', 'data-api': '${s2.apiBase.replace(/\/+$/, "")}' }],
31056
+ script: [{ src: '${srcOf(s2)}', async: true,${s2.pinned ? ` integrity: '${s2.pinned.integrity}', crossorigin: 'anonymous',` : ""} 'data-key': '${s2.snippetKey}', 'data-api': '${s2.apiBase.replace(/\/+$/, "")}' }],
30130
31057
  });
30131
31058
  });
30132
31059
  // ${MARK_END}
@@ -30142,8 +31069,11 @@ __export(init_exports, {
30142
31069
  configLines: () => configLines,
30143
31070
  configNameOf: () => configNameOf,
30144
31071
  default: () => init_default,
31072
+ liveKeyNextAction: () => liveKeyNextAction,
30145
31073
  parsePlanFlag: () => parsePlanFlag,
30146
- syncCheckoutConfigs: () => syncCheckoutConfigs
31074
+ snippetWords: () => snippetWords,
31075
+ syncCheckoutConfigs: () => syncCheckoutConfigs,
31076
+ withEvaluatedAt: () => withEvaluatedAt
30147
31077
  });
30148
31078
  import { existsSync as existsSync11 } from "node:fs";
30149
31079
  import { join as join11 } from "node:path";
@@ -30163,6 +31093,10 @@ function saveState(ctx, root, patch) {
30163
31093
  }
30164
31094
  ctx.config.save({ init: { ...cfg, projects } });
30165
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
+ }
30166
31100
  async function ask2(question) {
30167
31101
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
30168
31102
  try {
@@ -30190,6 +31124,7 @@ function projectFacts(d) {
30190
31124
  ...d.layoutFile ? { layout_file: d.layoutFile } : {},
30191
31125
  env_file: d.envFile,
30192
31126
  ...d.publicDir ? { public_dir: d.publicDir } : {},
31127
+ ...d.mailer ? { mailer: d.mailer } : {},
30193
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) })),
30194
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) }))
30195
31130
  };
@@ -30227,7 +31162,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
30227
31162
  const call = (perFile.get(s2.file) ?? 0) + 1;
30228
31163
  perFile.set(s2.file, call);
30229
31164
  if (!registrable(s2.params, s2.unresolved)) {
30230
- out.push({ file: s2.file, line: s2.line, skipped: s2.unresolved.length ? `values not readable statically: ${s2.unresolved.slice(0, 5).join(", ")}` : "not a subscription-mode Checkout Session" });
31165
+ out.push({ file: s2.file, line: s2.line, skipped: s2.unresolved.length ? `values not readable statically: ${s2.unresolved.slice(0, 5).join(", ")} (a variable, call or template string). Next: register the exact params your server sends with createStripeCheckoutConfig (POST /v1/sites/{site}/stripe_checkout_configs), or, once Stripe is connected, create one Checkout Session in Stripe test mode so Checkbox reads it` : "not a subscription-mode Checkout Session" });
30231
31166
  continue;
30232
31167
  }
30233
31168
  try {
@@ -30265,11 +31200,32 @@ function configLines(configs, now = Date.now()) {
30265
31200
  };
30266
31201
  return ["Checkout params registered:", ...configs.map((c) => c.id ? ` ${c.file}:${c.line} \u2192 ${c.id}${age(c.registered_at)}${c.replaced?.length ? ` (replaced ${c.replaced.length} older config${c.replaced.length === 1 ? "" : "s"})` : ""}` : ` ${c.file}${c.line ? `:${c.line}` : ""}: ${c.skipped}`)];
30267
31202
  }
31203
+ async function snippetRelease(client) {
31204
+ try {
31205
+ return pinnedRelease(await client.get("/s/latest.json"));
31206
+ } catch {
31207
+ return void 0;
31208
+ }
31209
+ }
30268
31210
  function registrable(params, unresolved) {
30269
31211
  if (!params) return false;
30270
31212
  if (unresolved.some((u) => /^(mode|line_items|subscription_data|custom_text|consent_collection)\b/.test(u))) return false;
30271
31213
  return params.mode === "subscription" && Array.isArray(params.line_items) && params.line_items.length > 0;
30272
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
+ }
30273
31229
  function detectionSummary(d, siteUrl) {
30274
31230
  return {
30275
31231
  framework: d.framework,
@@ -30292,9 +31248,14 @@ function detectionSummary(d, siteUrl) {
30292
31248
  function fileOut(c) {
30293
31249
  return { file: c.file, action: c.action, summary: c.summary, added: c.added };
30294
31250
  }
30295
- function resultOf(status, d, session, changes, extra, apiBase) {
31251
+ function resultOf2(status, d, session, changes, extra, apiBase) {
30296
31252
  const names = envVarNames(d);
30297
- 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;
30298
31259
  return {
30299
31260
  object: "checkbox_init",
30300
31261
  status,
@@ -30309,7 +31270,10 @@ function resultOf(status, d, session, changes, extra, apiBase) {
30309
31270
  install: session.install,
30310
31271
  stripe: { ...session.stripe, ...extra.stripe ?? {} },
30311
31272
  ...extra.live && Object.keys(extra.live).length ? { live: extra.live } : {},
30312
- 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 } : {},
30313
31277
  steps: session.steps.map((s2) => ({ key: s2.key, status: s2.status, summary: s2.summary })),
30314
31278
  warnings: extra.warnings ?? [],
30315
31279
  owner_notified: session.owner_notified,
@@ -30332,6 +31296,10 @@ function renderSession(ctx, s2) {
30332
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})`)}`);
30333
31297
  if (todo.length > 5) lines.push(c.dim(` \u2026 ${todo.length - 5} more: checkbox plan`));
30334
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
+ }
30335
31303
  lines.push("", `${c.cyan("next")} ${s2.next_action.message}${s2.next_action.command ? `
30336
31304
  ${s2.next_action.command}` : ""}`, `${c.dim("review")} ${s2.review_url}`);
30337
31305
  return lines;
@@ -30357,6 +31325,7 @@ var init_init = __esm({
30357
31325
  "use strict";
30358
31326
  init_args();
30359
31327
  init_config();
31328
+ init_src2();
30360
31329
  init_http();
30361
31330
  init_types2();
30362
31331
  init_detect();
@@ -30364,7 +31333,7 @@ var init_init = __esm({
30364
31333
  init_patch2();
30365
31334
  VERSION_FLAGS = {
30366
31335
  string: ["site-url", "checkout-url", "account-url", "terms-url", "privacy-url", "refund-url", "cancellation-url", "wait-seconds", "agent", "support-email", "merchant-name", "plan"],
30367
- boolean: ["yes", "dry-run", "open", "wait", "verify", "complete", "live", "stripe", "snippet", "relogin"],
31336
+ boolean: ["yes", "dry-run", "open", "wait", "verify", "complete", "live", "stripe", "snippet", "pin", "relogin"],
30368
31337
  alias: { y: "yes" }
30369
31338
  };
30370
31339
  interactive = (ctx) => !ctx.flags.json && !!process.stdin.isTTY && !!process.stderr.isTTY;
@@ -30376,8 +31345,11 @@ var init_init = __esm({
30376
31345
  "Run it in the project root. What it does (idempotent; re-run any time):",
30377
31346
  " 1. detects the framework, billing platform, checkout code and pay buttons",
30378
31347
  ' 2. signs you in: prints ONE link ("Open \u2026 and click Approve") and opens the browser; new accounts are free',
30379
- " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it)",
30380
- " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments)",
31348
+ " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it); when that file is",
31349
+ " tracked by git, or the folder is deployed as it is, the key is not written to it and stays only in the CLI config",
31350
+ " (~/.config/checkbox/config.json), which the output says",
31351
+ " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments): the pinned release",
31352
+ " (/s/<version>.js with integrity= and crossorigin=, Subresource Integrity); --no-pin uses the unpinned /s.js",
30381
31353
  " 5. registers the site, Terms / refund / cancellation policy URLs (fetched and checked by Checkbox)",
30382
31354
  " 6. with your permission, uses the project's Stripe restricted TEST key (rk_test_\u2026; sk_test_ and live keys are refused) for a synthetic test subscription",
30383
31355
  " 7. runs the first checks and prints the next step (`checkbox plan --json`); the human gets the summary email",
@@ -30453,7 +31425,7 @@ var init_init = __esm({
30453
31425
  if (bool(flags, "verify")) {
30454
31426
  const v = await client2.sdk.setupSessions.verify(state.setup_session);
30455
31427
  session2 = v.setup_session;
30456
- 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}`);
30457
31429
  } else {
30458
31430
  session2 = await client2.sdk.setupSessions.retrieve(state.setup_session);
30459
31431
  }
@@ -30463,7 +31435,7 @@ var init_init = __esm({
30463
31435
  const configs = await syncCheckoutConfigs(client2, session2.site, d.checkoutSessions, root).catch((err) => [{ file: "", line: 0, skipped: err instanceof Error ? err.message : String(err) }]);
30464
31436
  if (configs.length) stripeOut.checkout_configs = configs;
30465
31437
  }
30466
- 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)]);
30467
31439
  return EXIT.ok;
30468
31440
  }
30469
31441
  if (dryRun && (!state.setup_session || !creds)) {
@@ -30492,10 +31464,11 @@ var init_init = __esm({
30492
31464
  let token = null;
30493
31465
  let apiKey = creds?.apiKey;
30494
31466
  let sessionId = state.setup_session;
30495
- 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);
30496
31469
  if (!reuse) {
30497
31470
  const anon = ctx.client({ apiKey: "device-login", apiBase });
30498
- 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;
30499
31472
  if (!pending) {
30500
31473
  const agent2 = detectAgent(ctx.env, agentName);
30501
31474
  const wantStripe = bool(flags, "stripe") !== false && d.billingPlatform === "stripe" && !!d.stripeTestKey;
@@ -30508,7 +31481,8 @@ var init_init = __esm({
30508
31481
  ...merchantName ? { merchant_name: merchantName.slice(0, 100) } : {},
30509
31482
  framework: d.framework,
30510
31483
  ...d.packageManager ? { package_manager: d.packageManager } : {},
30511
- 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 } : {},
30512
31486
  ...bool(flags, "live") ? { live: true } : {},
30513
31487
  ...wantStripe ? { stripe_test_key_hint: d.stripeTestKey.hint } : {}
30514
31488
  });
@@ -30539,7 +31513,7 @@ var init_init = __esm({
30539
31513
  expires_at: pending.expires_at,
30540
31514
  next_action: {
30541
31515
  type: "ask_human_to_approve",
30542
- 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"}).`,
30543
31517
  command: "checkbox init --yes --json",
30544
31518
  url: pending.verification_uri_complete
30545
31519
  }
@@ -30623,7 +31597,9 @@ var init_init = __esm({
30623
31597
  if (session.install.script_tag && session.install.snippet_key) {
30624
31598
  if (bool(flags, "snippet") === false) manualSnippet = session.install.script_tag;
30625
31599
  else {
30626
- const plan2 = planSnippet(d, { scriptTag: session.install.script_tag, snippetKey: session.install.snippet_key, apiBase });
31600
+ const pinned = bool(flags, "pin") === false ? void 0 : await snippetRelease(client);
31601
+ if (!pinned && bool(flags, "pin") !== false) warnings.push("The pinned snippet release (/s/latest.json) could not be read, so the unpinned /s.js tag was used. Run `checkbox init` again to pin it with Subresource Integrity.");
31602
+ const plan2 = planSnippet(d, { scriptTag: session.install.script_tag, snippetKey: session.install.snippet_key, apiBase, ...pinned ? { pinned } : {} });
30627
31603
  if (plan2.change) changes.push(plan2.change);
30628
31604
  if (plan2.manual) manualSnippet = plan2.manual;
30629
31605
  }
@@ -30680,9 +31656,12 @@ var init_init = __esm({
30680
31656
  }
30681
31657
  const live = {};
30682
31658
  if (!dryRun && bool(flags, "live") && siteUrl) {
30683
- const liveKey = token?.live_secret_key ?? void 0;
30684
- if (!liveKey) {
30685
- 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}`);
30686
31665
  } else {
30687
31666
  try {
30688
31667
  const lc = ctx.client({ apiKey: liveKey, apiBase });
@@ -30696,7 +31675,7 @@ var init_init = __esm({
30696
31675
  changes.push(wk);
30697
31676
  }
30698
31677
  }
30699
- session = await client.sdk.setupSessions.update(sessionId, { live_site: liveSite.id });
31678
+ session = await lc.sdk.setupSessions.update(sessionId, { live_site: liveSite.id });
30700
31679
  saveState(ctx, root, { live_site: liveSite.id });
30701
31680
  live.site = liveSite.id;
30702
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 };
@@ -30709,7 +31688,8 @@ var init_init = __esm({
30709
31688
  out.info("Running the first checks\u2026");
30710
31689
  session = (await client.sdk.setupSessions.verify(sessionId)).setup_session;
30711
31690
  }
30712
- 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;
30713
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)]);
30714
31694
  return EXIT.ok;
30715
31695
  }