@checkbox-site/cli 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/checkbox.js +580 -142
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -2612,7 +2612,7 @@ var init_operations = __esm({
2612
2612
  /**
2613
2613
  * Get the compliance plan for a site.
2614
2614
  *
2615
- * Ordered tasks that make the site's checks pass, recomputed from its latest evaluation: each with the requirement and citation, where to change (file / URL / selector / Stripe object), the exact change (unified-diff template with machine-applicable edits, copy, or Stripe params) and how to verify it. Do plan.next_action: apply the first open task, call verifyTask, repeat until status is compliant. Tasks with status manual hold a question for the human (answerTask); they never block the others.
2615
+ * Ordered tasks that make the site's checks pass, recomputed from its latest evaluation: each with the requirement and citation, where to change (file / URL / selector / Stripe object), the exact change (unified-diff template with machine-applicable edits, copy, or Stripe params) and how to verify it. Do plan.next_action: apply the first open task, call verifyTask, and repeat until the checks pass (status value compliant; status_label "Checks pass"). Tasks with status manual hold a question for the human (answerTask); they never block the others.
2616
2616
  *
2617
2617
  * @param site Unique identifier of the site (site_…).
2618
2618
  * @see GET /v1/sites/{site}/plan (operation `retrieveCompliancePlan`, scope `compliance_plans:read`)
@@ -2831,7 +2831,7 @@ var init_operations = __esm({
2831
2831
  /**
2832
2832
  * Update notice settings.
2833
2833
  *
2834
- * Chooses who sends the required emails (mode: merchant = you send them and Checkbox tracks them, the default; checkbox = Checkbox sends them), and sets branding, cancel_url and the confirmed cancellation policy. In checkbox mode, turning a notice on (enabled_kinds) requires cancel_url, a confirmed policy, a connected Stripe, Recharge, Chargebee, Recurly or Paddle account and a sending address; otherwise resource_conflict explains what is missing.
2834
+ * Chooses who sends the required emails (mode: merchant = you send them and Checkbox tracks them, the default; checkbox = Checkbox sends them), and sets branding, cancel_url and the confirmed cancellation policy. add_kinds / remove_kinds turn single notices on or off and leave the others as they are; enabled_kinds replaces the whole list. In checkbox mode, turning a notice on requires cancel_url, a confirmed policy, a connected Stripe, Recharge, Chargebee, Recurly or Paddle account and a sending address; otherwise resource_conflict explains what is missing.
2835
2835
  *
2836
2836
  * @param site Unique identifier of the site (site_…).
2837
2837
  * @see POST /v1/sites/{site}/notice_settings (operation `updateNoticeSettings`, scope `notices:write`, live mode needs an activated account)
@@ -4632,6 +4632,13 @@ var init_stripe_ids = __esm({
4632
4632
  }
4633
4633
  });
4634
4634
 
4635
+ // ../core/src/go-live.ts
4636
+ var init_go_live = __esm({
4637
+ "../core/src/go-live.ts"() {
4638
+ "use strict";
4639
+ }
4640
+ });
4641
+
4635
4642
  // ../core/src/index.ts
4636
4643
  var init_src2 = __esm({
4637
4644
  "../core/src/index.ts"() {
@@ -4640,6 +4647,7 @@ var init_src2 = __esm({
4640
4647
  init_jurisdictions();
4641
4648
  init_todo();
4642
4649
  init_stripe_ids();
4650
+ init_go_live();
4643
4651
  }
4644
4652
  });
4645
4653
 
@@ -4829,7 +4837,7 @@ var init_california = __esm({
4829
4837
  notes: notes(
4830
4838
  AB2863_APPLICABILITY,
4831
4839
  "Misrepresentation is only partly machine-detectable; the auto-renewal language check catches the most common case (offers that hide or deny the renewal).",
4832
- '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, $150M).',
4840
+ '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).',
4833
4841
  '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.'
4834
4842
  )
4835
4843
  }),
@@ -5064,7 +5072,7 @@ var init_federal = __esm({
5064
5072
  ROSCA_EFFECTIVE = "2010-12-29";
5065
5073
  REG_E_URL = "https://www.ecfr.gov/current/title-12/chapter-X/part-1005/subpart-A/section-1005.10";
5066
5074
  EFTA_EFFECTIVE = "1980-05-10";
5067
- ROSCA_SCOPE = `Applies to goods or services sold in a transaction effected on the Internet through a "negative option feature" (16 C.F.R. \xA7 310.2(w): a provision under which the customer's silence or failure to take affirmative action to reject goods or services or to cancel the agreement is interpreted as acceptance) \u2014 i.e. free-to-pay conversions, automatic renewals and continuity plans sold online. Enforced by the FTC (violations are treated as rule violations under FTC Act \xA7 18, enabling civil penalties and consumer redress) and by state attorneys general.`;
5075
+ ROSCA_SCOPE = `Applies to goods or services sold in a transaction effected on the Internet through a "negative option feature" (16 C.F.R. \xA7 310.2(w): a provision under which the customer's silence or failure to take affirmative action to reject goods or services or to cancel the agreement is interpreted as acceptance) \u2014 i.e. free-to-pay conversions, automatic renewals and continuity plans sold online. The definition was \xA7 310.2(u) when ROSCA was enacted; the FTC redesignated it \xA7 310.2(w) effective Feb. 12, 2016 (80 Fed. Reg. 77520, 77558), so older sources cite (u). Enforced by the FTC (violations are treated as rule violations under FTC Act \xA7 18, enabling civil penalties and consumer redress) and by state attorneys general.`;
5068
5076
  FEDERAL_REQUIREMENTS = [
5069
5077
  draft({
5070
5078
  id: "US-FED.8403.1",
@@ -5497,7 +5505,7 @@ var init_exposure = __esm({
5497
5505
  BASE_JURISDICTION_EXPOSURE = {
5498
5506
  "US-FED": {
5499
5507
  privateRightOfAction: "ag_only",
5500
- remedies: "ROSCA: enforced by the FTC and state attorneys general; no private right of action. A violation is treated as a violation of an FTC trade regulation rule (15 U.S.C. \xA7 8404(a)), so the FTC Act's penalties apply (\xA7 8404(b)): civil penalties up to $53,088 per violation (15 U.S.C. \xA7 45(m)(1)(A), as adjusted by 16 C.F.R. \xA7 1.98(d), 2025 adjustment, for penalties assessed after January 17, 2025; still the current figure as of 2026-11-08) and consumer redress. Examples: Amazon, a $1B civil penalty + $1.5B refunds ([FTC press release](https://www.ftc.gov/news-events/news/press-releases/2025/09/ftc-secures-historic-25-billion-settlement-against-amazon)); Adobe, a $150M settlement ([Justice Department press release](https://www.justice.gov/usao-ndca/pr/adobe-agrees-150-million-settlement-and-injunction-resolve-alleged-violations-restore)).",
5508
+ remedies: "ROSCA: enforced by the FTC and state attorneys general; no private right of action. A violation is treated as a violation of an FTC trade regulation rule (15 U.S.C. \xA7 8404(a)), so the FTC Act's penalties apply (\xA7 8404(b)): civil penalties up to $53,088 per violation (15 U.S.C. \xA7 45(m)(1)(A), as adjusted by 16 C.F.R. \xA7 1.98(d), 2025 adjustment, for penalties assessed after January 17, 2025; still the current figure as of 2026-11-08) and consumer redress. Examples: Amazon, a $1B civil penalty + $1.5B refunds ([FTC press release](https://www.ftc.gov/news-events/news/press-releases/2025/09/ftc-secures-historic-25-billion-settlement-against-amazon)); Adobe (Justice Department complaint filed June 2024 in the Northern District of California).",
5501
5509
  civilPenalties: true,
5502
5510
  restitution: true
5503
5511
  },
@@ -6113,7 +6121,11 @@ var init_new_york = __esm({
6113
6121
  scope: 'Consumers (personal, family or household purposes; definitions in \xA7 527). Exempt (\xA7 527-a(4)): services under a municipal franchise, DFS-regulated entities, security alarm operators, banks/credit unions/financial institutions, and service contract sellers and administrators (Ins. Law \xA7 7902). Related but not cataloged: GBL \xA7 5-903 (renewal notice for service, maintenance or repair contracts for real or personal property). New York City consumers are also covered by the stricter DCWP "Click to Cancel" rule (US-NY.NYC.*, effective Oct. 1, 2026).',
6114
6122
  amendments: [
6115
6123
  { citation: "L. 2023, ch. 728 (S5941-B)", effectiveDate: CH728_2023, summary: "15-45 day renewal notice for initial paid terms of one year or longer." },
6116
- { citation: "L. 2025, ch. 58, Part W (S.3008-C/A.3008-C)", effectiveDate: PART_W, summary: "Comprehensive rewrite of \xA7 527-a (see file header)." }
6124
+ {
6125
+ citation: "L. 2025, ch. 58, Part W (S.3008-C/A.3008-C)",
6126
+ effectiveDate: PART_W,
6127
+ summary: "Rewrote \xA7 527-a: material terms disclosed before consent or billing information is requested; price increases need consent, or a right to cancel for at least 14 days after the charge with a pro-rata refund; a notice after consent; cancellation as easy as sign-up, in the same medium and in every medium used to consent, with no obstruction; material-change notice at least 5 business days and no more than 30 days ahead; notice 3-21 days before the cancellation deadline when a free gift or trial of more than a month converts to paid."
6128
+ }
6117
6129
  ]
6118
6130
  },
6119
6131
  {
@@ -9727,7 +9739,7 @@ var init_catalog = __esm({
9727
9739
  init_states();
9728
9740
  init_terms();
9729
9741
  init_types3();
9730
- CATALOG_VERSION = "2026.11.08.5";
9742
+ CATALOG_VERSION = "2026.11.09.1";
9731
9743
  CATALOG = [
9732
9744
  ...FEDERAL_REQUIREMENTS,
9733
9745
  ...CALIFORNIA_REQUIREMENTS,
@@ -10363,7 +10375,9 @@ var init_text = __esm({
10363
10375
  /\breply\s+(?:to\s+this\s+email\s+)?(?:with\s+)?["']?(?:cancel|stop)["']?/gi
10364
10376
  ];
10365
10377
  TRIAL_MENTION_PATTERNS = [
10366
- /\b(?:free\s+)?trial(?:s|\s+period)?\b/gi,
10378
+ // Round 21 (pilot 20, change-guard): "trial-ending" / "trial reminder" names a kind of notice (a dropdown of notice
10379
+ // channels), not a free trial on offer.
10380
+ /\b(?:free\s+)?trial(?:s|\s+period)?\b(?!-end(?:ing|s)?\b)(?!\s+(?:ending\s+)?(?:reminders?|notices?|emails?|alerts?)\b)/gi,
10367
10381
  // "Try for $0.00", "Try 1 month for $0" (calibration corpus: Audible, Spotify)
10368
10382
  /\btry\s+(?:[\w-]+\s+){0,3}?for\s+(?:US)?\$\s?0(?:\.00)?(?![\d.,])/gi,
10369
10383
  /\btry\s+(?:it\s+)?(?:for\s+)?free\b/gi,
@@ -10469,6 +10483,12 @@ function policyKindOf(c) {
10469
10483
  const m = new RegExp(`#${POLICY_FRAGMENT}=([a-z]+)$`).exec(c.url ?? "");
10470
10484
  return m && isPolicyKind(m[1]) ? m[1] : null;
10471
10485
  }
10486
+ function isUnreadStaticFetch(c) {
10487
+ return c.userAgent === POLICY_FETCHER_USER_AGENT && !!c.pageProblem && /almost no text/i.test(c.pageProblem) && !/even rendered/i.test(c.pageProblem);
10488
+ }
10489
+ function renderedRead(c) {
10490
+ return c.userAgent !== POLICY_FETCHER_USER_AGENT && !c.pageProblem;
10491
+ }
10472
10492
  function policyDocuments(captures) {
10473
10493
  const best = /* @__PURE__ */ new Map();
10474
10494
  for (const c of captures) {
@@ -10476,7 +10496,13 @@ function policyDocuments(captures) {
10476
10496
  if (!kind) continue;
10477
10497
  const key = `${kind}|${policySourceUrl(c.url)}`;
10478
10498
  const prev = best.get(key);
10479
- if (!prev || Date.parse(c.capturedAt) > Date.parse(prev.capture.capturedAt)) best.set(key, { kind, capture: c });
10499
+ const newer = !prev || Date.parse(c.capturedAt) > Date.parse(prev.capture.capturedAt);
10500
+ if (prev && newer && isUnreadStaticFetch(c) && renderedRead(prev.capture)) continue;
10501
+ if (prev && !newer && isUnreadStaticFetch(prev.capture) && renderedRead(c)) {
10502
+ best.set(key, { kind, capture: c });
10503
+ continue;
10504
+ }
10505
+ if (newer) best.set(key, { kind, capture: c });
10480
10506
  }
10481
10507
  return [...best.values()].sort((a, b) => POLICY_KINDS.indexOf(a.kind) - POLICY_KINDS.indexOf(b.kind) || a.capture.url.localeCompare(b.capture.url));
10482
10508
  }
@@ -10534,13 +10560,14 @@ function analyzePolicyText(input) {
10534
10560
  function clausesOf(clauses, code2) {
10535
10561
  return clauses.filter((c) => c.code === code2);
10536
10562
  }
10537
- var POLICY_KINDS, POLICY_FRAGMENT, SUBSCRIPTION_WORDS, NO_CANCEL, OFFLINE_ONLY, CONTACT_TO_CANCEL, ACCOUNT_NOT_PLACE, CANCEL_ONLINE_STRONG, NOTICE_PERIOD, ALL_SALES_FINAL, CANCEL_ONLINE, AUTO_RENEWAL, HOW_TO_CANCEL, NO_REFUNDS, REFUND_WINDOW, GUARANTEE, REFUND_EXCEPTION;
10563
+ var POLICY_KINDS, POLICY_FRAGMENT, POLICY_FETCHER_USER_AGENT, SUBSCRIPTION_WORDS, NO_CANCEL, OFFLINE_ONLY, CONTACT_TO_CANCEL, ACCOUNT_NOT_PLACE, CANCEL_ONLINE_STRONG, NOTICE_PERIOD, ALL_SALES_FINAL, CANCEL_ONLINE, AUTO_RENEWAL, HOW_TO_CANCEL, NO_REFUNDS, REFUND_WINDOW, GUARANTEE, REFUND_EXCEPTION;
10538
10564
  var init_policy_docs = __esm({
10539
10565
  "../rules/src/policy-docs.ts"() {
10540
10566
  "use strict";
10541
10567
  init_text();
10542
10568
  POLICY_KINDS = ["terms", "refund", "cancellation", "privacy"];
10543
10569
  POLICY_FRAGMENT = "checkbox-policy";
10570
+ POLICY_FETCHER_USER_AGENT = "CheckboxPolicyFetcher/1.0 (+https://checkbox.site/docs/policy-fetcher)";
10544
10571
  SUBSCRIPTION_WORDS = /\b(subscriptions?|memberships?|plans?|renew\w*|recurring|billing|agreement|services?|account)\b/i;
10545
10572
  NO_CANCEL = [
10546
10573
  /\bno cancell?ations?\b(?!\s+(?:fees?|charges?|penalt))/i,
@@ -11211,6 +11238,30 @@ function declaredPlansFrom(plans) {
11211
11238
  });
11212
11239
  return out;
11213
11240
  }
11241
+ function withIntroOffers(declared, billing, offers) {
11242
+ if (!offers?.length) return [...declared];
11243
+ const introFields = (o) => ({
11244
+ ...o.introAmount !== void 0 ? { intro_amount: o.introAmount } : {},
11245
+ ...o.introPercentOff !== void 0 ? { intro_percent_off: o.introPercentOff } : {},
11246
+ ...o.introAmountOff !== void 0 ? { intro_amount_off: o.introAmountOff } : {},
11247
+ ...o.introPeriods !== void 0 ? { intro_periods: o.introPeriods } : {}
11248
+ });
11249
+ const hasIntro2 = (x) => ["intro_amount", "intro_price", "intro_percent_off", "intro_amount_off"].some((k) => x[k] !== void 0 && x[k] !== null);
11250
+ const base = declared.length ? [...declared] : billing.filter((p) => p && p.amount > 0 && p.interval).map((p) => ({ name: p.name, price_id: p.id, amount: p.amount, currency: (p.currency || "usd").toLowerCase(), interval: p.interval, interval_count: p.intervalCount || 1, ...p.trialDays && p.trialDays > 0 ? { trial_days: p.trialDays } : {} }));
11251
+ const mapped = reconcileDeclaredPlans(base, billing).plans;
11252
+ const key = (p) => `${p.amount}|${(p.currency || "usd").toLowerCase()}|${p.interval}|${p.intervalCount || 1}`;
11253
+ return base.map((raw, i) => {
11254
+ if (!raw || typeof raw !== "object") return raw;
11255
+ const x = raw;
11256
+ const ids = [...declaredPriceIds(x), ...declaredPriceIds(mapped[i] ?? {})];
11257
+ const d = declaredPlansFrom([x])[0];
11258
+ if (d) {
11259
+ for (const p of billing) if (p && key(p) === key(d)) ids.push(p.id);
11260
+ }
11261
+ const o = offers.find((e) => ids.includes(e.priceId));
11262
+ return o && !hasIntro2(x) ? { ...x, ...introFields(o) } : raw;
11263
+ });
11264
+ }
11214
11265
  function shortPlanTerms(p) {
11215
11266
  const digits = currencyDigitsOf(p.currency);
11216
11267
  const major = p.amount / 10 ** digits;
@@ -11268,13 +11319,18 @@ function reconcileDeclaredPlans(raw, billing, platform = "Stripe") {
11268
11319
  const wa = nameWords(a);
11269
11320
  return nameWords(b).some((w) => wa.includes(w));
11270
11321
  };
11322
+ const anchorPrices = prices.filter((p) => claimed.has(p.id));
11271
11323
  parsed.forEach((d, i) => {
11272
11324
  if (!d || match[i]) return;
11273
11325
  if (prices.some((p) => key(p) === key(d))) return;
11274
11326
  const same = prices.filter((p) => !claimed.has(p.id) && p.interval === d.interval && (p.intervalCount || 1) === (d.intervalCount || 1) && (p.currency || "usd").toLowerCase() === (d.currency || "usd").toLowerCase());
11275
- if (same.length !== 1 || !(namesMatch(d, same[0]) || anchored && norm2(same[0].name).length < 3)) return;
11276
- match[i] = { price: same[0], by: "interval" };
11277
- claimed.add(same[0].id);
11327
+ if (same.length !== 1) return;
11328
+ const cand = same[0];
11329
+ const sameTrial = (cand.trialDays ?? 0) > 0 && cand.trialDays === d.trialDays;
11330
+ const sameProduct = anchorPrices.some((a) => sameProductName(a.name, cand.name));
11331
+ if (!(namesMatch(d, cand) || anchored && norm2(cand.name).length < 3 || sameTrial || sameProduct)) return;
11332
+ match[i] = { price: cand, by: "interval" };
11333
+ claimed.add(cand.id);
11278
11334
  });
11279
11335
  parsed.forEach((d, i) => {
11280
11336
  const m = match[i];
@@ -11312,7 +11368,8 @@ function reconcileDeclaredPlans(raw, billing, platform = "Stripe") {
11312
11368
  fields,
11313
11369
  declared,
11314
11370
  billing: billed,
11315
- message: `${d.label ? `${d.label}: ` : ""}Your setup says ${declared}; ${platform} price ${p.id} charges ${billed}. Your page must show what ${platform} charges: Checkbox checks every page, email and fix against the ${platform} Price. Update the price on your page and in your setup plans (update_setup), or change the Price in ${platform}.`
11371
+ // Round 21 (item 9): name the plan the merchant knows ("your Annual plan says $79/year, Stripe charges $72/year").
11372
+ message: `${d.label ? `Your ${d.label}${/\bplan$/i.test(d.label.trim()) ? "" : " plan"} says ${declared}; ${platform} charges ${billed} (price ${p.id}).` : `Your setup says ${declared}; ${platform} price ${p.id} charges ${billed}.`} Your page must show what ${platform} charges: Checkbox checks every page, email and fix against the ${platform} Price. Update the price on your page and in your setup plans (updateSetupSession plans[]), or change the Price in ${platform}.`
11316
11373
  });
11317
11374
  });
11318
11375
  return { plans: out, conflicts };
@@ -12623,6 +12680,14 @@ function oldPaymentLinksWithoutConsent(links, captures) {
12623
12680
  if (!linked.length || !linked.some((l) => l.consentTermsOfService === "required")) return [];
12624
12681
  return active.filter((l) => !linked.includes(l) && l.consentTermsOfService !== "required").slice(0, MAX_PAYMENT_LINKS);
12625
12682
  }
12683
+ function unjudgedPaymentLinksWithoutTerms(links, captures, checkoutType) {
12684
+ const judged = new Set([...paymentLinksForSite(links, captures, checkoutType), ...oldPaymentLinksWithoutConsent(links, captures)].map((l) => l.id));
12685
+ const active = links.filter((l) => l.active !== false && (l.lineItems ?? []).some((li) => !!li.interval) && !judged.has(l.id));
12686
+ return active.filter((l) => {
12687
+ const a = analyzeTerms(l.customText?.submit ?? "");
12688
+ return !(a.autoRenew.length || a.untilCanceled.length);
12689
+ }).slice(0, MAX_PAYMENT_LINKS);
12690
+ }
12626
12691
  function paymentLinkName(l) {
12627
12692
  const product = l.lineItems?.find((li) => li.productName)?.productName?.trim();
12628
12693
  return `${product ? `\u201C${product}\u201D ` : ""}(${l.id}${l.url ? `, ${l.url}` : ""})`;
@@ -12791,12 +12856,13 @@ function isLoginPage(c) {
12791
12856
  }
12792
12857
  function selectSignupCaptures(captures, opts = {}) {
12793
12858
  const declared = new Set((opts.nonSignupUrls ?? []).filter((u) => typeof u === "string" && u.trim()).map((u) => pageKey(u.trim())));
12859
+ const selling = new Set((opts.signupUrls ?? []).filter((u) => typeof u === "string" && u.trim()).map((u) => pageKey(u.trim())));
12794
12860
  const latest = latestCaptures(captures).filter((c) => !isLoginPage(c) && !declared.has(pageKey(c.url ?? "")));
12795
12861
  const checkout = latest.filter((c) => c.pageKind === "checkout");
12796
12862
  const knownKind = /* @__PURE__ */ new Map();
12797
12863
  for (const c of latest) if (c.pageKind !== "other") knownKind.set(pageKey(c.url ?? ""), c.pageKind);
12798
12864
  const other = latest.filter(
12799
- (c) => c.pageKind !== "checkout" && !NON_SIGNUP_KINDS.has(c.pageKind) && !(c.pageKind === "other" && knownKind.has(pageKey(c.url ?? "")) && knownKind.get(pageKey(c.url ?? "")) !== "checkout") && (c.ctas?.length ?? 0) > 0 && (c.disclosures?.length ?? 0) > 0 && (checkout.length === 0 || c.pageKind !== "pricing") && // Pilot round 11: a page nobody labelled the checkout is a sign-up page only with a purchase button in its main
12865
+ (c) => c.pageKind !== "checkout" && !NON_SIGNUP_KINDS.has(c.pageKind) && !(c.pageKind === "other" && knownKind.has(pageKey(c.url ?? "")) && knownKind.get(pageKey(c.url ?? "")) !== "checkout") && (c.ctas?.length ?? 0) > 0 && ((c.disclosures?.length ?? 0) > 0 || selling.has(pageKey(c.url ?? ""))) && (checkout.length === 0 || c.pageKind !== "pricing") && // Pilot round 11: a page nobody labelled the checkout is a sign-up page only with a purchase button in its main
12800
12866
  // content; header / nav / footer links and "Cancel subscription" are not purchase buttons, and an account-area
12801
12867
  // path (/account, /billing, /settings…) is not a sign-up page.
12802
12868
  !(c.pageKind !== "pricing" && ACCOUNT_AREA_PATH_RE.test(pathOf(c.url ?? ""))) && hasMainPurchaseCta(c)
@@ -12970,12 +13036,15 @@ function emailLabel(e) {
12970
13036
  const when = toDate(e.receivedAt)?.toISOString().slice(0, 10) ?? "unknown date";
12971
13037
  return `${quoted} (${when})`;
12972
13038
  }
13039
+ function emailTemplateKey(e) {
13040
+ const from = (e.from ?? "").toLowerCase().replace(/^.*</, "").replace(/>.*$/, "").trim();
13041
+ const subject = (e.subject ?? "").toLowerCase().replace(/^(?:\s*(?:re|fwd?|fw)\s*:\s*)+/i, "").replace(/#?\w*\d[\w-]*/g, "#").replace(/\s+/g, " ").trim();
13042
+ return `${from}|${subject}`;
13043
+ }
12973
13044
  function latestPerTemplate(emails) {
12974
13045
  const map = /* @__PURE__ */ new Map();
12975
13046
  for (const e of emails) {
12976
- const from = (e.from ?? "").toLowerCase().replace(/^.*</, "").replace(/>.*$/, "").trim();
12977
- const subject = (e.subject ?? "").toLowerCase().replace(/^(?:\s*(?:re|fwd?|fw)\s*:\s*)+/i, "").replace(/#?\w*\d[\w-]*/g, "#").replace(/\s+/g, " ").trim();
12978
- const key = `${from}|${subject}`;
13047
+ const key = emailTemplateKey(e);
12979
13048
  const prev = map.get(key);
12980
13049
  if (!prev || time(e.receivedAt) > time(prev.receivedAt)) map.set(key, e);
12981
13050
  }
@@ -13057,14 +13126,23 @@ function annotateSignupViews(views, ctx) {
13057
13126
  }
13058
13127
  return views;
13059
13128
  }
13129
+ function staleAccountCaptures(captures, flowSteps = []) {
13130
+ const scannerAccount = (c) => c.pageKind === "account" && c.source !== "snippet";
13131
+ const times = [...captures.filter(scannerAccount), ...flowSteps.filter((s2) => s2.pageKind === "account" && s2.source !== "snippet")].map((c) => time(c.capturedAt)).filter((t) => t > 0);
13132
+ if (!times.length) return [];
13133
+ const newest = Math.max(...times);
13134
+ return captures.filter((c) => scannerAccount(c) && time(c.capturedAt) > 0 && time(c.capturedAt) < newest - ACCOUNT_REFRESH_WINDOW_MS);
13135
+ }
13060
13136
  function buildEvaluationData(ctx) {
13061
13137
  const captures = ctx.captures ?? [];
13062
13138
  const latest = latestCaptures(captures);
13063
13139
  const flowSteps = ctx.cancellation?.steps ?? [];
13140
+ const stale = new Set(staleAccountCaptures(latest, flowSteps));
13064
13141
  const emails = (ctx.emails ?? []).filter((e) => toDate(e.receivedAt)).sort((a, b) => time(a.receivedAt) - time(b.receivedAt));
13065
13142
  return {
13066
- signupViews: annotateSignupViews([...dropNotCheckoutViews(selectSignupCaptures(captures, { nonSignupUrls: ctx.nonSignupUrls ?? [] }).flatMap(buildSignupViews)), ...checkoutConfigViews(ctx)], ctx),
13067
- accountPages: [...latest.filter((c) => c.pageKind === "account"), ...flowSteps.filter((s2) => s2.pageKind === "account")],
13143
+ signupViews: annotateSignupViews([...dropNotCheckoutViews(selectSignupCaptures(captures, { nonSignupUrls: ctx.nonSignupUrls ?? [], signupUrls: ctx.signupUrls ?? [] }).flatMap(buildSignupViews)), ...checkoutConfigViews(ctx)], ctx),
13144
+ accountPages: [...latest.filter((c) => c.pageKind === "account" && !stale.has(c)), ...flowSteps.filter((s2) => s2.pageKind === "account")],
13145
+ staleAccountPages: [...stale],
13068
13146
  cancelPages: [...latest.filter((c) => c.pageKind === "cancel"), ...flowSteps.filter((s2) => s2.pageKind === "cancel")],
13069
13147
  emails,
13070
13148
  emailCaptureStart: emails.length ? toDate(emails[0].receivedAt) ?? void 0 : void 0,
@@ -13153,7 +13231,7 @@ ${v.pageText}`));
13153
13231
  sources
13154
13232
  };
13155
13233
  }
13156
- var DAY_MS, HOUR_MS, NON_SIGNUP_KINDS, LOGIN_PATH_RE, LOGIN_TEXT_RE, ACCOUNT_AREA_PATH_RE, FOOTER_LINK_RE, CTA_STRONG, CTA_PURCHASE, CTA_WEAK, CTA_EXPRESS, CTA_NOT_PURCHASE, STRIPE_PAY_HREF_RE, OFFER_TEXT_RE, DEFAULT_VIEWPORT, POST_PURCHASE_PATH_RE, POST_PURCHASE_TEXT_RE;
13234
+ var DAY_MS, HOUR_MS, NON_SIGNUP_KINDS, LOGIN_PATH_RE, LOGIN_TEXT_RE, ACCOUNT_AREA_PATH_RE, FOOTER_LINK_RE, CTA_STRONG, CTA_PURCHASE, CTA_WEAK, CTA_EXPRESS, CTA_NOT_PURCHASE, STRIPE_PAY_HREF_RE, OFFER_TEXT_RE, DEFAULT_VIEWPORT, POST_PURCHASE_PATH_RE, POST_PURCHASE_TEXT_RE, ACCOUNT_REFRESH_WINDOW_MS;
13157
13235
  var init_context = __esm({
13158
13236
  "../rules/src/checks/context.ts"() {
13159
13237
  "use strict";
@@ -13182,6 +13260,7 @@ var init_context = __esm({
13182
13260
  };
13183
13261
  POST_PURCHASE_PATH_RE = /\/(?:welcome|thank[-_]?you|thanks|success|order[-_]?(?:complete|confirmation|received)|confirmation)(?:[/?#.]|$)/i;
13184
13262
  POST_PURCHASE_TEXT_RE = /\b(?:you(?:['’]re| are)\s+(?:now\s+)?(?:subscribed|all\s+set|in)|thanks?\s+(?:you\s+)?for\s+(?:subscribing|signing\s+up|your\s+(?:order|purchase|subscription))|subscription\s+(?:is\s+)?(?:confirmed|active)|order\s+(?:confirmed|complete)|welcome\s+aboard|payment\s+(?:successful|received))\b/i;
13263
+ ACCOUNT_REFRESH_WINDOW_MS = 30 * 6e4;
13185
13264
  }
13186
13265
  });
13187
13266
 
@@ -13474,7 +13553,8 @@ function inAppPath(input) {
13474
13553
  }
13475
13554
  const hosted = input.ctx.hostedCancel;
13476
13555
  const captures = input.ctx.captures ?? [];
13477
- for (const p of [...input.data.cancelPages, ...captures.filter((c) => c.pageKind === "account")]) {
13556
+ const stale = new Set(input.data?.staleAccountPages ?? []);
13557
+ for (const p of [...input.data.cancelPages, ...captures.filter((c) => c.pageKind === "account" && !stale.has(c))]) {
13478
13558
  if (p.source !== "scanner" || isFetchedDocument(p) || p.pageProblem || isLiveHostedCancelPage(p, hosted)) continue;
13479
13559
  const controls = ownOnlineCancelControls(p, hosted).filter((el) => !/order/i.test(el.text ?? ""));
13480
13560
  const leadsOnline = controls.filter((el) => {
@@ -13533,7 +13613,8 @@ var init_cancel_paths = __esm({
13533
13613
 
13534
13614
  // ../rules/src/checks/cancellation.ts
13535
13615
  function offlineOnlyCancellation(input) {
13536
- const captures = input.ctx.captures ?? [];
13616
+ const stale = new Set(input.data?.staleAccountPages ?? []);
13617
+ const captures = (input.ctx.captures ?? []).filter((c) => !stale.has(c));
13537
13618
  const hosted = input.ctx.hostedCancel;
13538
13619
  const helpHits = [];
13539
13620
  for (const c of captures) {
@@ -13908,6 +13989,31 @@ function pageFindability(page, flow, hosted) {
13908
13989
  }
13909
13990
  return { status: "fail", detail: `no cancel or manage-subscription option on ${page.url}`, confidence: 0.7, refs: refs2 };
13910
13991
  }
13992
+ function newestCapturedAt(pages) {
13993
+ let best;
13994
+ for (const p of pages) if (p.capturedAt && (!best || Date.parse(p.capturedAt) > Date.parse(best))) best = p.capturedAt;
13995
+ return best;
13996
+ }
13997
+ function utcMinute(iso) {
13998
+ const d = new Date(iso);
13999
+ return Number.isNaN(d.getTime()) ? iso : `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
14000
+ }
14001
+ function notRefreshedUnits(stale, fresh) {
14002
+ const freshDevices = new Set(fresh.map((p) => p.device));
14003
+ const byDevice = /* @__PURE__ */ new Map();
14004
+ for (const p of stale) if (!freshDevices.has(p.device)) byDevice.set(p.device, [...byDevice.get(p.device) ?? [], p]);
14005
+ return [...byDevice].map(([device, pages]) => {
14006
+ const at = newestCapturedAt(pages) ?? pages[0].capturedAt;
14007
+ return {
14008
+ label: `${device} account area`,
14009
+ status: "needs_review",
14010
+ detail: `not refreshed \u2014 last captured ${utcMinute(at)}; the newest scan did not capture it again, so that old capture is not judged. To refresh it, call verifyTask again (each scan logs in with the test account on every device); if it stays like this, check test_account (login_url, email, password) and that the account page loads on that device`,
14011
+ confidence: 0.3,
14012
+ refs: [],
14013
+ meta: { device, capturedAt: at, notRefreshed: true }
14014
+ };
14015
+ });
14016
+ }
13911
14017
  var CANCEL_MAX_STEPS, CANCEL_MAX_RETENTION_OFFERS, CANCEL_LINK_MIN_FONT_PX, CANCEL_LINK_MIN_CONTRAST, CANCEL_FLOW_MAX_AGE_DAYS, CLICK_TO_CANCEL_JURISDICTIONS, OFFER_PERMISSION_JURISDICTIONS, RETENTION_PAGE_RE, OFFER_PERMISSION_RE, ONLINE_ALTERNATIVE_RE, NO_CANCEL_ATTEMPT_MESSAGE, OBSTACLE_BLOCKER_RE, CONTACT_BLOCKER_RE, NOT_FOUND_BLOCKER_RE, RETENTION_BLOCKER_RE, TECHNICAL_BLOCKER_RE, CONTACT_TO_CANCEL_RE, CANCEL_CONTROL_RE, ORDER_ONLY_RE, MANAGE_RE, HOSTED_FLOW_NOTE, onlineAvailable, NO_ACTIVE_SUBSCRIPTION_RE, FIND_RANK, mechanismFindable, flowFriction, CANCEL_CONFIRM_EMAIL_RE, confirmation, CANCELLATION_CHECKS;
13912
14018
  var init_cancellation = __esm({
13913
14019
  "../rules/src/checks/cancellation.ts"() {
@@ -14159,6 +14265,10 @@ var init_cancellation = __esm({
14159
14265
  const id = "cancel.mechanism_findable";
14160
14266
  const flow = input.ctx.cancellation;
14161
14267
  const pages = flow ? input.data.accountPages : input.data.accountPages.filter((p) => !isLiveHostedCancelPage(p, input.ctx.hostedCancel));
14268
+ const staleUnits = notRefreshedUnits(input.data.staleAccountPages ?? [], pages);
14269
+ if (!pages.length && staleUnits.length) {
14270
+ return missingData(id, `Not checked yet \u2014 ${staleUnits.map((u) => `${u.label}: ${u.detail}`).join("; ")}`, { notRefreshed: staleUnits.map((u) => u.meta) });
14271
+ }
14162
14272
  if (!pages.length) {
14163
14273
  if (flow) {
14164
14274
  const b = blockerSummary(flow);
@@ -14192,17 +14302,20 @@ var init_cancellation = __esm({
14192
14302
  const scannerCoverage = pages.some((p) => p.source !== "snippet");
14193
14303
  const results = devicePages.map((p) => p.source === "snippet" ? untrustedSnippetUnit(pageFindability(p, flow, input.ctx.hostedCancel), scannerCoverage) : pageFindability(p, flow, input.ctx.hostedCancel));
14194
14304
  results.sort((a, b) => FIND_RANK[b.status] - FIND_RANK[a.status]);
14195
- units.push({ ...results[0], label: `${device} account area`, refs: results.flatMap((r) => r.refs) });
14305
+ const capturedAt = newestCapturedAt(devicePages);
14306
+ units.push({ ...results[0], label: `${device} account area`, refs: results.flatMap((r) => r.refs), meta: { ...results[0].meta, device, ...capturedAt ? { capturedAt } : {} } });
14196
14307
  }
14308
+ units.push(...staleUnits);
14309
+ const onlyNotRefreshed = staleUnits.length > 0 && !units.some((u) => u.status === "fail") && units.every((u) => u.status === "pass" || u.status === "not_applicable" || staleUnits.includes(u));
14197
14310
  return aggregate(
14198
14311
  id,
14199
14312
  units,
14200
14313
  {
14201
14314
  pass: "The cancel option is easy to find in the account area",
14202
14315
  fail: "The cancel option is not easy to find in the account area",
14203
- needs_review: "Findability of the cancel option needs review"
14316
+ needs_review: onlyNotRefreshed ? "Not checked yet on every device" : "Findability of the cancel option needs review"
14204
14317
  },
14205
- { noun: "device" }
14318
+ { noun: "device", ...staleUnits.length ? { data: { notRefreshed: staleUnits.map((u) => u.meta), ...onlyNotRefreshed ? { missingData: true } : {} } } : {} }
14206
14319
  );
14207
14320
  }
14208
14321
  };
@@ -14696,7 +14809,7 @@ function introEmailFinding(rawText, plans, opts) {
14696
14809
  quote: said,
14697
14810
  reason: "intro_unconfirmed",
14698
14811
  detail: `states an introductory price (\u201C${said}\u201D), but no intro terms are known for ${usable.slice(0, 3).map(nameOf).join(", ")}, so Checkbox cannot confirm it`,
14699
- fix: "Declare the promo on the plan (update_setup plans[]: intro_amount, intro_percent_off or intro_amount_off, with intro_periods), or remove the introductory price from the email if there is none."
14812
+ fix: "Declare the promo on the plan (updateSetupSession plans[]: intro_amount, intro_percent_off or intro_amount_off, with intro_periods), or remove the introductory price from the email if there is none."
14700
14813
  };
14701
14814
  }
14702
14815
  const fits = (p2) => {
@@ -15040,18 +15153,90 @@ function missingSignupAck(input, set) {
15040
15153
  { ...data, missingData: true }
15041
15154
  );
15042
15155
  }
15043
- async function ackDocs(input) {
15156
+ async function ackDocs(input, opts = {}) {
15044
15157
  const set = findAcknowledgments(input.data.emails);
15045
15158
  const source = set.acks.length ? set.acks : set.receipts;
15046
15159
  const docs = [];
15047
- for (const email of latestPerTemplate(source)) {
15160
+ for (const email of opts.perPlan && set.acks.length ? latestPerTemplateAndPlan(source, input) : latestPerTemplate(source)) {
15048
15161
  docs.push({ email, analysis: await input.reader.analyze(emailBody(email), "email") });
15049
15162
  }
15050
15163
  return { docs, receiptsOnly: set.acks.length === 0 && set.receipts.length > 0, set };
15051
15164
  }
15165
+ function ackPlan(e, input, offers) {
15166
+ const p = emailPlan(e, input.ctx, offers);
15167
+ if (p.offer || p.how !== "subscription") return p;
15168
+ const { subscriptionRef: _ref, ...rest } = e;
15169
+ const stated = emailPlan(rest, input.ctx, offers);
15170
+ return stated.offer ? { ...stated, ...p.trial !== void 0 ? { trial: p.trial } : {} } : p;
15171
+ }
15172
+ function latestPerTemplateAndPlan(emails, input) {
15173
+ const offers = contextOffers(input.ctx);
15174
+ if (offers.length < 2) return latestPerTemplate(emails);
15175
+ const at = (e) => toDate(e.receivedAt)?.getTime() ?? 0;
15176
+ const plan2 = new Map(emails.map((e) => [e, ackPlan(e, input, offers).offer?.id ?? ""]));
15177
+ const keep = emails.filter((e) => {
15178
+ const key = emailTemplateKey(e);
15179
+ const p = plan2.get(e);
15180
+ return !emails.some((o) => o !== e && emailTemplateKey(o) === key && at(o) > at(e) && (plan2.get(o) === p || plan2.get(o) === ""));
15181
+ });
15182
+ return keep.sort((a, b) => at(b) - at(a));
15183
+ }
15052
15184
  function docUnit(doc, u) {
15053
15185
  return { ...u, label: emailLabel(doc.email), refs: emailRefs(doc.email), meta: { emailId: doc.email.id, kind: doc.email.kind, ...u.meta } };
15054
15186
  }
15187
+ function missingIntro(a, body, plans) {
15188
+ const restated = plans.filter(
15189
+ (p) => p.amount > 0 && a.recurringPrices.some((r) => r.money.amount !== null && Math.abs(r.money.amount - planAmount(p)) < 5e-3 && !!r.frequency.unit && frequencyMatchesPlan(r.frequency.unit, r.frequency.count, p))
15190
+ );
15191
+ if (!restated.length || !restated.every(hasIntro)) return void 0;
15192
+ if (new RegExp(PERCENT_OFF_RE.source, "i").test(body)) return void 0;
15193
+ const intros = restated.map((p) => p.introAmount / 10 ** currencyDigitsOf(p.currency));
15194
+ if (positiveAmounts(a).some((m) => m.amount !== null && intros.some((x) => Math.abs(x - m.amount) < 5e-3))) return void 0;
15195
+ const phrase = restated.map((p) => introTermsPhrase(p)).join("; ");
15196
+ return { label: phrase, phrase };
15197
+ }
15198
+ function plansWithoutAck(input, docs, units) {
15199
+ const offers = contextOffers(input.ctx);
15200
+ if (offers.length < 2) return null;
15201
+ const covered = /* @__PURE__ */ new Set();
15202
+ let unknownPlan = 0;
15203
+ docs.forEach((doc, i) => {
15204
+ const unit2 = units[i];
15205
+ if (unit2.status !== "pass") return;
15206
+ const p = ackPlan(doc.email, input, offers);
15207
+ if (!p.offer) {
15208
+ unknownPlan += 1;
15209
+ return;
15210
+ }
15211
+ if (p.how === "subscription") {
15212
+ const { subscriptionRef: _ref, ...rest } = doc.email;
15213
+ const stated = emailPlan(rest, input.ctx, offers).offer;
15214
+ if (stated && stated !== p.offer) {
15215
+ units[i] = docUnit(doc, {
15216
+ status: "fail",
15217
+ detail: `went to a subscriber of ${offerLabel(p.offer)} but restates the terms of ${offerLabel(stated)} \u2014 restate the terms of the plan this subscriber has`,
15218
+ confidence: 0.7,
15219
+ meta: { planMismatch: true, perPlan: true }
15220
+ });
15221
+ return;
15222
+ }
15223
+ }
15224
+ covered.add(p.offer);
15225
+ });
15226
+ if (units.some((u) => u.status === "fail")) return null;
15227
+ const missing = offers.filter((o) => !covered.has(o));
15228
+ if (!missing.length) return null;
15229
+ const seen = covered.size ? ` Checkbox has an acknowledgment that restates the terms of ${[...covered].map(offerLabel).join(", ")}.` : "";
15230
+ const unknown = unknownPlan ? ` ${unknownPlan} acknowledgment${unknownPlan > 1 ? "s" : ""} could not be matched to a plan (no subscriber billing data and no single plan price stated).` : "";
15231
+ return makeResult(
15232
+ "confirmation.email.terms",
15233
+ "needs_review",
15234
+ `No acknowledgment that restates the terms of ${missing.map(offerLabel).join(", ")} was captured yet: each plan's subscribers must get an acknowledgment with that plan's own price and billing frequency.${seen}${unknown} ${NO_ACK_EMAIL_MESSAGE}`,
15235
+ docs.flatMap((d) => emailRefs(d.email)),
15236
+ 0.5,
15237
+ { missingData: true, perPlan: true, plansWithoutAck: missing.map((o) => o.id) }
15238
+ );
15239
+ }
15055
15240
  function mentionsCancelling(sentence) {
15056
15241
  return CANCELLING_RE.test(sentence);
15057
15242
  }
@@ -15076,6 +15261,7 @@ var init_confirmation = __esm({
15076
15261
  init_result();
15077
15262
  init_notice_content();
15078
15263
  init_plan_terms();
15264
+ init_intro_terms();
15079
15265
  init_offer();
15080
15266
  init_email_plan();
15081
15267
  ACK_MAX_DELAY_HOURS = 24;
@@ -15167,7 +15353,7 @@ var init_confirmation = __esm({
15167
15353
  id: "confirmation.email.terms",
15168
15354
  usesText: true,
15169
15355
  async run(input) {
15170
- const { docs, receiptsOnly, set } = await ackDocs(input);
15356
+ const { docs, receiptsOnly, set } = await ackDocs(input, { perPlan: true });
15171
15357
  if (!docs.length) return noAckResult("confirmation.email.terms", input, set);
15172
15358
  const headlines = {
15173
15359
  pass: "The acknowledgment restates the automatic-renewal terms",
@@ -15200,6 +15386,15 @@ var init_confirmation = __esm({
15200
15386
  meta: { introMisstated: content.intro.reason, introFix: content.intro.fix }
15201
15387
  });
15202
15388
  }
15389
+ const noIntro = !missing.length ? missingIntro(a, emailBody(doc.email), noticePlanOptions(input.ctx).plans) : void 0;
15390
+ if (noIntro) {
15391
+ return docUnit(doc, {
15392
+ status: "fail",
15393
+ detail: `does not state the introductory price: ${noIntro.label} \u2014 restate the terms subscribers signed up to`,
15394
+ confidence: 0.75,
15395
+ meta: { introMissing: true, introFix: `State the introductory offer in the acknowledgment: ${noIntro.phrase}.` }
15396
+ });
15397
+ }
15203
15398
  if (!missing.length) {
15204
15399
  const span = a.recurringPrices[0] ?? a.untilCanceled[0] ?? a.autoRenew[0];
15205
15400
  const llm = [a.autoRenew[0], a.untilCanceled[0], positiveAmounts(a)[0], a.frequencies[0]].some((s2) => s2?.source === "llm");
@@ -15207,6 +15402,8 @@ var init_confirmation = __esm({
15207
15402
  }
15208
15403
  return docUnit(doc, { status: "fail", detail: `does not state ${missing.join(", ")}`, confidence: receiptsOnly ? 0.75 : 0.85 });
15209
15404
  });
15405
+ const uncovered = receiptsOnly ? null : plansWithoutAck(input, docs, units);
15406
+ if (uncovered && !units.some((u) => u.status === "fail" || u.status === "needs_review")) return uncovered;
15210
15407
  return aggregate("confirmation.email.terms", units, headlines, { noun: "email" });
15211
15408
  }
15212
15409
  };
@@ -16916,6 +17113,15 @@ function untilCancelIsAboutRenewal(text, span) {
16916
17113
  const sentence = text.slice(start, endRel === -1 ? void 0 : span.index + span.text.length + endRel);
16917
17114
  return RENEWAL_CONTEXT_RE.test(sentence.replace(span.text, " "));
16918
17115
  }
17116
+ function withUnjudgedPaymentLinks(input, r) {
17117
+ const links = unjudgedPaymentLinksWithoutTerms(paymentLinksOf(input.ctx.config), input.ctx.captures ?? [], input.ctx.facts?.checkoutType);
17118
+ if (!links.length) return r;
17119
+ const one = links.length === 1;
17120
+ const note = `An active Payment Link in your Stripe account has no renewal terms: ${links.slice(0, 3).map(paymentLinkName).join(", ")}${one ? "" : ` (${links.length} links)`}. No page of your site links to ${one ? "it" : "them"}, but anyone with the URL can still subscribe without seeing the terms. Add the renewal terms to ${one ? "its" : "their"} custom text (and a consent checkbox), or deactivate ${one ? "it" : "them"} in Stripe (Payment Links \u2192 the link \u2192 Deactivate) if ${one ? "it is" : "they are"} no longer used.`;
17121
+ const data = { ...r.data ?? {}, unjudgedPaymentLinks: links.map((l) => ({ id: l.id, ...l.url ? { url: l.url } : {} })) };
17122
+ if (r.status === "fail") return { ...r, message: `${r.message} ${note}`, data };
17123
+ return { ...r, status: "needs_review", message: `Needs review: an active Payment Link with no renewal terms. ${note}${r.status === "pass" ? ` (The checkout itself: ${r.message})` : ` ${r.message}`}`, confidence: Math.min(r.confidence, 0.6), data };
17124
+ }
16919
17125
  function currencyDigits(currency) {
16920
17126
  try {
16921
17127
  return new Intl.NumberFormat("en-US", { style: "currency", currency: (currency || "usd").toUpperCase() }).resolvedOptions().maximumFractionDigits ?? 2;
@@ -17098,7 +17304,7 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
17098
17304
  }
17099
17305
  return unit(
17100
17306
  "needs_review",
17101
- `the terms state an introductory price (\u201C${said2}\u201D), but no intro terms were given for ${plans.slice(0, 4).map((p2) => p2.label).join(", ")}, so Checkbox cannot confirm it \u2014 declare the intro terms on the plan (update_setup plans[]: intro_amount, intro_percent_off or intro_amount_off, with intro_periods; in a preflight check, the same fields), or apply the coupon in the Stripe params`,
17307
+ `the terms state an introductory price (\u201C${said2}\u201D), but no intro terms were given for ${plans.slice(0, 4).map((p2) => p2.label).join(", ")}, so Checkbox cannot confirm it \u2014 declare the intro terms on the plan (Site settings \u2192 Your offer \u2192 Introductory price or coupon; or updateSetupSession plans[]: intro_amount, intro_percent_off or intro_amount_off, with intro_periods; in a preflight check, the same fields), or apply the coupon in the Stripe params`,
17102
17308
  0.5,
17103
17309
  { ...u.quote ? { quote: u.quote } : {}, meta: { ...u.meta ?? {}, introTermsMissing: true } }
17104
17310
  );
@@ -17256,7 +17462,7 @@ function boxNearCta(box, cta) {
17256
17462
  const gap = box.y >= cta.y + cta.height ? box.y - (cta.y + cta.height) : cta.y >= box.y + box.height ? cta.y - (box.y + box.height) : 0;
17257
17463
  return gap <= 200;
17258
17464
  }
17259
- var NO_CHECKOUT_CAPTURE_MESSAGE, NO_PURCHASE_BUTTON, OTHER_PLAN_CARD_NOTE, heuristicCache, PROCESSOR_LINE_NOTE, RENEWAL_CONTEXT_RE, autorenewLanguage, FOOTNOTE_RE, NON_CHARGE_AMOUNT_RE, INTRO_PRICE_RE, RENEWAL_PRICE_RE, PRODUCT_TITLE_RE, price, frequency, CANCEL_LINK_RE, cancellationPolicy, TRIAL_DAYS_SLACK, trialTerms2, termLength, PLACEMENT_RANK, proximity, conspicuous, RENEWAL_CONSENT_RE, CONSENT_CHECKBOX_RE, TOS_RE, SUBSCRIPTION_CTA_RE, MARKETING_RE, RENEWAL_TERMS_RE, NOTIFICATION_OPT_IN_RE, AGREEMENT_RE, SEPARATE_CONSENT_JURISDICTIONS, EXPRESS_CONSENT_COUNSEL_QUESTION, CA_SEPARATE_CONSENT, consentAffirmative, consentOverViews, notPrechecked, SIGNUP_CHECKS;
17465
+ var NO_CHECKOUT_CAPTURE_MESSAGE, NO_PURCHASE_BUTTON, OTHER_PLAN_CARD_NOTE, heuristicCache, PROCESSOR_LINE_NOTE, RENEWAL_CONTEXT_RE, autorenewLanguage, autorenewOverViews, FOOTNOTE_RE, NON_CHARGE_AMOUNT_RE, INTRO_PRICE_RE, RENEWAL_PRICE_RE, PRODUCT_TITLE_RE, price, frequency, CANCEL_LINK_RE, cancellationPolicy, TRIAL_DAYS_SLACK, trialTerms2, termLength, PLACEMENT_RANK, proximity, conspicuous, RENEWAL_CONSENT_RE, CONSENT_CHECKBOX_RE, TOS_RE, SUBSCRIPTION_CTA_RE, MARKETING_RE, RENEWAL_TERMS_RE, NOTIFICATION_OPT_IN_RE, AGREEMENT_RE, SEPARATE_CONSENT_JURISDICTIONS, EXPRESS_CONSENT_COUNSEL_QUESTION, CA_SEPARATE_CONSENT, consentAffirmative, consentOverViews, notPrechecked, SIGNUP_CHECKS;
17260
17466
  var init_signup = __esm({
17261
17467
  "../rules/src/checks/signup.ts"() {
17262
17468
  "use strict";
@@ -17281,73 +17487,74 @@ var init_signup = __esm({
17281
17487
  autorenewLanguage = {
17282
17488
  id: "signup.disclosure.autorenew_language",
17283
17489
  usesText: true,
17284
- run: (input) => overViews(
17285
- "signup.disclosure.autorenew_language",
17286
- input,
17287
- {
17288
- pass: "Checkout states that the subscription renews automatically until canceled",
17289
- fail: "Checkout does not say that the subscription renews automatically until the customer cancels",
17290
- needs_review: "Automatic-renewal language needs review"
17291
- },
17292
- async (view) => {
17293
- const raw = await input.reader.analyze(view.ownTermsText, "checkout_disclosure");
17294
- const a = { ...raw, untilCanceled: raw.untilCanceled.filter((sp) => untilCancelIsAboutRenewal(raw.text, sp)) };
17295
- const span = a.untilCanceled[0] ?? a.autoRenew[0];
17296
- const far = span ? onlyInFarFootnotes(view, (t) => hasRenewalLanguage(analyzeTerms(t))) : void 0;
17297
- if (span && far) {
17298
- return unit(
17299
- "needs_review",
17300
- `renewal language (\u201C${quote(span)}\u201D) appears only in ${far} \u2014 confirm it describes this offer, and state it with the offer`,
17301
- 0.5,
17302
- { quote: quote(span), meta: { ...hostedMeta(view), farFromCta: true } }
17303
- );
17304
- }
17305
- if (span && a.untilCanceled.length === 0) {
17306
- return unit(
17307
- "needs_review",
17308
- `says \u201C${quote(span)}\u201D but not that it continues until the customer cancels \u2014 add \u201Cuntil you cancel\u201D`,
17309
- 0.55,
17310
- { quote: quote(span), meta: { ...hostedMeta(view), missingUntilCanceled: true } }
17311
- );
17312
- }
17313
- if (span) {
17314
- const both = a.untilCanceled.length > 0 && a.autoRenew.length > 0;
17315
- const detail = both ? `states \u201C${quote(a.autoRenew[0])}\u201D and \u201C${quote(a.untilCanceled[0])}\u201D` : `states \u201C${quote(span)}\u201D`;
17316
- return unit("pass", detail, span.source === "llm" ? 0.75 : both ? 0.92 : 0.85, { quote: quote(span), meta: hostedMeta(view) });
17317
- }
17318
- const p = ownPageAnalysis(view);
17319
- const ps = p.untilCanceled.find((sp) => untilCancelIsAboutRenewal(p.text, sp));
17320
- if (!ps && p.autoRenew[0]) {
17321
- return unit("needs_review", `renewal language (\u201C${quote(p.autoRenew[0])}\u201D) appears on the page, outside the detected terms block, and does not say it continues until the customer cancels`, 0.5, {
17322
- quote: quote(p.autoRenew[0]),
17323
- meta: { ...hostedMeta(view), missingUntilCanceled: true }
17324
- });
17325
- }
17326
- if (ps) {
17327
- return unit("pass", `renewal language (\u201C${quote(ps)}\u201D) appears on the page, outside the detected terms block`, 0.6, {
17328
- quote: quote(ps),
17329
- meta: hostedMeta(view)
17330
- });
17331
- }
17332
- if (view.processorAuthorization.length) {
17333
- return unit(
17334
- "needs_review",
17335
- `the only renewal wording is ${PROCESSOR_LINE_NOTE} \u2014 state that the subscription renews automatically, at what price and how often, until the customer cancels`,
17336
- 0.5,
17337
- { quote: quote(view.processorAuthorization[0]), meta: { ...hostedMeta(view), weakEvidence: "processor_authorization" } }
17338
- );
17339
- }
17340
- if (a.negatedAutoRenew.length) {
17341
- return unit(
17342
- "needs_review",
17343
- `the checkout says the plan does not renew automatically (\u201C${quote(a.negatedAutoRenew[0])}\u201D); if that is accurate, automatic-renewal rules may not apply to this offer`,
17344
- 0.5
17345
- );
17346
- }
17347
- return unit("fail", "no statement that the subscription renews automatically and continues until canceled", 0.85);
17348
- }
17349
- )
17490
+ run: async (input) => withUnjudgedPaymentLinks(input, await autorenewOverViews(input))
17350
17491
  };
17492
+ autorenewOverViews = (input) => overViews(
17493
+ "signup.disclosure.autorenew_language",
17494
+ input,
17495
+ {
17496
+ pass: "Checkout states that the subscription renews automatically until canceled",
17497
+ fail: "Checkout does not say that the subscription renews automatically until the customer cancels",
17498
+ needs_review: "Automatic-renewal language needs review"
17499
+ },
17500
+ async (view) => {
17501
+ const raw = await input.reader.analyze(view.ownTermsText, "checkout_disclosure");
17502
+ const a = { ...raw, untilCanceled: raw.untilCanceled.filter((sp) => untilCancelIsAboutRenewal(raw.text, sp)) };
17503
+ const span = a.untilCanceled[0] ?? a.autoRenew[0];
17504
+ const far = span ? onlyInFarFootnotes(view, (t) => hasRenewalLanguage(analyzeTerms(t))) : void 0;
17505
+ if (span && far) {
17506
+ return unit(
17507
+ "needs_review",
17508
+ `renewal language (\u201C${quote(span)}\u201D) appears only in ${far} \u2014 confirm it describes this offer, and state it with the offer`,
17509
+ 0.5,
17510
+ { quote: quote(span), meta: { ...hostedMeta(view), farFromCta: true } }
17511
+ );
17512
+ }
17513
+ if (span && a.untilCanceled.length === 0) {
17514
+ return unit(
17515
+ "needs_review",
17516
+ `says \u201C${quote(span)}\u201D but not that it continues until the customer cancels \u2014 add \u201Cuntil you cancel\u201D`,
17517
+ 0.55,
17518
+ { quote: quote(span), meta: { ...hostedMeta(view), missingUntilCanceled: true } }
17519
+ );
17520
+ }
17521
+ if (span) {
17522
+ const both = a.untilCanceled.length > 0 && a.autoRenew.length > 0;
17523
+ const detail = both ? `states \u201C${quote(a.autoRenew[0])}\u201D and \u201C${quote(a.untilCanceled[0])}\u201D` : `states \u201C${quote(span)}\u201D`;
17524
+ return unit("pass", detail, span.source === "llm" ? 0.75 : both ? 0.92 : 0.85, { quote: quote(span), meta: hostedMeta(view) });
17525
+ }
17526
+ const p = ownPageAnalysis(view);
17527
+ const ps = p.untilCanceled.find((sp) => untilCancelIsAboutRenewal(p.text, sp));
17528
+ if (!ps && p.autoRenew[0]) {
17529
+ return unit("needs_review", `renewal language (\u201C${quote(p.autoRenew[0])}\u201D) appears on the page, outside the detected terms block, and does not say it continues until the customer cancels`, 0.5, {
17530
+ quote: quote(p.autoRenew[0]),
17531
+ meta: { ...hostedMeta(view), missingUntilCanceled: true }
17532
+ });
17533
+ }
17534
+ if (ps) {
17535
+ return unit("pass", `renewal language (\u201C${quote(ps)}\u201D) appears on the page, outside the detected terms block`, 0.6, {
17536
+ quote: quote(ps),
17537
+ meta: hostedMeta(view)
17538
+ });
17539
+ }
17540
+ if (view.processorAuthorization.length) {
17541
+ return unit(
17542
+ "needs_review",
17543
+ `the only renewal wording is ${PROCESSOR_LINE_NOTE} \u2014 state that the subscription renews automatically, at what price and how often, until the customer cancels`,
17544
+ 0.5,
17545
+ { quote: quote(view.processorAuthorization[0]), meta: { ...hostedMeta(view), weakEvidence: "processor_authorization" } }
17546
+ );
17547
+ }
17548
+ if (a.negatedAutoRenew.length) {
17549
+ return unit(
17550
+ "needs_review",
17551
+ `the checkout says the plan does not renew automatically (\u201C${quote(a.negatedAutoRenew[0])}\u201D); if that is accurate, automatic-renewal rules may not apply to this offer`,
17552
+ 0.5
17553
+ );
17554
+ }
17555
+ return unit("fail", "no statement that the subscription renews automatically and continues until canceled", 0.85);
17556
+ }
17557
+ );
17351
17558
  FOOTNOTE_RE = /^\s*(?:\d{1,2}(?=[A-Z])|[*†‡§¹²³⁴⁵⁶⁷⁸⁹]+\s*(?=[A-Z]))/;
17352
17559
  NON_CHARGE_AMOUNT_RE = /\b(?:off|discount|save[sd]?|saving|savings|value|worth|credit|reward|bonus|max)\b/i;
17353
17560
  INTRO_PRICE_RE = /\bfor\s+(?:the\s+|your\s+)?first\s+(?:\d{1,3}\s+|one\s+|two\s+|three\s+|six\s+|twelve\s+)?(?:months?|years?|weeks?|boxes|box|orders?|deliveries|delivery|shipments?|issues?)\b|\bintro(?:ductory)?\s+(?:price|offer|rate|period)\b|\b(?:your\s+)?first\s+(?:box|order|delivery|shipment|kit|issue|month|week)\b[^.\n]{0,8}?[$€£]\s?\d|[$€£]\s?\d[\d.,]*\s+(?:for\s+)?(?:your\s+|the\s+)?first\s+(?:box|order|delivery|shipment|kit|issue)\b/i;
@@ -17852,6 +18059,12 @@ var init_signup = __esm({
17852
18059
  const meta = { statedTermMonths: [...new Set(stated)], knownTermMonths: [...known], ...minimum ? { minimumObligation: quote(minimum) } : {} };
17853
18060
  const chosen = viewPlans(input, view);
17854
18061
  if (chosen.length && !minimum && !stated.some((m) => chosen.some((p) => termMatchesPlan(m, p)))) {
18062
+ if (stated.length && otherPlanCard(view, input)) {
18063
+ return unit("needs_review", `the card states \u201C${quote(signals[0])}\u201D, not ${chosen.map(planEvery).join(" / ")}: ${OTHER_PLAN_CARD_NOTE}`, 0.5, {
18064
+ quote: quote(signals[0]),
18065
+ meta: { ...meta, termMismatch: true, otherPlanCard: true }
18066
+ });
18067
+ }
17855
18068
  return unit(
17856
18069
  "needs_review",
17857
18070
  stated.length ? `the checkout states \u201C${quote(signals[0])}\u201D, but the plan renews ${chosen.map(planEvery).join(" / ")} \u2014 state the renewal term subscribers actually get` : `the checkout says the subscription continues until cancelled (\u201C${quote(signals[0])}\u201D) but not how long each renewal term is \u2014 the plan renews ${chosen.map(planEvery).join(" / ")}`,
@@ -20042,6 +20255,12 @@ function hostedCheckoutKind(declared, consentData) {
20042
20255
  const [only] = [...kinds];
20043
20256
  return kinds.size === 1 && (only === "payment_link" || only === "stripe_checkout") ? only : void 0;
20044
20257
  }
20258
+ function guidePlatformFor(checkId, platform, facts) {
20259
+ if (platform !== "stripe") return platform;
20260
+ if (facts?.checkoutType === "own_page" && checkId.startsWith("signup.")) return "custom";
20261
+ if (facts?.cancelsInApp === true && checkId.startsWith("cancel.")) return "custom";
20262
+ return platform;
20263
+ }
20045
20264
  function remediationFor(checkId, platform) {
20046
20265
  const spec = SPECS2[checkId];
20047
20266
  if (!spec) {
@@ -21239,7 +21458,8 @@ function classify(input) {
21239
21458
  return finish({ status: "not_applicable" }, "verdict_not_applicable", checkId);
21240
21459
  case "fail":
21241
21460
  if (checkId === "config.portal_cancel_enabled" && input.origin !== "preflight" && portalCancelRequired(input.siblings)) {
21242
- const fix = input.data?.cancelPath === "in_app_broken" ? PORTAL_IN_APP_BROKEN_FIX : input.data?.cancelPath === "offline" ? PORTAL_OFFLINE_FIX : PORTAL_REQUIRED_FIX;
21461
+ const noCode = input.data?.paymentLinkSite === true && input.data?.cancelPath !== "in_app_broken";
21462
+ const fix = noCode ? input.data?.cancelPath === "offline" ? PAYMENT_LINK_OFFLINE_FIX : PAYMENT_LINK_PORTAL_FIX : input.data?.cancelPath === "in_app_broken" ? PORTAL_IN_APP_BROKEN_FIX : input.data?.cancelPath === "offline" ? PORTAL_OFFLINE_FIX : PORTAL_REQUIRED_FIX;
21243
21463
  return finish({ status: "needs_fix", fix }, "portal_only_cancel_path", checkId, "required");
21244
21464
  }
21245
21465
  if (checkId.startsWith("cancel.") && input.data?.billingContradicts === true) return finish({ status: "needs_fix", fix: BILLING_CONTRADICTS_FIX }, "cancel_not_in_stripe", checkId);
@@ -21399,7 +21619,7 @@ function classifyFindings(findings) {
21399
21619
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
21400
21620
  }));
21401
21621
  }
21402
- 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, STRIPE_HOSTED_UNITS, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, FAIL_FIX, STATUS_RANK;
21622
+ 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, STRIPE_HOSTED_UNITS, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, STATUS_RANK;
21403
21623
  var init_status = __esm({
21404
21624
  "../rules/src/status.ts"() {
21405
21625
  "use strict";
@@ -21468,13 +21688,13 @@ var init_status = __esm({
21468
21688
  family = (c) => String(c.checkId).split(".")[0];
21469
21689
  isEmailCheck = (id) => id.startsWith("confirmation.") || id.startsWith("reminder.");
21470
21690
  ask = (text, recommended, why) => ({ text, recommended, why });
21471
- PLAN_FACTS_FIX = "Tell Checkbox your plans (price, how often, any free trial) in Site settings \u2192 Your offer, or update_setup: it only lists the reminders your plans need.";
21691
+ PLAN_FACTS_FIX = "Tell Checkbox your plans (price, how often, any free trial) in Site settings \u2192 Your offer, or with updateSetupSession plans[]: it only lists the reminders your plans need.";
21472
21692
  STRIPE_CANCEL_UNREAD_FIX = "Not checked yet: Checkbox could not read the test subscription in Stripe after cancelling. Connect Stripe (or Re-sync Stripe) with a key that can read Customers and Subscriptions, and use the same email for the test account in Stripe.";
21473
21693
  BILLING_CONTRADICTS_FIX = 'Your cancel button says "cancelled", but Stripe still bills the subscription. In the cancel handler, call stripe.subscriptions.update(id, { cancel_at_period_end: true }) (Python: stripe.Subscription.modify(id, cancel_at_period_end=True)), not a pause or a flag in your own database.';
21474
21694
  VERIFIED_FROM_STRIPE_NOTE = "Verified from Stripe settings: cancellation is on in your Stripe customer portal and your site links to its login page. A test cancellation (a proof run) would confirm it live.";
21475
21695
  VERIFIED_FROM_HOSTED_NOTE = "Verified from Checkbox settings: your Checkbox cancellation page is on and working, and your site links to it. Checkbox runs each cancellation there itself and confirms it on screen and by email.";
21476
21696
  HOSTED_UNLINKED_FIX = (url, hiddenOn) => `${hiddenOn ? `The link to your Checkbox cancellation page on ${hiddenOn} is hidden from visitors, so it does not count. ` : ""}Add a visible "Cancel subscription" link to your Checkbox cancellation page${url ? ` (${url})` : ""} on your site (the footer is fine), or turn on Checkbox Notices so every acknowledgment email carries it. Then re-check; no test account is needed.`;
21477
- NOT_CHECKED_CANCEL_FIX = "Let Checkbox try your cancel flow: add a test subscriber account in Site settings \u2192 Test account (agents: updateSite / update_site field test_account { login_url, email, password }; the shopper signs in and cancels), or start a test run in Stripe test mode (it signs up and cancels a test subscription). If subscribers cancel in the Stripe customer portal, no test account is needed: turn on cancellation there and link its login page from your site.";
21697
+ NOT_CHECKED_CANCEL_FIX = "Let Checkbox try your cancel flow: add a test subscriber account in Site settings \u2192 Test account (agents: updateSite field test_account { login_url, email, password }; the shopper signs in and cancels), or start a test run in Stripe test mode (it signs up and cancels a test subscription). If subscribers cancel in the Stripe customer portal, no test account is needed: turn on cancellation there and link its login page from your site.";
21478
21698
  EMAIL_EXISTS = /not seen sent yet|only a template or sample|shows the template, not that|turned on in your billing platform|only been received for|captured, but its timing|from another system|not linked to a subscriber|sign-ups have no acknowledgment on record|window has not closed yet|upcoming .* observed|no price change observed yet|confirm they reach every/;
21479
21699
  RULES = [
21480
21700
  // Round 20 (pilot 19 item 1): an acknowledgment that does not mention a trial, on a site where only some plans have
@@ -21488,6 +21708,18 @@ var init_status = __esm({
21488
21708
  fix: "Send each plan its own acknowledgment (the one for a plan with a free trial says when the trial ends and how to cancel before it converts), and BCC the copy your app sends for a Stripe test-mode subscription of each plan so Checkbox can match it to its plan."
21489
21709
  })
21490
21710
  },
21711
+ // Round 21 (pilot 20, codex-newcomer): an active Payment Link with no renewal terms that no page links to still sells.
21712
+ // A decision for the merchant (add the terms, or deactivate it), never a pass.
21713
+ {
21714
+ code: "unjudged_payment_link",
21715
+ category: "legal_choice",
21716
+ when: (c) => c.status === "needs_review" && Array.isArray(c.data?.unjudgedPaymentLinks) && c.data.unjudgedPaymentLinks.length > 0,
21717
+ build: () => ({
21718
+ status: "your_call",
21719
+ fix: "Add the renewal terms (and a consent checkbox) to the Payment Link, or deactivate it in Stripe (Payment Links \u2192 the link \u2192 Deactivate) if it is no longer used.",
21720
+ question: ask("An active Payment Link in your Stripe account states no renewal terms. Is it still used to sell?", "If not, deactivate it in Stripe; if it is, add the renewal terms and a consent checkbox to it.", "Anyone with its URL can still subscribe through it, whether or not your site links to it.")
21721
+ })
21722
+ },
21491
21723
  {
21492
21724
  code: "internal_error",
21493
21725
  category: "legal_choice",
@@ -21568,7 +21800,7 @@ var init_status = __esm({
21568
21800
  // Pilot round 11 (change-guard): an intro / discounted price the Stripe session does not apply.
21569
21801
  // Pilot round 12: an intro price with no intro terms to compare it with (never a pass), a stated intro price that is
21570
21802
  // not the real one, and terms that sit on another page than the payment page.
21571
- { code: "intro_terms_missing", category: "missing_evidence", when: has(/no intro terms were given/), build: () => ({ status: "not_checked", fix: "Pass the intro terms (intro_price, or intro_percent_off / intro_amount_off with intro_periods) so Checkbox can compare the promo, or apply the coupon in the Stripe params." }) },
21803
+ { code: "intro_terms_missing", category: "missing_evidence", when: has(/no intro terms were given/), build: () => ({ status: "not_checked", fix: "Declare the intro price or coupon in Site settings \u2192 Your offer \u2192 Introductory price or coupon (an agent passes it in updateSetupSession plans[]: intro_amount, intro_percent_off or intro_amount_off, with intro_periods), so Checkbox can compare the promo. On Stripe Checkout, applying the coupon in the Checkout Session params works too." }) },
21572
21804
  { code: "intro_price_mismatch", category: "content", when: has(/intro price is .{1,40} — state the intro price subscribers will actually pay/), build: () => ({ status: "needs_fix", fix: 'State the real intro price, then the regular price ("$8.00 for your first month, then $10.00 every month until you cancel").' }) },
21573
21805
  { code: "terms_on_other_page", category: "content", when: has(/shown on another page than the payment page/), build: () => ({ status: "needs_fix", fix: "Show the disclosure on the payment page, directly above the purchase button." }) },
21574
21806
  { code: "intro_price_not_applied", category: "content", when: has(/promises an introductory or discounted price/), build: () => ({ status: "needs_fix", fix: "Apply the promised discount in the session (discounts: [{ coupon }] or a promotion_code), or remove the promise from the text." }) },
@@ -21942,9 +22174,20 @@ var init_status = __esm({
21942
22174
  code: "portal_off_untested",
21943
22175
  category: "missing_evidence",
21944
22176
  when: (c) => String(c.checkId) === "cancel.online_available" && c.data?.portalOff === true,
21945
- build: () => ({
22177
+ build: (c) => ({
21946
22178
  status: "not_checked",
21947
- fix: 'Nothing has been tested yet: cancellation is off in your Stripe customer portal and Checkbox has not tried your own cancel flow. Either turn on "Cancel subscriptions" in the Stripe customer portal and link it from your account page, or, if you have your own cancel flow, add a test account (Site settings \u2192 Test account) so Checkbox can try it.'
22179
+ fix: c.data?.paymentLinkSite === true ? `Nothing has been tested yet: cancellation is off in your Stripe customer portal. ${PAYMENT_LINK_PORTAL_FIX} If you have your own cancel flow instead, add a test account (Site settings \u2192 Test account) so Checkbox can try it.` : 'Nothing has been tested yet: cancellation is off in your Stripe customer portal and Checkbox has not tried your own cancel flow. Either turn on "Cancel subscriptions" in the Stripe customer portal and link it from your account page, or, if you have your own cancel flow, add a test account (Site settings \u2192 Test account) so Checkbox can try it.'
22180
+ })
22181
+ },
22182
+ // Round 21 (pilot 20, agent-api-only): an account-area capture the newest scan did not refresh is not judged. Say which
22183
+ // device, since when, and how to refresh it (not "add a test account": there is one).
22184
+ {
22185
+ code: "cancel_device_not_refreshed",
22186
+ category: "missing_evidence",
22187
+ when: (c) => c.checkId === "cancel.mechanism_findable" && c.data?.missingData === true && Array.isArray(c.data?.notRefreshed) && c.data.notRefreshed.length > 0,
22188
+ build: (c) => ({
22189
+ status: "not_checked",
22190
+ fix: `${c.detail.replace(/^Not checked yet[^—]*—\s*/, "").replace(/^./, (m) => m.toUpperCase())}.`
21948
22191
  })
21949
22192
  },
21950
22193
  {
@@ -21989,6 +22232,8 @@ var init_status = __esm({
21989
22232
  PORTAL_REQUIRED_FIX = 'Checkbox has not seen another way for your subscribers to cancel online yet. Choose one: turn on "Cancel subscriptions" in the Stripe customer portal (Stripe Dashboard \u2192 Settings \u2192 Billing \u2192 Customer portal, or POST /v1/billing_portal/configurations/{id} with features[subscription_cancel][enabled]=true) and link the portal from your account page; or, if subscribers cancel on your own page, add a test login for it (site test_account) so Checkbox can try that cancellation.';
21990
22233
  PORTAL_IN_APP_BROKEN_FIX = 'Fix your cancel handler so it cancels in Stripe (Node: stripe.subscriptions.update(id, { cancel_at_period_end: true }); Python: stripe.Subscription.modify(id, cancel_at_period_end=True)). Second option: turn on "Cancel subscriptions" in the Stripe customer portal and link it from your account page.';
21991
22234
  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".';
22235
+ 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.`;
22236
+ PAYMENT_LINK_OFFLINE_FIX = `${PAYMENT_LINK_PORTAL_FIX} Then replace the "email / call us to cancel" line with "Cancel anytime online" and that link.`;
21992
22237
  FAIL_FIX = {
21993
22238
  "signup.disclosure.autorenew_language": 'Add one line above the pay button: "Renews automatically every <interval> at <price> until you cancel."',
21994
22239
  "signup.disclosure.price": "State the recurring price in the renewal line (the amount actually charged each time).",
@@ -22258,6 +22503,12 @@ function withDeliveredSignups(ctx) {
22258
22503
  });
22259
22504
  return changed ? { ...ctx, emails } : ctx;
22260
22505
  }
22506
+ function siteRemediation(id, platform, facts) {
22507
+ const guide = guidePlatformFor(id, platform, facts);
22508
+ if (guide === platform) return remediationFor(id, platform);
22509
+ const { platform: _p, ...rest } = remediationFor(id, guide);
22510
+ return platform ? { ...rest, platform } : rest;
22511
+ }
22261
22512
  async function runEvaluation(input, opts) {
22262
22513
  return withBrandNames(brandNamesOf(input), () => runEvaluationInScope(input, opts));
22263
22514
  }
@@ -22336,6 +22587,8 @@ async function runEvaluationInScope(input, opts) {
22336
22587
  const result = results.get(id);
22337
22588
  if (!result) continue;
22338
22589
  const reqs = byCheck.get(id);
22590
+ const paymentLinkCancel = platform === "stripe" && (ctx.facts?.checkoutType === "payment_link" || hostedKind === "payment_link") && (result.status === "fail" || result.status === "needs_review") && PAYMENT_LINK_CANCEL_CHECKS.has(id) && result.data?.billingContradicts !== true && // The broken in-app cancel handler gets its own steps (CANCEL_HANDLER_REMEDIATION, below).
22591
+ !(result.status === "fail" && id === "config.portal_cancel_enabled" && result.data?.cancelPath === "in_app_broken");
22339
22592
  const finding = {
22340
22593
  id: findingId(ctx.siteId, id),
22341
22594
  siteId: ctx.siteId,
@@ -22350,12 +22603,13 @@ async function runEvaluationInScope(input, opts) {
22350
22603
  title: CHECKS[id].title,
22351
22604
  detail: result.message,
22352
22605
  evidenceRefs: result.evidenceRefs,
22353
- remediation: id === "cancel.flow_friction" && result.data?.offerPermissionMissing === true ? { ...OFFER_PERMISSION_REMEDIATION, ...platform ? { platform } : {} } : platform === "stripe" && result.status === "fail" && (id === "cancel.online_available" && result.data?.billingContradicts === true || id === "config.portal_cancel_enabled" && result.data?.cancelPath === "in_app_broken") ? CANCEL_HANDLER_REMEDIATION : platform === "stripe" && (ctx.facts?.checkoutType === "payment_link" || hostedKind === "payment_link") && (result.status === "fail" || result.status === "needs_review") && PAYMENT_LINK_CANCEL_CHECKS.has(id) && result.data?.billingContradicts !== true ? paymentLinkCancelRemediation(ctx.config?.cancellation?.portalUrl, ctx.config?.raw?.portal?.loginPageEnabled) : withContentFix(personalizeRemediation(hostedKind && hostedCheckoutRemediation(id, hostedKind, platform, { consentOn: results.get("signup.consent.affirmative")?.status === "pass" }) || remediationFor(id, platform), values), contentFixLine(id, result.data)),
22606
+ remediation: id === "cancel.flow_friction" && result.data?.offerPermissionMissing === true ? { ...OFFER_PERMISSION_REMEDIATION, ...platform ? { platform } : {} } : platform === "stripe" && result.status === "fail" && (id === "cancel.online_available" && result.data?.billingContradicts === true || id === "config.portal_cancel_enabled" && result.data?.cancelPath === "in_app_broken") ? CANCEL_HANDLER_REMEDIATION : paymentLinkCancel ? paymentLinkCancelRemediation(ctx.config?.cancellation?.portalUrl, ctx.config?.raw?.portal?.loginPageEnabled) : withContentFix(personalizeRemediation(hostedKind && hostedCheckoutRemediation(id, hostedKind, platform, { consentOn: results.get("signup.consent.affirmative")?.status === "pass" }) || siteRemediation(id, platform, ctx.facts), values), contentFixLine(id, result.data)),
22354
22607
  confidence: result.confidence,
22355
22608
  firstSeenAt: evaluatedAt,
22356
22609
  lastSeenAt: evaluatedAt
22357
22610
  };
22358
22611
  if (result.data) finding.data = result.data;
22612
+ if (paymentLinkCancel) finding.data = { ...finding.data ?? {}, paymentLinkSite: true };
22359
22613
  if (id === "config.portal_cancel_enabled" && platform === "stripe" && result.status === "needs_review") finding.data = { ...finding.data ?? {}, stripeSite: true };
22360
22614
  finding.evidence = result.evidence ?? buildEvidence(result, ctx);
22361
22615
  findings.push(finding);
@@ -22645,11 +22899,15 @@ function scrub(v, depth = 0) {
22645
22899
  function withoutSyntheticArtifacts(c) {
22646
22900
  return scrub(c);
22647
22901
  }
22902
+ function withCancelUrl(fix, cancelUrl) {
22903
+ if (!cancelUrl) return fix;
22904
+ return fix.replace("Cancel anytime with your cancel link.", `Cancel anytime online at ${cancelUrl}.`).replace('Add "Cancel anytime online" to the renewal line, with your cancel link.', `Add "Cancel anytime online at ${cancelUrl}" to the renewal line.`);
22905
+ }
22648
22906
  function withCompliance(c, kind, all, plan2) {
22649
22907
  const siblings = new Map(all.map((x) => [x.check_id, x.status]));
22650
22908
  const classified = classify({ checkId: c.check_id, status: c.status, detail: c.explanation, missing: c.evidence?.missing ?? void 0, origin: "preflight", kind, siblings });
22651
- const terms2 = plan2 ? { units: [{ status: "fail", planAmount: plan2.amount, planCurrency: plan2.currency, planInterval: plan2.interval, planIntervalCount: plan2.intervalCount }] } : void 0;
22652
- const info = terms2 && classified.fix ? { ...classified, fix: withPlanTerms(classified.fix, terms2) } : classified;
22909
+ const terms2 = plan2 && "amount" in plan2 ? { units: [{ status: "fail", planAmount: plan2.amount, planCurrency: plan2.currency, planInterval: plan2.interval, planIntervalCount: plan2.intervalCount }] } : void 0;
22910
+ const info = classified.fix ? { ...classified, fix: withCancelUrl(terms2 ? withPlanTerms(classified.fix, terms2) : classified.fix, plan2?.cancelUrl) } : classified;
22653
22911
  return { ...c, ...hostedWording(c, info), compliance_status: info.status, compliance: statusWire(info) };
22654
22912
  }
22655
22913
  function hostedWording(c, info) {
@@ -23677,6 +23935,18 @@ function readParams(params, merchant, kind, asOf) {
23677
23935
  for (const [k, v] of [["custom_text.submit.message", submit], ["custom_text.terms_of_service_acceptance.message", tosAcceptance]]) {
23678
23936
  if (v && v.length > STRIPE_CUSTOM_TEXT_MAX) warnings.push({ code: "stripe_custom_text_too_long", message: `${k} is ${v.length} characters; Stripe rejects more than ${STRIPE_CUSTOM_TEXT_MAX}.`, param: `params.${k}` });
23679
23937
  }
23938
+ for (const k of CUSTOM_TEXT_FIELDS) {
23939
+ if (k === "submit" || k === "terms_of_service_acceptance" && !consentRequired) continue;
23940
+ const other = otherPlanTerms(msg(ct[k]), plan2, { unreadDiscount: hasDiscount });
23941
+ if (other) {
23942
+ caveats.push({
23943
+ checks: other.amount ? ["signup.disclosure.price"] : ["signup.disclosure.frequency"],
23944
+ status: other.alsoOwn ? "needs_review" : "fail",
23945
+ reason: `custom_text.${k}.message states \u201C${other.quote}\u201D, but this checkout charges ${planPhrase(plan2)}: every message on the checkout must state this plan's own terms`,
23946
+ param: `params.custom_text.${k}.message`
23947
+ });
23948
+ }
23949
+ }
23680
23950
  if (tosAcceptance && !consentRequired) {
23681
23951
  warnings.push({
23682
23952
  code: "stripe_tos_acceptance_without_consent",
@@ -23698,6 +23968,33 @@ function readParams(params, merchant, kind, asOf) {
23698
23968
  caveats
23699
23969
  };
23700
23970
  }
23971
+ function planPhrase(plan2) {
23972
+ return `${formatMoney(plan2.amount, plan2.currency)} every ${intervalPhrase(plan2.interval, plan2.intervalCount)}`;
23973
+ }
23974
+ function otherPlanTerms(text, plan2, opts = {}) {
23975
+ const found = otherPlanTerms0(text, plan2, opts);
23976
+ if (!found) return void 0;
23977
+ const a = analyzeTerms(text);
23978
+ const own = a.recurringPrices.some((r) => r.money.amount !== null && Math.abs(Math.round(r.money.amount * 10 ** currencyDigitsOf(plan2.currency)) - plan2.amount) < 1 && !!r.frequency.unit && frequencyMatchesPlan(r.frequency.unit, r.frequency.count, plan2));
23979
+ return { ...found, alsoOwn: own };
23980
+ }
23981
+ function otherPlanTerms0(text, plan2, opts) {
23982
+ if (!text) return void 0;
23983
+ const a = analyzeTerms(text);
23984
+ const digits = currencyDigitsOf(plan2.currency);
23985
+ const allowed = [plan2.amount, plan2.introAmount, plan2.introAmount !== void 0 ? plan2.amount - plan2.introAmount : void 0, 0].filter((n) => typeof n === "number");
23986
+ for (const m of a.amounts) {
23987
+ if (m.amount === null || m.currency && m.currency.toLowerCase() !== plan2.currency.toLowerCase()) continue;
23988
+ const minor = Math.round(m.amount * 10 ** digits);
23989
+ if (opts.unreadDiscount && plan2.introAmount === void 0 && minor < plan2.amount) continue;
23990
+ if (!allowed.some((x) => Math.abs(x - minor) < 1)) return { quote: a.recurringPrices.find((r) => r.money.index === m.index)?.text ?? m.text, amount: true };
23991
+ }
23992
+ for (const r of a.recurringPrices) {
23993
+ const f = r.frequency;
23994
+ if (f.unit && !frequencyMatchesPlan(f.unit, f.count, plan2)) return { quote: r.text, amount: false };
23995
+ }
23996
+ return void 0;
23997
+ }
23701
23998
  function applyCaveats(checks, caveats) {
23702
23999
  if (!caveats.length) return checks;
23703
24000
  return checks.map((c) => {
@@ -23778,8 +24075,10 @@ async function judge2(shape, url, jurisdictions, asOf, now, id) {
23778
24075
  }
23779
24076
  function planAsMerchant(shape, merchant) {
23780
24077
  const p = shape.plan;
24078
+ const { intro_price: _ip, intro_amount: _ia, intro_percent_off: _ipo, intro_amount_off: _iao, intro_periods: _ipr, ...rest } = merchant ?? {};
23781
24079
  return {
23782
- ...merchant,
24080
+ ...rest,
24081
+ ...p.introAmount !== void 0 ? { intro_amount: p.introAmount, ...p.introPeriods ? { intro_periods: p.introPeriods } : {} } : {},
23783
24082
  price: void 0,
23784
24083
  amount: p.amount,
23785
24084
  currency: p.currency,
@@ -23811,7 +24110,7 @@ function correct(params, shape, checks, forLink, style2 = "full") {
23811
24110
  const ct = isObj(out.custom_text) ? out.custom_text : void 0;
23812
24111
  const set = {};
23813
24112
  if (needText) set.submit = { message: texts.disclosure };
23814
- if (needConsent) set.terms_of_service_acceptance = { message: texts.consentLabel };
24113
+ if (needConsent || shape.consentRequired) set.terms_of_service_acceptance = { message: texts.consentLabel };
23815
24114
  if (!ct) {
23816
24115
  patch.push({ op: "add", path: "/custom_text", value: set });
23817
24116
  out.custom_text = set;
@@ -23822,6 +24121,13 @@ function correct(params, shape, checks, forLink, style2 = "full") {
23822
24121
  }
23823
24122
  }
23824
24123
  }
24124
+ if (patch.length && Array.isArray(out.discounts)) {
24125
+ out.discounts = out.discounts.map((d, i) => {
24126
+ if (!isObj(d) || !isObj(d.coupon) || typeof d.coupon.id !== "string") return d;
24127
+ patch.push({ op: "replace", path: `/discounts/${i}/coupon`, value: d.coupon.id });
24128
+ return { ...d, coupon: d.coupon.id };
24129
+ });
24130
+ }
23825
24131
  if (forLink && "id" in out) {
23826
24132
  for (const k of ["id", "object", "url", "livemode", "active"]) delete out[k];
23827
24133
  if (isObj(out.line_items) && Array.isArray(out.line_items.data)) {
@@ -23952,7 +24258,7 @@ async function checkStripeCheckoutSession(input0, opts = {}) {
23952
24258
  const channel2 = noticeChannelField(params);
23953
24259
  if (channel2) warnings.push(channel2);
23954
24260
  return {
23955
- ...baseReport("stripe_checkout_session", checks, jurisdictions, asOf, warnings, nextAction("session", checks, correction)),
24261
+ ...baseReport("stripe_checkout_session", checks, jurisdictions, asOf, warnings, nextAction("session", checks, correction), void 0, { cancelUrl: shape.plan?.cancelUrl }),
23956
24262
  kind: "stripe_checkout_session",
23957
24263
  correction
23958
24264
  };
@@ -24013,7 +24319,7 @@ async function checkStripePaymentLink(input0, opts = {}) {
24013
24319
  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.' });
24014
24320
  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).` : "";
24015
24321
  return {
24016
- ...baseReport("stripe_payment_link", checks, jurisdictions, asOf, warnings, nextAction("link", checks, correction, extra)),
24322
+ ...baseReport("stripe_payment_link", checks, jurisdictions, asOf, warnings, nextAction("link", checks, correction, extra), void 0, { cancelUrl: shape.plan?.cancelUrl }),
24017
24323
  kind: "stripe_payment_link",
24018
24324
  correction
24019
24325
  };
@@ -24081,13 +24387,14 @@ function noticeChannelField(params) {
24081
24387
  param: `params.custom_fields[${i}]`
24082
24388
  };
24083
24389
  }
24084
- var TEXT_CHECKS, CONSENT_CHECKS2, HOSTED_STYLING_NOTE, INTRO_PROMISE_RE, isObj, msg, STRIPE_GREY, DAY_MS3, open, STRIPE_HOSTED_STYLING_NOTE;
24390
+ 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;
24085
24391
  var init_stripe2 = __esm({
24086
24392
  "../rules/src/preflight/stripe.ts"() {
24087
24393
  "use strict";
24088
24394
  init_terms2();
24089
24395
  init_compact();
24090
24396
  init_plan_terms();
24397
+ init_text();
24091
24398
  init_verify();
24092
24399
  init_stripe();
24093
24400
  init_common();
@@ -24107,6 +24414,7 @@ var init_stripe2 = __esm({
24107
24414
  INTRO_PROMISE_RE = /\b\d{1,3}(?:\.\d+)?\s?%\s*off\b|\b(?:introductory|intro)\s+(?:price|rate|offer|discount)\b|\b(?:discounted|promotional|promo)\s+(?:price|rate)\b|(?:US\$|\$|€|£)\s?\d[\d,]*(?:\.\d{1,2})?\s+(?:for\s+(?:your|the)\s+)?first\s+(?:\d+\s+)?(?:months?|years?|weeks?|box(?:es)?|orders?|deliver(?:y|ies)|shipments?)\b|\bfirst\s+(?:\d+\s+)?(?:months?|years?|weeks?|box(?:es)?|orders?)\s+(?:for|at|just|only)\s+(?:US\$|\$|€|£)\s?\d/i;
24108
24415
  isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
24109
24416
  msg = (v) => isObj(v) && typeof v.message === "string" && v.message.trim() ? v.message : void 0;
24417
+ CUSTOM_TEXT_FIELDS = ["submit", "terms_of_service_acceptance", "after_submit", "shipping_address"];
24110
24418
  STRIPE_GREY = { fontSizePx: 13, fontWeight: 400, color: "rgba(26, 26, 26, 0.7)", backgroundColor: "rgb(255, 255, 255)" };
24111
24419
  DAY_MS3 = 864e5;
24112
24420
  open = (c) => !!c && (c.status === "fail" || c.status === "needs_review");
@@ -24421,7 +24729,7 @@ function nextAction2(kind, checks, requiredBy, tpl, unresolved = []) {
24421
24729
  if (kind === "renewal_reminder") {
24422
24730
  return {
24423
24731
  type: "provide_input",
24424
- message: `No applicable requirement governs a renewal reminder for this plan (renewal reminders apply to longer terms). Plans billed more often need the yearly terms reminder instead: check that email with kind "annual_terms" (check_email / POST /v1/checks/email).${fill2}`
24732
+ message: `No applicable requirement governs a renewal reminder for this plan (renewal reminders apply to longer terms). Plans billed more often need the yearly terms reminder instead: check that email with kind "annual_terms" (checkEmail, POST /v1/checks/email).${fill2}`
24425
24733
  };
24426
24734
  }
24427
24735
  return {
@@ -24474,7 +24782,10 @@ ${text}
24474
24782
  ${html ?? ""}`, kind);
24475
24783
  if (unresolved.length) {
24476
24784
  warnings.push({ code: "unresolved_placeholders", message: `The email still contains placeholders a subscriber would see: ${unresolved.join(", ")}. Checkbox cannot confirm a term it cannot read: to confirm, check the email again with sample values in their place (keep the merge tags in your code).`, param: "text" });
24477
- checks = checks.map((c) => c.status === "pass" ? { ...c, status: "needs_review", explanation: `${c.explanation} (Not confirmed: the email contains unresolved placeholders ${unresolved.join(", ")}.)` } : c);
24785
+ const tags = `The email has unfilled merge tags (${unresolved.join(", ")}): Checkbox cannot read what they hold. Check the email again with sample values in their place`;
24786
+ checks = checks.map(
24787
+ (c) => c.status === "pass" ? { ...c, status: "needs_review", explanation: `${c.explanation} (Not confirmed: the email contains unresolved placeholders ${unresolved.join(", ")}.)` } : c.status === "fail" && c.fix ? { ...c, fix: `${tags}; if it still fails: ${c.fix.charAt(0).toLowerCase()}${c.fix.slice(1)}` } : c
24788
+ );
24478
24789
  }
24479
24790
  const requiredBy = checks.every((c) => c.status === "not_applicable") ? [] : run2.applicable.filter((r) => r.checkIds.some((c) => checkIds.includes(c))).map((r) => r.id);
24480
24791
  const prevMinor = minorAmount({ price: input.terms.previous_price, amount: input.terms.previous_amount, currency: plan2.currency }, "terms.previous_price");
@@ -24623,6 +24934,16 @@ function staticUncertainty(v, u) {
24623
24934
  }
24624
24935
  return v;
24625
24936
  }
24937
+ function pagePriceDiffers(findings) {
24938
+ const f = findings.find((x) => x.checkId === "signup.disclosure.price");
24939
+ const units = f?.data?.units ?? [];
24940
+ const quotes = units.filter((u) => u.priceMismatch === true && u.otherPlanCard !== true && u.equivalentPrice !== true && u.status !== "pass" && u.status !== "not_applicable").map((u) => typeof u.quote === "string" && u.quote ? u.quote : "a different price");
24941
+ return [...new Set(quotes)];
24942
+ }
24943
+ function withPriceDiffers(next, differs) {
24944
+ if (!differs.length) return next;
24945
+ return { ...next, message: `The price on the page differs from the plan terms you passed (warning page_price_differs): confirm which price subscribers are charged first. ${next.message}` };
24946
+ }
24626
24947
  function nextAction3(checks, replacement, buttons, perPlan = false, onStripe) {
24627
24948
  if (!buttons) {
24628
24949
  return { type: "provide_input", message: "No purchase button was found. Pass the rendered checkout HTML (after JavaScript runs), or the page that contains the subscribe button." };
@@ -24691,7 +25012,7 @@ async function checkPageHtml(input, opts = {}) {
24691
25012
  const facts = input.checkout_type ? { ...facts0, checkoutType: input.checkout_type } : facts0;
24692
25013
  let onStripe;
24693
25014
  const merchantNames = [...new Set([plan2?.merchantName, ...plans.map((p) => p.merchantName)].filter((n) => !!n))];
24694
- const ctx = modelContext(jurisdictions, asOf, facts, { captures: [capture], ...multi ? multi.extra : plan2 ? { config: planConfig(plan2, asOf) } : {}, ...merchantNames.length ? { merchantNames } : {} });
25015
+ const ctx = modelContext(jurisdictions, asOf, facts, { captures: [capture], signupUrls: [capture.url], ...multi ? multi.extra : plan2 ? { config: planConfig(plan2, asOf) } : {}, ...merchantNames.length ? { merchantNames } : {} });
24695
25016
  const run2 = await runChecks(ctx, SIGNUP_FIX_CHECKS, now);
24696
25017
  const checks = shapeChecks(run2.findings, {
24697
25018
  adjust: (v0, f) => {
@@ -24748,10 +25069,24 @@ async function checkPageHtml(input, opts = {}) {
24748
25069
  }
24749
25070
  const consentOnStripe = onStripe ?? (input.checkout_type === "payment_link" || input.checkout_type === "stripe_checkout" ? input.checkout_type : input.checkout_type ? void 0 : stripeButtonsKind(capture));
24750
25071
  const repOpts = consentOnStripe ? { onStripe: consentOnStripe } : {};
24751
- const replacement = plan2 ? await buildReplacement(plan2, jurisdictions, asOf, now, { ...repOpts, page: capture }) : void 0;
25072
+ const built = plan2 ? await buildReplacement(plan2, jurisdictions, asOf, now, { ...repOpts, page: capture }) : void 0;
25073
+ const differs = plan2 && !multi ? pagePriceDiffers(run2.findings) : [];
25074
+ if (plan2 && differs.length) {
25075
+ warnings.push({
25076
+ code: "page_price_differs",
25077
+ message: `The price on the page (${differs.map((q2) => `\u201C${q2}\u201D`).join(", ")}) differs from the plan terms you passed (${planDisplay(plan2, 0)}). The replacement states the plan's price and is not verified for this page: confirm which price subscribers are charged. If the plan terms are out of date, pass the real price and check again; if the page is wrong, use the replacement and remove the other price from the page.`,
25078
+ param: t && hasPrice(t) ? "terms" : "plans"
25079
+ });
25080
+ }
25081
+ const replacement = built && differs.length ? {
25082
+ ...built,
25083
+ verified: false,
25084
+ remaining_checks: [.../* @__PURE__ */ new Set([...built.remaining_checks, "signup.disclosure.price"])],
25085
+ note: `${built.note ? `${built.note} ` : ""}The price on the page differs from the plan terms you passed, so this replacement is not verified for this page (warning page_price_differs).`
25086
+ } : built;
24752
25087
  const planReplacements = multi ? await Promise.all(plans.map(async (p, i) => ({ plan: planDisplay(p, i), ...await buildReplacement(p, jurisdictions, asOf, now, repOpts) }))) : void 0;
24753
25088
  return {
24754
- ...baseReport("page", checks, jurisdictions, asOf, warnings, nextAction3(checks, replacement ?? planReplacements?.[0], capture.ctas.length, !!planReplacements, onStripe), capture.text, multi ? void 0 : plan2),
25089
+ ...baseReport("page", checks, jurisdictions, asOf, warnings, withPriceDiffers(nextAction3(checks, replacement ?? planReplacements?.[0], capture.ctas.length, !!planReplacements, onStripe), differs), capture.text, multi ? void 0 : plan2),
24755
25090
  kind: "page",
24756
25091
  source: opts.source ?? "html",
24757
25092
  extraction: {
@@ -24901,7 +25236,8 @@ __export(preflight_exports, {
24901
25236
  resolvePriceIds: () => resolvePriceIds,
24902
25237
  runChecks: () => runChecks,
24903
25238
  shapeChecks: () => shapeChecks,
24904
- signupStripeObjects: () => signupStripeObjects
25239
+ signupStripeObjects: () => signupStripeObjects,
25240
+ withIntroOffers: () => withIntroOffers
24905
25241
  });
24906
25242
  var init_preflight = __esm({
24907
25243
  "../rules/src/preflight/index.ts"() {
@@ -24914,6 +25250,7 @@ var init_preflight = __esm({
24914
25250
  init_page();
24915
25251
  init_checkout_plans();
24916
25252
  init_stripe_objects();
25253
+ init_plan_terms();
24917
25254
  }
24918
25255
  });
24919
25256
 
@@ -25221,6 +25558,11 @@ var init_check = __esm({
25221
25558
  // src/ci/config.ts
25222
25559
  import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
25223
25560
  import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve2 } from "node:path";
25561
+ function planList(config, ref) {
25562
+ if (Array.isArray(ref)) return ref;
25563
+ if (ref === "all" && !(config.plans ?? []).some((p) => p.name === "all")) return (config.plans ?? []).map((p) => p.name);
25564
+ return void 0;
25565
+ }
25224
25566
  function findConfig(cwd, explicit) {
25225
25567
  if (explicit) {
25226
25568
  const p = isAbsolute(explicit) ? explicit : resolve2(cwd, explicit);
@@ -25249,7 +25591,7 @@ function loadConfig(path) {
25249
25591
  function validateConfig(value) {
25250
25592
  const is = new Issues();
25251
25593
  if (!isObj2(value)) return ["must be a JSON object (see https://checkbox.site/docs/guides/ci#config)"];
25252
- is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "checks", "coverage"]);
25594
+ is.keys("", value, ["$schema", "merchant", "jurisdictions", "plans", "notices", "checks", "coverage"]);
25253
25595
  const cov = value.coverage;
25254
25596
  if (cov !== void 0 && cov !== false) {
25255
25597
  if (!isObj2(cov)) is.add("coverage", `must be false or an object like { "ignore": ["scripts/**"] } (got ${show(cov)})`);
@@ -25303,6 +25645,17 @@ function validateConfig(value) {
25303
25645
  });
25304
25646
  }
25305
25647
  const planCount = Array.isArray(plans) ? plans.length : 0;
25648
+ const notices = value.notices;
25649
+ if (notices !== void 0) {
25650
+ if (!isObj2(notices)) is.add("notices", `must be an object like { "sent_by_checkbox": true } (got ${show(notices)})`);
25651
+ else {
25652
+ is.keys("notices", notices, ["sent_by_checkbox"]);
25653
+ const sb = notices.sent_by_checkbox;
25654
+ if (sb !== true && !(Array.isArray(sb) && sb.length && sb.every((k) => typeof k === "string" && EMAIL_KINDS2.includes(k)))) {
25655
+ is.add("notices.sent_by_checkbox", `must be true (every email Checkbox Notices sends) or a list of kinds (${EMAIL_KINDS2.join(", ")}) (got ${show(sb)})`);
25656
+ }
25657
+ }
25658
+ }
25306
25659
  const checks = value.checks;
25307
25660
  if (!isObj2(checks)) {
25308
25661
  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)})`);
@@ -25310,7 +25663,7 @@ function validateConfig(value) {
25310
25663
  }
25311
25664
  is.keys("checks", checks, ["stripe_checkout", "disclosures", "pages", "emails", "notice_schedule"]);
25312
25665
  const names = /* @__PURE__ */ new Set();
25313
- const entries = (key, each, needsPlan) => {
25666
+ const entries = (key, each, needsPlan, severalPlans = false) => {
25314
25667
  const list2 = checks[key];
25315
25668
  if (list2 === void 0) return;
25316
25669
  if (!Array.isArray(list2)) return is.add(`checks.${key}`, `must be an array (got ${show(list2)})`);
@@ -25321,7 +25674,12 @@ function validateConfig(value) {
25321
25674
  if (names.has(e.name)) is.add(`${at}.name`, `duplicates another check's name (${show(e.name)}); names must be unique`);
25322
25675
  names.add(e.name);
25323
25676
  }
25324
- if (e.plan !== void 0) {
25677
+ if (severalPlans && Array.isArray(e.plan)) {
25678
+ if (!e.plan.length || !e.plan.every((n) => typeof n === "string")) is.add(`${at}.plan`, `must be a plan name, a list of plan names or "all" (got ${show(e.plan)})`);
25679
+ else for (const n of e.plan) if (!planNames.has(n)) is.add(`${at}.plan`, `names no plan in "plans" (${show(n)}; plans: ${[...planNames].map((x) => JSON.stringify(x)).join(", ")})`);
25680
+ } else if (severalPlans && e.plan === "all" && !planNames.has("all")) {
25681
+ if (!planCount) is.add(`${at}.plan`, 'is "all", but "plans" is empty: add the plans to "plans"');
25682
+ } else if (e.plan !== void 0) {
25325
25683
  if (is.str(`${at}.plan`, e.plan) && !planNames.has(e.plan)) {
25326
25684
  is.add(`${at}.plan`, planCount ? `names no plan in "plans" (${show(e.plan)}; plans: ${[...planNames].map((n) => JSON.stringify(n)).join(", ")})` : `names a plan, but "plans" is empty: add the plan to "plans"`);
25327
25685
  }
@@ -25373,7 +25731,9 @@ function validateConfig(value) {
25373
25731
  if (e.url !== void 0) is.str(`${at}.url`, e.url, { pattern: /^https?:\/\/\S+$/, patternHint: "an http(s) URL" });
25374
25732
  is.oneOf(`${at}.kind`, e.kind, PAGE_KINDS2);
25375
25733
  },
25376
- "several"
25734
+ // Round 21 (pilot 20, ci-pr-agent): a page with no "plan" is checked against every plan (a multi-plan pricing page).
25735
+ false,
25736
+ true
25377
25737
  );
25378
25738
  entries(
25379
25739
  "emails",
@@ -25395,7 +25755,8 @@ function validateConfig(value) {
25395
25755
  if ((e.text !== void 0 || e.html !== void 0) && e.subject === void 0) is.add(`${at}.subject`, 'is required with inline "text" / "html"');
25396
25756
  is.money(`${at}.previous_price`, e.previous_price);
25397
25757
  },
25398
- "always"
25758
+ "always",
25759
+ true
25399
25760
  );
25400
25761
  const ns = checks.notice_schedule;
25401
25762
  if (ns !== void 0 && typeof ns !== "boolean") is.add("checks.notice_schedule", `must be true or false (got ${show(ns)})`);
@@ -26470,11 +26831,14 @@ async function stripeJob(loaded, e, o, root) {
26470
26831
  params = r.params;
26471
26832
  if (r.unresolved.length) {
26472
26833
  job.partial = true;
26834
+ job.unresolved = r.unresolved;
26473
26835
  job.warnings.push(`Read statically; ${r.unresolved.length} value(s) are expressions Checkbox cannot evaluate (${r.unresolved.slice(0, 4).join(", ")}${r.unresolved.length > 4 ? ", \u2026" : ""}). Give the price in "plans" (and "plan" here), or use "module" to return the exact params.`);
26474
26836
  }
26475
26837
  }
26476
26838
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
26477
26839
  const merchant = planTerms(config, pickPlan(config, e.plan));
26840
+ const drift = planDrift(params, pickPlan(config, e.plan));
26841
+ if (drift) job.warnings.push(drift);
26478
26842
  const items = Array.isArray(params.line_items) ? params.line_items : [];
26479
26843
  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];
26480
26844
  job.body = { params, ...Object.keys(merchant).length ? { merchant } : {}, ...jurisdictionsOf(config) };
@@ -26483,6 +26847,23 @@ async function stripeJob(loaded, e, o, root) {
26483
26847
  }
26484
26848
  return job;
26485
26849
  }
26850
+ function planDrift(params, plan2) {
26851
+ if (!plan2) return void 0;
26852
+ const sd = params.subscription_data ?? {};
26853
+ const trial = typeof sd.trial_period_days === "number" ? sd.trial_period_days : void 0;
26854
+ const out = [];
26855
+ if (trial !== void 0 && trial !== (plan2.trial_days ?? 0)) out.push(`the params give a ${trial}-day free trial, plan "${plan2.name}" has ${plan2.trial_days ? `a ${plan2.trial_days}-day trial` : "none"}`);
26856
+ if (trial === void 0 && plan2.trial_days && plan2.trial_days > 0 && sd.trial_end === void 0) out.push(`plan "${plan2.name}" has a ${plan2.trial_days}-day free trial, the params give none`);
26857
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
26858
+ const pd = items.map((li) => li && typeof li === "object" ? li.price_data : void 0).find((x) => x && typeof x === "object");
26859
+ const rec = pd && typeof pd.recurring === "object" ? pd.recurring : void 0;
26860
+ if (rec && typeof rec.interval === "string" && rec.interval !== plan2.interval) out.push(`the params bill every ${rec.interval}, plan "${plan2.name}" every ${plan2.interval}`);
26861
+ const price3 = typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
26862
+ 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) {
26863
+ out.push(`the params charge ${(pd.unit_amount / 100).toFixed(2)}, plan "${plan2.name}" ${price3.toFixed(2)}`);
26864
+ }
26865
+ 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;
26866
+ }
26486
26867
  function disclosureJob(loaded, e, root) {
26487
26868
  const { config, dir } = loaded;
26488
26869
  const job = { name: e.name, type: "disclosure", ...e.plan ? { plan: e.plan } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
@@ -26509,9 +26890,17 @@ function disclosureJob(loaded, e, root) {
26509
26890
  }
26510
26891
  function pageJob(loaded, e, o, root) {
26511
26892
  const { config, dir } = loaded;
26512
- const job = { name: e.name, type: "page", ...e.plan ? { plan: e.plan } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
26513
- const terms2 = planTerms(config, pickPlan(config, e.plan));
26514
- const common3 = { ...e.kind ? { page_kind: e.kind } : {}, ...Object.keys(terms2).length ? { terms: terms2 } : {}, ...jurisdictionsOf(config) };
26893
+ const several = planList(config, e.plan);
26894
+ const one = several?.length === 1 ? several[0] : several ? void 0 : e.plan;
26895
+ const job = { name: e.name, type: "page", ...one ? { plan: one } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
26896
+ const plan2 = several && several.length > 1 ? void 0 : pickPlan(config, one);
26897
+ const terms2 = planTerms(config, plan2);
26898
+ const names = several && several.length > 1 ? several : !plan2 && !e.plan && (config.plans?.length ?? 0) > 1 ? (config.plans ?? []).map((p) => p.name) : void 0;
26899
+ const every = names?.map((n) => {
26900
+ const { merchant_name: _m, cancel_url: _c, ...own } = planTerms(config, pickPlan(config, n));
26901
+ return own;
26902
+ });
26903
+ const common3 = { ...e.kind ? { page_kind: e.kind } : {}, ...Object.keys(terms2).length ? { terms: terms2 } : {}, ...every ? { plans: every } : {}, ...jurisdictionsOf(config) };
26515
26904
  if (e.url) {
26516
26905
  if (!o.online) job.skip = "URL pages are fetched by the API: run with --online (and CHECKBOX_SECRET_KEY)";
26517
26906
  else job.body = { url: e.url, ...common3 };
@@ -26526,9 +26915,9 @@ function pageJob(loaded, e, o, root) {
26526
26915
  }
26527
26916
  return job;
26528
26917
  }
26529
- async function emailJob(loaded, e, o, root) {
26918
+ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
26530
26919
  const { config, dir } = loaded;
26531
- const job = { name: e.name, type: "email", ...e.plan ? { plan: e.plan } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
26920
+ const job = { name, type: "email", ...planName ? { plan: planName } : {}, target: { file: rel(loaded, root, loaded.path), line: entryLine(loaded.raw, e.name) }, inputs: [], warnings: [] };
26532
26921
  try {
26533
26922
  let msg2;
26534
26923
  if (e.file) {
@@ -26548,7 +26937,7 @@ async function emailJob(loaded, e, o, root) {
26548
26937
  } else msg2 = { subject: e.subject, ...e.text ? { text: e.text } : {}, ...e.html ? { html: e.html } : {} };
26549
26938
  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>`);
26550
26939
  if (!msg2.text && !msg2.html) throw new InputError("the email body is empty");
26551
- const terms2 = { ...planTerms(config, pickPlan(config, e.plan)) };
26940
+ const terms2 = { ...planTerms(config, pickPlan(config, planName)) };
26552
26941
  if (config.merchant?.support_contact) terms2.support_contact = config.merchant.support_contact;
26553
26942
  if (e.previous_price !== void 0) terms2.previous_price = typeof e.previous_price === "string" && /^[\d.]+$/.test(e.previous_price) ? Number(e.previous_price) : e.previous_price;
26554
26943
  job.body = { kind: e.kind, subject: msg2.subject, ...msg2.text ? { text: msg2.text } : {}, ...msg2.html ? { html: msg2.html } : {}, terms: terms2, ...jurisdictionsOf(config) };
@@ -26564,7 +26953,11 @@ async function resolveJobs(loaded, o) {
26564
26953
  for (const e of c.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
26565
26954
  for (const e of c.disclosures ?? []) jobs.push(disclosureJob(loaded, e, root));
26566
26955
  for (const e of c.pages ?? []) jobs.push(pageJob(loaded, e, o, root));
26567
- for (const e of c.emails ?? []) jobs.push(await emailJob(loaded, e, o, root));
26956
+ for (const e of c.emails ?? []) {
26957
+ const several = planList(loaded.config, e.plan);
26958
+ if (several && several.length > 1) for (const n of several) jobs.push(await emailJob(loaded, e, o, root, n, `${e.name} (${n})`));
26959
+ else jobs.push(await emailJob(loaded, e, o, root, several?.[0] ?? e.plan));
26960
+ }
26568
26961
  if (c.notice_schedule) {
26569
26962
  const plans = loaded.config.plans ?? [];
26570
26963
  for (const p of plans) {
@@ -26598,6 +26991,8 @@ var init_inputs = __esm({
26598
26991
  const [url, name, argsJson] = process.argv.slice(1);
26599
26992
  const mod = await import(url);
26600
26993
  let v = mod[name];
26994
+ // A CommonJS-compiled module (a .ts file without "type": "module") puts its named exports on default.
26995
+ if (v === undefined && name !== 'default' && mod.default && typeof mod.default === 'object' && name in mod.default) v = mod.default[name];
26601
26996
  if (v === undefined) {
26602
26997
  process.stderr.write('__CHECKBOX_ERR__ ' + (name === 'default' ? 'no default export' : 'no export named "' + name + '"') + ' (exports: ' + (Object.keys(mod).join(', ') || 'none') + ')\\n');
26603
26998
  process.exit(2);
@@ -26642,7 +27037,7 @@ function renderHuman(run2, c, opts = {}) {
26642
27037
  for (const w of x.warnings.slice(0, opts.verbose ? 10 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
26643
27038
  if (x.corrected && opts.verbose) lines.push(c.dim(` ${x.corrected.title}:`), ...x.corrected.body.split("\n").map((l) => c.dim(` ${l}`)));
26644
27039
  }
26645
- const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && !run2.strict ? c.yellow : c.red;
27040
+ const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
26646
27041
  lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}`);
26647
27042
  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."));
26648
27043
  lines.push(c.dim(run2.disclaimer));
@@ -26651,7 +27046,7 @@ function renderHuman(run2, c, opts = {}) {
26651
27046
  function annotations(run2) {
26652
27047
  const out = [];
26653
27048
  for (const x of run2.checks) {
26654
- const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict ? "error" : "warning" : void 0;
27049
+ const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : void 0;
26655
27050
  if (!level) continue;
26656
27051
  const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
26657
27052
  const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
@@ -26781,6 +27176,7 @@ var init_src3 = __esm({
26781
27176
  init_engine();
26782
27177
  init_checks2();
26783
27178
  init_terms_of_service();
27179
+ init_context();
26784
27180
  init_plan_terms();
26785
27181
  init_offer();
26786
27182
  init_offer_view();
@@ -27196,7 +27592,10 @@ function fixesOf(r) {
27196
27592
  const out = [];
27197
27593
  const failing = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status)));
27198
27594
  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));
27199
- for (const c of failing) if (c.fix && !out.includes(c.fix)) out.push(c.fix);
27595
+ for (const c of failing) {
27596
+ const text = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
27597
+ if (text && !out.includes(text)) out.push(text);
27598
+ }
27200
27599
  return out;
27201
27600
  }
27202
27601
  function correctedOf(type, r, partial = false) {
@@ -27220,7 +27619,10 @@ ${r.template.text}` };
27220
27619
  }
27221
27620
  function noticeCoverage(config, planName, schedule) {
27222
27621
  const plan2 = (config.plans ?? []).find((p) => p.name === planName);
27223
- const have = new Set((config.checks.emails ?? []).filter((e) => !e.plan || e.plan === planName).map((e) => e.kind));
27622
+ const have = new Set((config.checks.emails ?? []).filter((e) => !e.plan || e.plan === planName || planList(config, e.plan)?.includes(planName)).map((e) => e.kind));
27623
+ const sb = config.notices?.sent_by_checkbox;
27624
+ const byCheckbox = new Set(sb === true ? Object.values(NOTICE_TO_EMAIL) : Array.isArray(sb) ? sb : []);
27625
+ for (const k of byCheckbox) have.add(k);
27224
27626
  const yearly = plan2 ? (plan2.interval === "year" ? 12 * (plan2.interval_count ?? 1) : plan2.interval === "month" ? plan2.interval_count ?? 1 : 0) >= 12 : false;
27225
27627
  if (yearly && have.has("renewal_reminder")) have.add("annual_terms");
27226
27628
  const required = /* @__PURE__ */ new Map();
@@ -27230,7 +27632,8 @@ function noticeCoverage(config, planName, schedule) {
27230
27632
  }
27231
27633
  const missing = [...required].filter(([k]) => !have.has(k));
27232
27634
  if (!required.size) return { status: "not_applicable", label: "No reminder emails required", fixes: [] };
27233
- if (!missing.length) return { status: "pass", label: `Checks pass \xB7 ${required.size} required email${required.size === 1 ? "" : "s"} covered`, fixes: [] };
27635
+ const sent2 = [...required.keys()].filter((k) => byCheckbox.has(k)).length;
27636
+ 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, as checkbox.json declares)` : ""}`, fixes: [] };
27234
27637
  const fixes = missing.map(([k, w]) => `Add 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.`);
27235
27638
  return { status: "needs_review", label: `${missing.length} required email${missing.length === 1 ? "" : "s"} not in checkbox.json`, fix: fixes[0], fixes };
27236
27639
  }
@@ -27243,17 +27646,26 @@ async function runJob(job, engine, config) {
27243
27646
  report = await engine.run(ENGINE_KIND[job.type], job.body);
27244
27647
  } catch (err) {
27245
27648
  const e = err;
27246
- return { ...base, status: "error", label: "Could not run", message: `${e.message}${e.param ? ` (${e.param})` : ""}`, fixes: [] };
27649
+ const failure = isAuthError(err) ? "auth" : err instanceof CheckboxError && (!e.statusCode || e.statusCode >= 500 || e.statusCode === 429) ? "api" : void 0;
27650
+ return { ...base, status: "error", label: "Could not run", message: `${e.message}${e.param ? ` (${e.param})` : ""}`, fixes: [], ...failure ? { failure } : {} };
27247
27651
  }
27248
27652
  if (job.type === "notice_schedule") return { ...base, ...noticeCoverage(config, job.plan, report), report };
27249
27653
  for (const w of report.warnings ?? []) base.warnings.push(w.message);
27250
27654
  const status = ciStatusOf(report);
27251
27655
  const fixes = status === "pass" || status === "not_applicable" ? [] : fixesOf(report);
27656
+ const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection)\b/.test(p));
27657
+ const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review");
27658
+ if (unread) {
27659
+ fixes.unshift(`${unreadText.slice(0, 3).join(", ")} ${unreadText.length === 1 ? "is an expression" : "are expressions"} Checkbox cannot read statically (e.g. a ternary), so ${unreadText.length === 1 ? "its" : "their"} text was not checked. Use "module" (a function returning the exact params for each plan) or "file" instead of "source"; the findings below are what the params read without ${unreadText.length === 1 ? "it" : "them"}.`);
27660
+ }
27661
+ const finalStatus = unread ? "needs_review" : status;
27252
27662
  const corrected = correctedOf(job.type, report, job.partial);
27663
+ const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked");
27253
27664
  return {
27254
27665
  ...base,
27255
- status,
27256
- label: report.compliance?.label || DEFAULT_LABEL[status],
27666
+ status: finalStatus,
27667
+ ...blocking ? { blocking: true } : {},
27668
+ label: unread ? `Not checked: ${unreadText[0]} is an expression` : report.compliance?.label || DEFAULT_LABEL[status],
27257
27669
  ...fixes[0] ? { fix: fixes[0] } : {},
27258
27670
  fixes,
27259
27671
  ...corrected ? { corrected } : {},
@@ -27261,14 +27673,36 @@ async function runJob(job, engine, config) {
27261
27673
  };
27262
27674
  }
27263
27675
  function exitCodeOf(checks, strict) {
27676
+ if (checks.some((c) => c.failure === "auth")) return { status: "error", exit: EXIT.auth };
27677
+ if (checks.some((c) => c.failure === "api")) return { status: "error", exit: EXIT.error };
27264
27678
  if (checks.some((c) => c.status === "error")) return { status: "error", exit: EXIT.usage };
27265
27679
  if (checks.some((c) => c.status === "fail")) return { status: "fail", exit: EXIT.fail };
27266
- if (checks.some((c) => c.status === "needs_review")) return { status: "needs_review", exit: strict ? EXIT.needs_review : EXIT.ok };
27680
+ if (checks.some((c) => c.status === "needs_review")) return { status: "needs_review", exit: strict || checks.some((c) => c.blocking) ? EXIT.needs_review : EXIT.ok };
27267
27681
  return { status: "pass", exit: EXIT.ok };
27268
27682
  }
27683
+ function mergeSourcePatches(checks) {
27684
+ const groups = /* @__PURE__ */ new Map();
27685
+ for (const c of checks) {
27686
+ if (c.type !== "stripe_checkout" || c.corrected?.format !== "json" || !c.target.line) continue;
27687
+ const key = `${c.target.file}:${c.target.line}`;
27688
+ groups.set(key, [...groups.get(key) ?? [], c]);
27689
+ }
27690
+ for (const list2 of groups.values()) {
27691
+ if (list2.length < 2) continue;
27692
+ const body = {};
27693
+ for (const c of list2) body[c.plan ?? c.name] = JSON.parse(c.corrected.body);
27694
+ const first2 = list2[0];
27695
+ first2.corrected = { format: "json", title: `Changes for this one call, per plan (${list2.length} plans share it: choose each value by plan, e.g. build the params in a function of the plan)`, body: JSON.stringify(body, null, 2) };
27696
+ for (const c of list2.slice(1)) {
27697
+ delete c.corrected;
27698
+ c.warnings.push(`The changes for this call (shared with "${first2.name}") are shown once, per plan, under "${first2.name}".`);
27699
+ }
27700
+ }
27701
+ }
27269
27702
  async function runCi(jobs, engine, opts) {
27270
27703
  const checks = [...opts.extra ?? []];
27271
27704
  for (const j of jobs) checks.push(await runJob(j, engine, opts.config));
27705
+ mergeSourcePatches(checks);
27272
27706
  const summary = { pass: 0, fail: 0, needs_review: 0, not_applicable: 0, skipped: 0, error: 0 };
27273
27707
  for (const c of checks) summary[c.status]++;
27274
27708
  const { status, exit } = exitCodeOf(checks, opts.strict);
@@ -27292,7 +27726,10 @@ var init_runner = __esm({
27292
27726
  "src/ci/runner.ts"() {
27293
27727
  "use strict";
27294
27728
  init_src();
27729
+ init_http();
27730
+ init_src();
27295
27731
  init_types2();
27732
+ init_config3();
27296
27733
  ENGINE_KIND = {
27297
27734
  stripe_checkout: "stripe_checkout_session",
27298
27735
  disclosure: "disclosure",
@@ -27487,7 +27924,8 @@ var init_ci = __esm({
27487
27924
  "In GitHub Actions (GITHUB_ACTIONS=true): ::error / ::warning annotations on the file each check reads, a Markdown",
27488
27925
  "table in $GITHUB_STEP_SUMMARY, and status=<pass|fail|needs_review|error|skipped> in $GITHUB_OUTPUT.",
27489
27926
  "",
27490
- "Exit: 0 pass (or needs review without --strict) \xB7 1 a check needs a fix \xB7 2 needs review with --strict \xB7",
27927
+ "Exit: 0 pass (or needs review without --strict) \xB7 1 a check needs a fix \xB7 2 needs review with --strict, or a",
27928
+ " page / disclosure with no renewal terms to judge \xB7",
27491
27929
  " 3 config / usage error \xB7 4 auth (--online) \xB7 5 API or network error",
27492
27930
  "",
27493
27931
  "checkbox ci init [--yes] [--force] [--dry-run] [--no-workflow]",
@@ -28952,7 +29390,7 @@ var init_plan = __esm({
28952
29390
  " --json The plan object exactly as the API returns it (for agents)",
28953
29391
  "",
28954
29392
  "The loop: apply plan.next_action \u2192 `checkbox fix <task>` (prints the change, or an exact diff of your file) \u2192",
28955
- "`checkbox verify <task> --wait` \u2192 `checkbox plan` again, until status is compliant. Tasks marked [!] need a human",
29393
+ "`checkbox verify <task> --wait` \u2192 `checkbox plan` again, until the checks pass (status compliant). Tasks marked [!] need a human",
28956
29394
  "answer (answerTask); they never block the others."
28957
29395
  ].join("\n"),
28958
29396
  async run(ctx) {