@checkbox-site/cli 0.4.8 → 0.4.9

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 +362 -90
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -144,7 +144,8 @@ function findProjectKey(cwd, env2 = {}) {
144
144
  const v = readEnvValue(text, "CHECKBOX_SECRET_KEY");
145
145
  if (v && looksLikeApiKey(v)) {
146
146
  const up = depth === 0 ? "" : "../".repeat(depth);
147
- return { apiKey: v, file: `${up}${f}` };
147
+ const base = readEnvValue(text, "CHECKBOX_API_BASE");
148
+ return { apiKey: v, file: `${up}${f}`, ...base && /^https?:\/\//.test(base) ? { apiBase: base } : {} };
148
149
  }
149
150
  }
150
151
  if (existsSync(join(dir, ".git")) || dir === home) break;
@@ -198,12 +199,12 @@ function createConfigStore(env2) {
198
199
  };
199
200
  }
200
201
  function resolveCredentials(env2, stored, flags, project) {
201
- const apiBase = (flags.apiBase || env2.CHECKBOX_API_BASE || stored.api_base || DEFAULT_API_BASE).replace(/\/+$/, "");
202
- if (flags.apiKey) return { apiKey: flags.apiKey, apiBase, source: "flag" };
203
- if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase, source: "env" };
204
- if (env2.CHECKBOX_SECRET_KEY) return { apiKey: env2.CHECKBOX_SECRET_KEY, apiBase, source: "env" };
205
- if (project) return { apiKey: project.apiKey, apiBase, source: "project", file: project.file };
206
- if (stored.api_key) return { apiKey: stored.api_key, apiBase, source: "config" };
202
+ const base = (own) => (flags.apiBase || env2.CHECKBOX_API_BASE || own || DEFAULT_API_BASE).replace(/\/+$/, "");
203
+ if (flags.apiKey) return { apiKey: flags.apiKey, apiBase: base(), source: "flag" };
204
+ if (env2.CHECKBOX_API_KEY) return { apiKey: env2.CHECKBOX_API_KEY, apiBase: base(), source: "env" };
205
+ if (env2.CHECKBOX_SECRET_KEY) return { apiKey: env2.CHECKBOX_SECRET_KEY, apiBase: base(), source: "env" };
206
+ if (project) return { apiKey: project.apiKey, apiBase: base(project.apiBase), source: "project", file: project.file };
207
+ if (stored.api_key) return { apiKey: stored.api_key, apiBase: base(stored.api_base), source: "config" };
207
208
  return void 0;
208
209
  }
209
210
  function describeKeySource(c, env2 = {}) {
@@ -3030,7 +3031,7 @@ var init_operations = __esm({
3030
3031
  /**
3031
3032
  * Check a subscription lifecycle email.
3032
3033
  *
3033
- * Checks the content of an acknowledgment (welcome) email, trial-ending reminder, renewal reminder, price-change notice, yearly terms reminder or cancellation confirmation against the requirements for that notice (renewal terms, amount, frequency, how to cancel, trial cancellation). The email is modelled as sent on time, so the verdict is about content; get send dates from checkNoticeSchedule. Prices are judged against what Stripe charges: with `site` (or, without it and without a price in terms, the account's one site with Stripe connected) the plan fields come from the site's plans and the email is matched to the plan it names ("Annual") first; a price that contradicts it fails, and `warnings` say which site and plan were used. Without `site`, a price you send in terms is judged as sent; when it differs from every plan of that site, the result is at most needs_review and Next is update_setup (update the site's plans first). Returns `required_by` (requirements that make the email mandatory for this plan) and a `template` that passes the checks, with {{merge_tags}}, verified by the engine. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
3034
+ * Checks the content of an acknowledgment (welcome) email, trial-ending reminder, renewal reminder, price-change notice, yearly terms reminder or cancellation confirmation against the requirements for that notice (renewal terms, amount, frequency, how to cancel, trial cancellation). The email is modelled as sent on time, so the verdict is about content; get send dates from checkNoticeSchedule. Prices are judged against what Stripe charges: with `site` (or, without it and without a price in terms, the account's one site with Stripe connected) the plan fields come from the site's plans and the email is matched to the plan it names ("Annual") first; a price that contradicts it fails, and `warnings` say which site and plan were used. Without `site`, a price you send in terms is judged as sent; when it differs from every plan of that site, the verdict is still judged with the terms you sent and a `site_plans_differ` warning says to update the site's plans (update_setup) once the change is deployed. Returns `required_by` (requirements that make the email mandatory for this plan) and a `template` that passes the checks, with {{merge_tags}}, verified by the engine. Stateless and deterministic: nothing is stored, the same input on the same as_of date gives the same verdicts. Every verdict comes from the Checkbox rules engine over the legal catalog (federal ROSCA / EFTA plus the state automatic-renewal laws); the response carries rules_version, catalog_version and a disclaimer (not legal advice). Read `verdict`, then `next_action`: it tells you exactly what to change and to call the check again until verdict is pass.
3034
3035
  *
3035
3036
  * @see POST /v1/checks/email (operation `checkEmail`, scope `checks:read`)
3036
3037
  */
@@ -3617,7 +3618,7 @@ var SDK_VERSION;
3617
3618
  var init_version = __esm({
3618
3619
  "../sdk-node/src/version.ts"() {
3619
3620
  "use strict";
3620
- SDK_VERSION = "0.2.6";
3621
+ SDK_VERSION = "0.2.7";
3621
3622
  }
3622
3623
  });
3623
3624
 
@@ -4793,16 +4794,25 @@ var init_stripe_key = __esm({
4793
4794
  function goLiveSteps(input = {}) {
4794
4795
  const out = [];
4795
4796
  if (!input.domainVerified) out.push({ key: "verify_domain", who: "person", text: "Verify your domain (a DNS record or a file on your site). Live checks start only on a verified public domain." });
4796
- if (input.termsOfServiceRequired && !input.termsOfServiceUrlSet) out.push({ key: "stripe_terms_url", who: "person", text: input.termsOfServiceConditional ? GO_LIVE_TERMS_URL_STEP_CONDITIONAL : GO_LIVE_TERMS_URL_STEP, url: STRIPE_PUBLIC_DETAILS });
4797
+ if (input.termsOfServiceRequired && !input.termsOfServiceUrlSet)
4798
+ out.push({ key: "stripe_terms_url", who: "person", text: input.checkoutKind === "payment_link" ? GO_LIVE_TERMS_URL_STEP_PAYMENT_LINK : input.termsOfServiceConditional ? GO_LIVE_TERMS_URL_STEP_CONDITIONAL : GO_LIVE_TERMS_URL_STEP, url: STRIPE_PUBLIC_DETAILS });
4797
4799
  if (!input.liveKeyConnected)
4798
4800
  out.push({
4799
4801
  key: "live_stripe_key",
4800
4802
  who: "person",
4801
- text: "Switch the dashboard to Live mode and connect your live Stripe key (rk_live_\u2026) with the same permissions as your test key (the checklist next to the key field: Read for the checks; Write only for fixes you approve and to add Checkbox\u2019s webhook endpoint). Only a person adds live keys; an agent never does."
4803
+ text: `Switch the dashboard to Live mode and connect your live Stripe key (rk_live_\u2026) with the same permissions as your test key (the checklist next to the key field: Read for the checks; Write only for fixes you approve and to add Checkbox\u2019s webhook endpoint).${input.noAgent ? " Only a person adds live keys." : " Only a person adds live keys; an agent never does."}`
4802
4804
  });
4803
- if (input.checkoutKind === "payment_link")
4804
- out.push({ key: "live_checkout", who: "person", text: "Create the live Payment Link in Stripe live mode with the same settings as the test link Checkbox checked (renewal terms, consent checkbox, cancellation link); then your agent puts the live link on your site in place of the test link." });
4805
- else if (input.checkoutKind === "stripe_checkout")
4805
+ if (input.portalCancel)
4806
+ out.push({
4807
+ key: "live_portal_cancel",
4808
+ who: "person",
4809
+ text: 'Turn on cancellation in the Stripe customer portal in live mode (Stripe Dashboard in Live mode \u203A Settings \u203A Billing \u203A Customer portal: "Cancel subscriptions", with the same timing as in test mode, and the customer login link). Stripe keeps these settings separately for test and live mode, so the test-mode setting does not carry over.',
4810
+ url: STRIPE_PORTAL_SETTINGS
4811
+ });
4812
+ if (input.checkoutKind === "payment_link") {
4813
+ out.push({ key: "live_checkout", who: "person", text: `Create the live Payment Link in Stripe live mode with the same settings as the test link Checkbox checked (renewal terms, consent checkbox, cancellation link); then put the live link on your site in place of the test link${input.noAgent ? "" : " (or ask your agent to)"}.` });
4814
+ out.push({ key: "deactivate_old_live_link", who: "person", text: "If an older live Payment Link without these settings exists, deactivate it in Stripe live mode (Payment Links \u203A the old link \u203A Deactivate): anyone with its URL could still subscribe without seeing the terms." });
4815
+ } else if (input.checkoutKind === "stripe_checkout")
4806
4816
  out.push({ key: "live_checkout", who: "agent", text: "Ship the same Checkout Session params Checkbox checked with your live Price ids (the live Stripe secret key in your production settings is set by a person)." });
4807
4817
  out.push({
4808
4818
  key: "promote_site",
@@ -4811,13 +4821,15 @@ function goLiveSteps(input = {}) {
4811
4821
  });
4812
4822
  return out;
4813
4823
  }
4814
- var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_TERMS_URL_STEP_CONDITIONAL, GO_LIVE_STEPS;
4824
+ var STRIPE_PORTAL_SETTINGS, STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, GO_LIVE_TERMS_URL_STEP_CONDITIONAL, GO_LIVE_TERMS_URL_STEP_PAYMENT_LINK, GO_LIVE_STEPS;
4815
4825
  var init_go_live = __esm({
4816
4826
  "../core/src/go-live.ts"() {
4817
4827
  "use strict";
4828
+ STRIPE_PORTAL_SETTINGS = "https://dashboard.stripe.com/settings/billing/portal";
4818
4829
  STRIPE_PUBLIC_DETAILS = "https://dashboard.stripe.com/settings/public";
4819
4830
  GO_LIVE_TERMS_URL_STEP = 'Set your Terms of Service URL in the Stripe Dashboard (Settings > Public details). Your checkout asks shoppers to accept your terms (consent_collection.terms_of_service = "required"), and Stripe refuses those Checkout Sessions until that URL is set.';
4820
4831
  GO_LIVE_TERMS_URL_STEP_CONDITIONAL = 'Set your Terms of Service URL in the Stripe Dashboard (Settings > Public details) before your checkout asks shoppers to accept your terms (consent_collection.terms_of_service = "required", which the checkout fix adds): Stripe refuses those Checkout Sessions until that URL is set.';
4832
+ GO_LIVE_TERMS_URL_STEP_PAYMENT_LINK = "Set your Terms of Service URL in the Stripe Dashboard (Settings > Public details). Your Payment Link asks shoppers to accept your terms (\u201CRequire customers to accept your terms of service\u201D), and Stripe only allows that once the URL is set.";
4821
4833
  GO_LIVE_STEPS = goLiveSteps().map((s2) => s2.text);
4822
4834
  }
4823
4835
  });
@@ -12217,18 +12229,15 @@ function checkoutSessionsForSite(sessions, captures, checkoutType) {
12217
12229
  const ownCardForm = captures.some((c) => OWN_CARD_FORM_RE.test(c.text ?? ""));
12218
12230
  if (checkoutType !== "stripe_checkout" && !linksToCheckout && ownCardForm) return [];
12219
12231
  if (checkoutType !== "stripe_checkout" && !linksToCheckout && stripePayLinksIn(captures).length) return [];
12220
- const seen = /* @__PURE__ */ new Set();
12221
- const out = [];
12222
- for (const s2 of newestFirst(sessions)) {
12223
- if (s2.paymentLink || s2.checkboxTestRun) continue;
12224
- if (!(s2.lineItems ?? []).some((li) => !!li.interval || !!li.price)) continue;
12232
+ const picked = /* @__PURE__ */ new Map();
12233
+ newestFirst(sessions).forEach((s2, i) => {
12234
+ if (s2.paymentLink || s2.checkboxTestRun) return;
12235
+ if (!(s2.lineItems ?? []).some((li) => !!li.interval || !!li.price)) return;
12225
12236
  const key = (s2.lineItems ?? []).map((li) => li.price ?? `${li.amount}|${li.interval}|${li.intervalCount}`).sort().join(",");
12226
- if (seen.has(key)) continue;
12227
- seen.add(key);
12228
- out.push(s2);
12229
- if (out.length >= MAX_CHECKOUT_SESSIONS) break;
12230
- }
12231
- return out;
12237
+ const prev = picked.get(key);
12238
+ if (!prev || prev.s.status === "expired" && s2.status !== "expired") picked.set(key, { s: s2, i });
12239
+ });
12240
+ return [...picked.values()].sort((a, b) => a.i - b.i).slice(0, MAX_CHECKOUT_SESSIONS).map((x) => x.s);
12232
12241
  }
12233
12242
  function paymentLinkParams(link, opts = {}) {
12234
12243
  const items = (link.lineItems ?? []).filter((li) => li && (li.interval || li.price));
@@ -16655,7 +16664,14 @@ function noticeContent(e, opts = {}) {
16655
16664
  if (when) quotes.trial_end = quote(when);
16656
16665
  const afterTrial = opts.firstChargeAfterTrial === "always" || opts.firstChargeAfterTrial === "when_trial_mentioned" && TRIAL_ENDING_RE.test(body);
16657
16666
  const intro = wrong.amount ? void 0 : introEmailFinding(body, plans, { termsComplete: plans.length > 0 && plans.every((p) => isPreflightPlan(p)), extraAmounts: opts.extraAmounts ?? [] }) ?? (afterTrial ? introFirstChargeFinding(body, plans) : void 0);
16658
- return { present: Object.keys(quotes).filter((k) => quotes[k]), quotes, ...Object.keys(wrong).length ? { wrong } : {}, ...intro ? { intro } : {} };
16667
+ const trialLength = opts.trialLength?.(a, e);
16668
+ return {
16669
+ present: Object.keys(quotes).filter((k) => quotes[k]),
16670
+ quotes,
16671
+ ...Object.keys(wrong).length ? { wrong } : {},
16672
+ ...intro ? { intro } : {},
16673
+ ...trialLength ? { trialLength } : {}
16674
+ };
16659
16675
  }
16660
16676
  function missingElements(content, required) {
16661
16677
  return required.filter((el) => !content.present.includes(el));
@@ -16667,6 +16683,12 @@ function describeMissing(missing) {
16667
16683
  return missing.map((m) => ELEMENT_LABELS[m]).join(", ");
16668
16684
  }
16669
16685
  function contentFixLine(checkId, data) {
16686
+ const tl = data?.contentTrialLength;
16687
+ const trialFix = NOTICE_NOUN[checkId] && typeof tl?.stated === "string" && typeof tl.plan === "string" ? `The ${NOTICE_NOUN[checkId]} says ${tl.stated}, but ${tl.plan}: state the trial the subscriber really gets.` : void 0;
16688
+ const line2 = contentFixLineBase(checkId, data);
16689
+ return line2 && trialFix ? `${line2} ${trialFix}` : line2 ?? trialFix;
16690
+ }
16691
+ function contentFixLineBase(checkId, data) {
16670
16692
  if (typeof data?.contentIntro?.fix === "string" && !(Array.isArray(data?.contentMissing) && data.contentMissing.length)) return data.contentIntro.fix;
16671
16693
  if ((checkId === "signup.disclosure.price" || checkId === "confirmation.email.terms") && Array.isArray(data?.units)) {
16672
16694
  const line2 = data.units.find((x) => x && x.status !== "pass" && typeof x.introFix === "string")?.introFix;
@@ -16683,7 +16705,10 @@ function contentFixFromText(checkId, text) {
16683
16705
  const noun = NOTICE_NOUN[checkId];
16684
16706
  if (!noun) return void 0;
16685
16707
  const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
16686
- return m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16708
+ const add = m ? `Add ${m[1]} to the ${noun}; Checkbox's template has every required line.` : void 0;
16709
+ const t = /\bsays (“[^”]+”), but (.+?): state the trial the subscriber really gets/.exec(text);
16710
+ const trial = t ? `The ${noun} says ${t[1]}, but ${t[2]}: state the trial the subscriber really gets.` : void 0;
16711
+ return add && trial ? `${add} ${trial}` : add ?? trial;
16687
16712
  }
16688
16713
  var ELEMENT_LABELS, CHARGE_FOLLOWS_RE, DATE_TAG_RE, TRIAL_END_WHEN_RE, PRODUCT_RE, CONTACT_RE, CANCEL_URL_RE, CHARGE_AMOUNT_EVENTS, NOTICE_NOUN;
16689
16714
  var init_notice_content = __esm({
@@ -16770,6 +16795,19 @@ function offerLabel(o) {
16770
16795
  const price3 = `${(o.amount / 10 ** digits).toFixed(digits)} ${o.currency.toUpperCase()} every ${o.intervalCount > 1 ? `${o.intervalCount} ${o.interval}s` : o.interval}`;
16771
16796
  return o.name ? `${o.name} (${price3})` : price3;
16772
16797
  }
16798
+ function trialLengthFits(days, trial) {
16799
+ return days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
16800
+ }
16801
+ function statedTrialMismatch(a, p, offers) {
16802
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
16803
+ if (!stated.length) return void 0;
16804
+ const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
16805
+ if (!own.length) return void 0;
16806
+ if (stated.some((t) => own.some((o) => trialLengthFits(t.days, o.trialDays)))) return void 0;
16807
+ const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
16808
+ const plan2 = p?.offer ? `${offerLabel(p.offer)} has a ${p.offer.trialDays}-day free trial` : `the plan${lengths.length > 1 ? "s give" : " gives"} a ${lengths.join("- or ")}-day free trial`;
16809
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
16810
+ }
16773
16811
  var DAYS;
16774
16812
  var init_email_plan = __esm({
16775
16813
  "../rules/src/checks/email-plan.ts"() {
@@ -16842,11 +16880,29 @@ var init_forwarded = __esm({
16842
16880
 
16843
16881
  // ../rules/src/checks/confirmation.ts
16844
16882
  function isPostSignupEmail(e) {
16845
- if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
16883
+ if (e.kind !== "other" || !(e.scanId || testRunAckEmail(e)) || !e.relatedSignupAt) return false;
16846
16884
  const received = toDate(e.receivedAt);
16847
16885
  const signup = toDate(e.relatedSignupAt);
16848
16886
  return !!received && !!signup && received.getTime() >= signup.getTime() - HOUR_MS;
16849
16887
  }
16888
+ function testRunAckEmail(e) {
16889
+ const run2 = e.testRun;
16890
+ return !!run2 && typeof run2 === "object" && run2.step === "acknowledgment_email";
16891
+ }
16892
+ function acknowledgmentLike(e) {
16893
+ if (e.kind !== "other" || isCheckboxSentEmail(e) || e.noticeSample) return false;
16894
+ const opening = `${e.subject ?? ""}
16895
+ ${emailBody(e).slice(0, 600)}`;
16896
+ return ACK_LIKE_RE.test(opening) && !NEWSLETTER_RE.test(`${e.subject ?? ""}
16897
+ ${emailBody(e).slice(0, 300)}`);
16898
+ }
16899
+ function acknowledgmentCandidate(e, explicit) {
16900
+ if (!acknowledgmentLike(e)) return false;
16901
+ if (isPostSignupEmail(e)) return true;
16902
+ const key = emailTemplateKey(e);
16903
+ const at = toDate(e.receivedAt)?.getTime() ?? 0;
16904
+ return explicit.some((a) => emailTemplateKey(a) === key && (toDate(a.receivedAt)?.getTime() ?? Infinity) <= at);
16905
+ }
16850
16906
  function findAcknowledgments(emails) {
16851
16907
  const cached = ackCache.get(emails);
16852
16908
  if (cached) return cached;
@@ -16856,6 +16912,7 @@ function findAcknowledgments(emails) {
16856
16912
  const acknowledged = new Set(explicit.map((e) => recipientOf(e)).filter((r) => r !== ""));
16857
16913
  for (const e of emails) {
16858
16914
  if (e.kind === "acknowledgment") acks.push(e);
16915
+ else if (acknowledgmentCandidate(e, explicit)) acks.push(e);
16859
16916
  else if (acknowledged.has(recipientOf(e))) continue;
16860
16917
  else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
16861
16918
  else if (e.kind === "receipt" || isPostSignupEmail(e)) {
@@ -16891,6 +16948,18 @@ function noAckResult(checkId, input, set) {
16891
16948
  }
16892
16949
  if (set.receipts.length) {
16893
16950
  const r = set.receipts[set.receipts.length - 1];
16951
+ const runOnly = set.receipts.filter(testRunAckEmail);
16952
+ if (runOnly.length && !noticesCover) {
16953
+ const last = runOnly[runOnly.length - 1];
16954
+ return makeResult(
16955
+ checkId,
16956
+ "fail",
16957
+ `The test run's sign-up received ${runOnly.length === 1 ? "one email" : `${runOnly.length} emails`} (e.g. ${emailLabel(last)}), and none restates the subscription terms (price, how often it renews, that it renews until canceled, and how to cancel) \u2014 send an acknowledgment with the terms right after sign-up.`,
16958
+ runOnly.flatMap(emailRefs),
16959
+ 0.75,
16960
+ { runSignupWithoutTerms: runOnly.map((e) => e.id) }
16961
+ );
16962
+ }
16894
16963
  return makeResult(
16895
16964
  checkId,
16896
16965
  "needs_review",
@@ -17066,18 +17135,7 @@ function cancelBeforeTrialMatch(text) {
17066
17135
  if (byDate && TRIAL_ENDS_DATE_RE.test(text)) return byDate[0];
17067
17136
  return null;
17068
17137
  }
17069
- function statedTrialMismatch(a, p, offers) {
17070
- const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
17071
- if (!stated.length) return void 0;
17072
- const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
17073
- if (!own.length) return void 0;
17074
- const fits = (days, trial) => days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
17075
- if (stated.some((t) => own.some((o) => fits(t.days, o.trialDays)))) return void 0;
17076
- const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
17077
- const plan2 = p?.offer ? `${offerLabel(p.offer)} has a ${p.offer.trialDays}-day free trial` : `the plan${lengths.length > 1 ? "s give" : " gives"} a ${lengths.join("- or ")}-day free trial`;
17078
- return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
17079
- }
17080
- var ACK_MAX_DELAY_HOURS, NO_ACK_EMAIL_MESSAGE, ackCache, SIGNUP_LINK_TOLERANCE_MS, sent, terms, cancellationInstructions, CANCELLING_RE, CANCEL_BEFORE_TRIAL_RE, MONTH, DATE, CANCEL_BEFORE_NO_CHARGE_RE, CANCEL_BEFORE_DATE_RE, TRIAL_ENDS_DATE_RE, trialTerms, CONFIRMATION_CHECKS;
17138
+ var ACK_MAX_DELAY_HOURS, NO_ACK_EMAIL_MESSAGE, ackCache, ACK_LIKE_RE, NEWSLETTER_RE, SIGNUP_LINK_TOLERANCE_MS, sent, terms, cancellationInstructions, CANCELLING_RE, CANCEL_BEFORE_TRIAL_RE, MONTH, DATE, CANCEL_BEFORE_NO_CHARGE_RE, CANCEL_BEFORE_DATE_RE, TRIAL_ENDS_DATE_RE, trialTerms, CONFIRMATION_CHECKS;
17081
17139
  var init_confirmation = __esm({
17082
17140
  "../rules/src/checks/confirmation.ts"() {
17083
17141
  "use strict";
@@ -17093,6 +17151,8 @@ var init_confirmation = __esm({
17093
17151
  ACK_MAX_DELAY_HOURS = 24;
17094
17152
  NO_ACK_EMAIL_MESSAGE = "No acknowledgment email captured yet \u2014 BCC your sign-up confirmation email to your Checkbox inbound address (see Install), or add a test purchase to your scan settings so our shopper inbox receives it.";
17095
17153
  ackCache = /* @__PURE__ */ new WeakMap();
17154
+ ACK_LIKE_RE = /\bwelcome\b|\bthanks?\s+(?:you\s+)?for\s+(?:subscribing|joining|signing\s+up|your\s+subscription|starting\s+your|choosing\s+our)\b|\byou(?:'re|’re|\s+are)\s+(?:now\s+)?(?:subscribed|a\s+member|officially\s+in)\b|\b(?:subscription|membership)\s+(?:is\s+)?(?:confirmed|active|activated|started)\b|\b(?:free\s+)?trial\s+(?:has\s+(?:started|begun)|starts\s+(?:today|now)|is\s+now\s+active)\b/i;
17155
+ NEWSLETTER_RE = /\b(?:newsletter|mailing\s+list|digest|blog\s+(?:posts?|updates?))\b/i;
17096
17156
  SIGNUP_LINK_TOLERANCE_MS = 30 * 6e4;
17097
17157
  sent = {
17098
17158
  id: "confirmation.email.sent",
@@ -17527,6 +17587,11 @@ function wholeDaysBefore(days, tie = "up") {
17527
17587
  function sentInWindow(days, w) {
17528
17588
  return wholeDaysBefore(days, "up") <= w.maxDays && days >= w.minDays - MIN_DAYS_GRACE_DAYS;
17529
17589
  }
17590
+ function countsAsWholeDays(days) {
17591
+ if (Number.isInteger(days)) return "";
17592
+ const whole = wholeDaysBefore(days, "up");
17593
+ return `, which counts as ${whole} day${whole === 1 ? "" : "s"} by the whole-day rule`;
17594
+ }
17530
17595
  function windowCloseMs(dueMs, w) {
17531
17596
  return dueMs - (w.minDays - MIN_DAYS_GRACE_DAYS) * DAY_MS;
17532
17597
  }
@@ -17890,14 +17955,29 @@ function contentGap(required, emails, opts = {}) {
17890
17955
  const content = noticeContent(email, opts);
17891
17956
  const wrongKeys = Object.keys(content.wrong ?? {});
17892
17957
  const missing = [.../* @__PURE__ */ new Set([...missingElements(content, required), ...wrongKeys])];
17893
- if (missing.length || content.intro) return { email, missing, ...content.wrong ? { wrong: content.wrong } : {}, ...content.intro ? { intro: content.intro } : {} };
17958
+ if (missing.length || content.intro || content.trialLength)
17959
+ return { email, missing, ...content.wrong ? { wrong: content.wrong } : {}, ...content.intro ? { intro: content.intro } : {}, ...content.trialLength ? { trialLength: content.trialLength } : {} };
17894
17960
  }
17895
17961
  return null;
17896
17962
  }
17897
17963
  function contentFail(spec, required, gap2, timing, refs2, data) {
17898
17964
  const intro = gap2.intro;
17899
17965
  const sentWhat = isPreflightEmail(gap2.email) ? "is a draft that" : `${timing}, but it`;
17900
- const introData = intro ? { contentIntro: { status: intro.status, reason: intro.reason, quote: intro.quote, fix: intro.fix } } : {};
17966
+ const introData = {
17967
+ ...intro ? { contentIntro: { status: intro.status, reason: intro.reason, quote: intro.quote, fix: intro.fix } } : {},
17968
+ ...gap2.trialLength ? { contentTrialLength: gap2.trialLength } : {}
17969
+ };
17970
+ const trialLine = gap2.trialLength ? `says ${gap2.trialLength.stated}, but ${gap2.trialLength.plan}: state the trial the subscriber really gets` : "";
17971
+ if (!gap2.missing.length && gap2.trialLength) {
17972
+ return makeResult(
17973
+ spec.checkId,
17974
+ "fail",
17975
+ `The ${spec.noun} (${emailLabel(gap2.email)}) ${sentWhat} ${trialLine}${intro ? `; it also ${intro.detail}` : ""}`,
17976
+ [...emailRefs(gap2.email), ...refs2],
17977
+ 0.8,
17978
+ { ...data, contentMissing: [], contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...introData }
17979
+ );
17980
+ }
17901
17981
  if (!gap2.missing.length && intro) {
17902
17982
  return makeResult(
17903
17983
  spec.checkId,
@@ -17913,12 +17993,26 @@ function contentFail(spec, required, gap2, timing, refs2, data) {
17913
17993
  "fail",
17914
17994
  `The ${spec.noun} (${emailLabel(gap2.email)}) ${isPreflightEmail(gap2.email) ? "is a draft that" : `${timing}, but it`} does not state ${describeMissing(gap2.missing)}${// Round 20 (pilot 19 item 16, change-guard: "contradicts the plan or is not an online way to cancel"): each wrong
17915
17995
  // element says its own reason.
17916
- gap2.wrong ? ` correctly (it says ${Object.entries(gap2.wrong).map(([k, v]) => `\u201C${v}\u201D for ${ELEMENT_LABELS[k]}, ${k === "cancellation" ? "which is not an online way to cancel" : "which contradicts the plan"}`).join("; ")})` : ""} \u2014 the notice must tell the subscriber ${describeMissing(required)}${intro ? `; it also ${intro.detail}` : ""}`,
17996
+ gap2.wrong ? ` correctly (it says ${Object.entries(gap2.wrong).map(([k, v]) => `\u201C${v}\u201D for ${ELEMENT_LABELS[k]}, ${k === "cancellation" ? "which is not an online way to cancel" : "which contradicts the plan"}`).join("; ")})` : ""} \u2014 the notice must tell the subscriber ${describeMissing(required)}${intro ? `; it also ${intro.detail}` : ""}${trialLine ? `; it also ${trialLine}` : ""}`,
17917
17997
  [...emailRefs(gap2.email), ...refs2],
17918
17998
  0.8,
17919
17999
  { ...data, contentMissing: gap2.missing, contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...gap2.wrong ? { contentWrong: gap2.wrong } : {}, ...introData }
17920
18000
  );
17921
18001
  }
18002
+ function reminderTrialLength(input) {
18003
+ const offers = contextOffers(input.ctx);
18004
+ return (a, e) => {
18005
+ if (isCheckboxSentEmail(e)) return void 0;
18006
+ const ref = typeof e.subscriptionRef === "string" ? e.subscriptionRef.trim() : "";
18007
+ const own = ref ? (input.ctx.events ?? []).filter((ev) => ev.subscriptionRef === ref).map((ev) => ev.trialDays ?? 0).find((d) => d > 0) : void 0;
18008
+ if (own) {
18009
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
18010
+ if (!stated.length || stated.some((t) => trialLengthFits(t.days, own))) return void 0;
18011
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: isPreflightEmail(e) ? `the plan gives a ${own}-day free trial` : `this subscriber's free trial is ${own} days` };
18012
+ }
18013
+ return statedTrialMismatch(a, emailPlan(e, input.ctx, offers), offers);
18014
+ };
18015
+ }
17922
18016
  async function runReminder(spec, input) {
17923
18017
  const id = spec.checkId;
17924
18018
  const config = input.ctx.config;
@@ -17969,7 +18063,8 @@ async function runReminder(spec, input) {
17969
18063
  const contentOpts = {
17970
18064
  productNames: productNames2,
17971
18065
  ...planOpts,
17972
- ...spec.kind === "trial_ending" ? { firstChargeAfterTrial: "always" } : spec.kind === "annual_renewal" ? { firstChargeAfterTrial: "when_trial_mentioned" } : {}
18066
+ ...spec.kind === "trial_ending" ? { firstChargeAfterTrial: "always" } : spec.kind === "annual_renewal" ? { firstChargeAfterTrial: "when_trial_mentioned" } : {},
18067
+ ...spec.kind === "trial_ending" || spec.kind === "annual_renewal" ? { trialLength: reminderTrialLength(input) } : {}
17973
18068
  };
17974
18069
  const runStep = spec.kind === "trial_ending" ? "trial_reminder" : spec.kind === "annual_renewal" ? "renewal" : null;
17975
18070
  const runStamped = runStep ? emails.filter((e) => testRunDaysBefore(e, runStep)) : [];
@@ -18113,7 +18208,7 @@ async function runReminder(spec, input) {
18113
18208
  return makeResult(
18114
18209
  id,
18115
18210
  "pass",
18116
- `${spec.noun[0].toUpperCase()}${spec.noun.slice(1)} captured (${emailLabel(inWindow[0].e)}), sent about ${inWindow[0].days} days before the date it announces (required: ${conflicting ? w : why(inWindow[0].w)})`,
18211
+ `${spec.noun[0].toUpperCase()}${spec.noun.slice(1)} captured (${emailLabel(inWindow[0].e)}), sent about ${inWindow[0].days} days before the date it announces${countsAsWholeDays(inWindow[0].days)} (required: ${conflicting ? w : why(inWindow[0].w)})`,
18117
18212
  [...emailRefs(inWindow[0].e), ...configRefs],
18118
18213
  0.7,
18119
18214
  data
@@ -18270,6 +18365,7 @@ var init_reminder = __esm({
18270
18365
  "../rules/src/checks/reminder.ts"() {
18271
18366
  "use strict";
18272
18367
  init_email_plan();
18368
+ init_offer();
18273
18369
  init_text();
18274
18370
  init_business_days();
18275
18371
  init_context();
@@ -18976,8 +19072,21 @@ function otherPlanCard(view, input) {
18976
19072
  const known = referencePlans(input.ctx).map((p) => p.amount / 10 ** currencyDigits(p.currency));
18977
19073
  return !amounts.some((n) => known.some((k) => Math.abs(k - n) < 5e-3));
18978
19074
  }
19075
+ function boughtPlanViews(views, input) {
19076
+ if (input.ctx.facts?.boughtPlanOnly !== true) return [...views];
19077
+ const known = referencePlans(input.ctx).map((p) => p.amount / 10 ** currencyDigits(p.currency));
19078
+ if (!known.length) return [...views];
19079
+ const amountsOf = (v) => findAmounts(v.nearCtaText || v.termsText || "").map((m) => m.amount).filter((n) => typeof n === "number" && n > 0);
19080
+ const other = (v) => {
19081
+ if (v.offer === void 0) return false;
19082
+ const amounts = amountsOf(v);
19083
+ return amounts.length > 0 && !amounts.some((n) => known.some((k) => Math.abs(k - n) < 5e-3));
19084
+ };
19085
+ const kept = views.filter((v) => !other(v));
19086
+ return kept.length ? kept : [...views];
19087
+ }
18979
19088
  async function overViews(checkId, input, headlines, evaluateView) {
18980
- const views = input.data.signupViews;
19089
+ const views = boughtPlanViews(input.data.signupViews, input);
18981
19090
  if (views.length === 0) return missingData(checkId, NO_CHECKOUT_CAPTURE_MESSAGE);
18982
19091
  const outcomes = [];
18983
19092
  for (const view of views) outcomes.push({ view, unit: await evaluateView(view) });
@@ -19283,7 +19392,7 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
19283
19392
  function setOffAdvice(sizePx, contrast, weight, ref) {
19284
19393
  const refWeight = ref.fontWeight ?? 400;
19285
19394
  const larger = Math.ceil(ref.fontSizePx * SET_OFF_MIN_SIZE_RATIO);
19286
- const ways = [`${larger}px or larger (the surrounding text is about ${ref.fontSizePx}px)`, ...refWeight < 600 ? ["semibold or bold (weight 600+; the surrounding text is weight " + refWeight + ")"] : [], "a contrasting colour", "a border or shaded box (confirm by eye: borders and symbols cannot be measured from a capture)"];
19395
+ const ways = [`${larger}px or larger (the surrounding text is about ${ref.fontSizePx}px)`, ...refWeight < 600 ? ["semibold or bold (weight 600+; the surrounding text is weight " + refWeight + ")"] : [], "a contrasting colour", "a border or a shaded box around them (Checkbox measures both; symbols and italics are not measured)"];
19287
19396
  return `the renewal terms are legible (${sizePx}px, weight ${weight}${contrast !== null ? `, ${contrast}:1 contrast` : ""}) but look like the surrounding text (about ${ref.fontSizePx}px, weight ${refWeight}${refWeight >= 600 ? ": already bold, so a bolder weight cannot set them apart" : ""}, similar colour) \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border. Any one of these would: ${ways.join("; ")}`;
19288
19397
  }
19289
19398
  function trialObservedOutsideCheckout(input) {
@@ -19421,7 +19530,7 @@ function boxNearCta(box, cta) {
19421
19530
  const gap2 = box.y >= cta.y + cta.height ? box.y - (cta.y + cta.height) : cta.y >= box.y + box.height ? cta.y - (box.y + box.height) : 0;
19422
19531
  return gap2 <= 200;
19423
19532
  }
19424
- var NO_CHECKOUT_CAPTURE_MESSAGE, NO_PURCHASE_BUTTON, OTHER_PLAN_CARD_NOTE, heuristicCache, PROCESSOR_LINE_NOTE, RENEWAL_CONTEXT_RE, autorenewLanguage, autorenewOverViews, FOOTNOTE_RE, NON_CHARGE_AMOUNT_RE, INTRO_PRICE_RE, RENEWAL_PRICE_RE, PRODUCT_TITLE_RE, CHARGED_NOW_RE, LATER_CHARGE_RE, price, frequency, CANCEL_LINK_RE, cancellationPolicy, TRIAL_DAYS_SLACK, trialTerms2, termLength, PLACEMENT_RANK, proximity, conspicuous, RENEWAL_CONSENT_RE, CONSENT_CHECKBOX_RE, TOS_RE, SUBSCRIPTION_CTA_RE, MARKETING_RE, RENEWAL_TERMS_RE, NOTIFICATION_OPT_IN_RE, AGREEMENT_RE, SEPARATE_CONSENT_JURISDICTIONS, EXPRESS_CONSENT_COUNSEL_QUESTION, CA_SEPARATE_CONSENT, consentAffirmative, consentOverViews, notPrechecked, SIGNUP_CHECKS;
19533
+ var NO_CHECKOUT_CAPTURE_MESSAGE, NO_PURCHASE_BUTTON, OTHER_PLAN_CARD_NOTE, heuristicCache, PROCESSOR_LINE_NOTE, RENEWAL_CONTEXT_RE, autorenewLanguage, autorenewOverViews, FOOTNOTE_RE, NON_CHARGE_AMOUNT_RE, INTRO_PRICE_RE, RENEWAL_PRICE_RE, PRODUCT_TITLE_RE, CHARGED_NOW_RE, LATER_CHARGE_RE, HOSTED_SET_OFF_NOTE, 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;
19425
19534
  var init_signup = __esm({
19426
19535
  "../rules/src/checks/signup.ts"() {
19427
19536
  "use strict";
@@ -19522,6 +19631,7 @@ var init_signup = __esm({
19522
19631
  PRODUCT_TITLE_RE = /^(?:[A-Z0-9][\w'&.+]*\s+){0,6}[A-Z0-9][\w'&.+]*\s*[-–—|:·]\s*[$€£]\s?\d[\d,]*(?:\.\d{1,2})?\s*$/;
19523
19632
  CHARGED_NOW_RE = /\b(?:starting|beginning|effective|from)\s+today\b|\b(?:charged|billed|charge|bill)\b(?:[^.\n]|\.(?=\d)){0,50}?\b(?:today|immediately|right\s+away)\b|\btoday\b(?:[^.\n]|\.(?=\d)){0,30}?\b(?:charged|billed)\b/i;
19524
19633
  LATER_CHARGE_RE = /\b(?:then|thereafter|afterwards?|after\s+(?:that|which|the|your|an?)|from\s+(?:the\s+)?(?:second|2nd|next)|renews?\s+(?:automatically\s+)?at)\b/i;
19634
+ HOSTED_SET_OFF_NOTE = "This was measured on the Stripe-hosted page, where Stripe sets the size and colour of the text and the merchant cannot restyle it";
19525
19635
  price = {
19526
19636
  id: "signup.disclosure.price",
19527
19637
  usesText: true,
@@ -20266,16 +20376,20 @@ ${view.pageText}`;
20266
20376
  if (setOff && !setOff.differentiators.length) {
20267
20377
  return unit(
20268
20378
  "needs_review",
20269
- setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : setOffAdvice(best.a.fontSizePx, best.a.contrastRatio, best.el.style?.fontWeight ?? 400, setOff.reference),
20379
+ setOff.referenceUnknown ? `the renewal terms are legible (${best.a.fontSizePx}px, ${best.a.contrastRatio}:1 contrast), but there is no surrounding text to show they are set apart from it \u2014 California requires larger type, contrasting type / font / color, or setting them off with symbols or a border` : `${setOffAdvice(best.a.fontSizePx, best.a.contrastRatio, best.el.style?.fontWeight ?? 400, setOff.reference)}${stripeHosted(view) ? `. ${HOSTED_SET_OFF_NOTE}` : ""}`,
20270
20380
  0.55,
20271
- { meta: { ...meta, ...setOffMeta }, quote: q2 }
20381
+ // Round 34 (pilot 33 item 26, nontech-founder): measured on a Stripe-hosted page, where the merchant cannot
20382
+ // restyle the text; the fix (setOffFix) then names what they can change: the product name / description.
20383
+ { meta: { ...meta, ...setOffMeta, ...stripeHosted(view) ? { hostedCheckout: "stripe" } : {} }, quote: q2 }
20272
20384
  );
20273
20385
  }
20386
+ const smallerNearButton = view.hosted ? elements.filter((o) => o !== best.el && o !== view.primaryCta && hasRenewalLanguage(analyzeTerms(o.text ?? ""))).filter((o) => typeof o.style?.fontSizePx === "number" && typeof best.a.fontSizePx === "number" && o.style.fontSizePx < best.a.fontSizePx).find((o) => !cta || nearButton(view, o)) : void 0;
20387
+ const hostedNote = smallerNearButton ? `; the renewal text next to the pay button (\u201C${quote(labelOf(smallerNearButton))}\u201D) is still smaller (${smallerNearButton.style.fontSizePx}px) \u2014 the checkout provider sets its style, so it is the ${best.a.fontSizePx}px line that stands out` : "";
20274
20388
  return unit(
20275
20389
  "pass",
20276
- `terms are ${best.a.fontSizePx}px with ${best.a.contrastRatio}:1 contrast and set off from the surrounding text (${setOff ? setOff.differentiators.join(", ").replace(/_/g, " ") : "on the purchase button"})${terms2.tier === 2 ? " (subscription terms block; no explicit renewal sentence found)" : ""}`,
20390
+ `terms are ${best.a.fontSizePx}px with ${best.a.contrastRatio}:1 contrast and set off from the surrounding text (${setOff ? setOff.differentiators.join(", ").replace(/_/g, " ") : "on the purchase button"})${terms2.tier === 2 ? " (subscription terms block; no explicit renewal sentence found)" : ""}${hostedNote}`,
20277
20391
  terms2.tier === 2 ? 0.65 : 0.85,
20278
- { meta: { ...meta, ...setOffMeta }, quote: q2 }
20392
+ { meta: { ...meta, ...setOffMeta, ...smallerNearButton ? { hostedSmallerNearButton: { fontSizePx: smallerNearButton.style.fontSizePx, quote: quote(labelOf(smallerNearButton)) } } : {} }, quote: q2 }
20279
20393
  );
20280
20394
  }
20281
20395
  if (best.a.status === "needs_review") return unit("needs_review", best.a.warnings.join("; "), 0.5, { meta, quote: q2 });
@@ -22272,6 +22386,7 @@ var init_brand_scope = __esm({
22272
22386
 
22273
22387
  // ../rules/src/set-off-fix.ts
22274
22388
  function setOffFix(text) {
22389
+ if (/measured on the Stripe-hosted page/.test(text)) return HOSTED_SET_OFF_FIX;
22275
22390
  const own = /legible \((\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
22276
22391
  const ref = /look like the surrounding text \(about (\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
22277
22392
  if (!ref) return 'Set the renewal line apart from the text around it in a way Checkbox can measure: larger type (about 15% larger), semibold (weight 600+) where the text around it is not bold, a contrasting colour, or a border (e.g. style="border:1px solid currentColor;padding:8px 12px"). Any one of these is enough.';
@@ -22282,9 +22397,11 @@ function setOffFix(text) {
22282
22397
  const ways = [`${larger}px or larger`, ...refWeight < 600 ? ["semibold (font-weight 600 or more)"] : [], "a contrasting colour (not grey)", 'a border around it (e.g. style="border:1px solid currentColor;padding:8px 12px")'];
22283
22398
  return `Set the renewal line apart in a way Checkbox can measure. ${measured} Any one of these is enough: ${ways.join("; ")}.`;
22284
22399
  }
22400
+ var HOSTED_SET_OFF_FIX;
22285
22401
  var init_set_off_fix = __esm({
22286
22402
  "../rules/src/set-off-fix.ts"() {
22287
22403
  "use strict";
22404
+ HOSTED_SET_OFF_FIX = 'This line is on the Stripe-hosted page, where Stripe sets the size and colour of your custom text: it cannot be made bigger or bolder there. What you can change: put the renewal terms in the product name or description in Stripe (Product catalog \u2192 your product \u2192 Edit), for example "Monthly box: renews monthly at $29 until you cancel". Stripe shows the product name and description in larger type in the order summary. Then check again. Also show the same line, set off, on your own page before the redirect.';
22288
22405
  }
22289
22406
  });
22290
22407
 
@@ -22471,6 +22588,9 @@ function classifyRaw(input) {
22471
22588
  if (input.origin !== "preflight" && input.data?.verifiedFrom === "checkbox_hosted") {
22472
22589
  return finish({ status: "compliant", note: VERIFIED_FROM_HOSTED_NOTE }, "verified_from_checkbox_settings", checkId);
22473
22590
  }
22591
+ if (checkId === "signup.disclosure.conspicuous" && Array.isArray(input.data?.units) && input.data.units.some((u) => u && u.status === "pass" && !!u.hostedSmallerNearButton)) {
22592
+ return finish({ status: "compliant", note: HOSTED_SMALLER_NEAR_BUTTON_NOTE }, "hosted_checkout_styling", checkId);
22593
+ }
22474
22594
  if (input.origin !== "preflight" && LIVE_CHECKS.has(checkId)) {
22475
22595
  if (/on-screen only/.test(text)) return finish({ status: "pending_live_check", pending: pendingFor(checkId, "stripe was not checked", input.data), note: "Confirmed on screen only: run a proof to confirm the cancellation reaches Stripe." }, "pass_on_screen_only", checkId);
22476
22596
  return finish({ status: "verified_live" }, "verdict_pass_live", checkId);
@@ -22626,7 +22746,7 @@ function classifyFindings(findings) {
22626
22746
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22627
22747
  }));
22628
22748
  }
22629
- var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, RENEWAL_LINE_EXAMPLE, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22749
+ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_SMALLER_NEAR_BUTTON_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, CANCEL_STEPS_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, RENEWAL_LINE_EXAMPLE, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22630
22750
  var init_status = __esm({
22631
22751
  "../rules/src/status.ts"() {
22632
22752
  "use strict";
@@ -22692,7 +22812,8 @@ var init_status = __esm({
22692
22812
  has = (re) => (c) => re.test(c.text);
22693
22813
  isStripeInput = (c) => (c.kind ?? "").startsWith("stripe_") || onlyStripeHostedOpen(c.data) || /checkout\.stripe\.com|buy\.stripe\.com|stripe|custom_text/.test(c.text);
22694
22814
  HOSTED_BEFORE_LINE_NOTE = 'Checked once the renewal line exists (see "renewal language"). Stripe shows custom text small and grey below the button, and you cannot change that on its page.';
22695
- HOSTED_CHECKOUT_NOTE = "Stripe shows custom text small and grey below the button, and you cannot change that. Also show the same line, set off, on your own page before the redirect.";
22815
+ HOSTED_SMALLER_NEAR_BUTTON_NOTE = "This passes on the larger line that states the renewal terms (for example the product name). The renewal text next to the pay button is still smaller, and the checkout provider sets its style. Also show the same line, set off, on your own page before the redirect.";
22816
+ HOSTED_CHECKOUT_NOTE = "Stripe shows custom text small and grey below the button, and you cannot change that. What you can change: put the renewal terms in the product name or description in Stripe (shown larger in the order summary), and show the same line, set off, on your own page before the redirect.";
22696
22817
  family = (c) => String(c.checkId).split(".")[0];
22697
22818
  isEmailCheck = (id) => id.startsWith("confirmation.") || id.startsWith("reminder.");
22698
22819
  ask = (text, recommended, why) => ({ text, recommended, why });
@@ -22703,6 +22824,7 @@ var init_status = __esm({
22703
22824
  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.";
22704
22825
  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.`;
22705
22826
  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.";
22827
+ CANCEL_STEPS_FIX = `Shorten the cancellation to 4 steps or fewer from the account page. With Stripe, make your "Cancel subscription" button create a customer portal session with flow_data: { type: "subscription_cancel", subscription_cancel: { subscription: "<the subscriber's subscription id>" } } and redirect to its url: the subscriber lands straight on Stripe's cancel confirmation. In your own flow, drop extra pages (one confirmation and at most one save offer). Then run a test run again.`;
22706
22828
  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/;
22707
22829
  RULES = [
22708
22830
  // Round 23 (pilot 22, ai-builder "soft false pass"): a test cancellation worked in the Stripe customer portal, but no
@@ -22713,6 +22835,16 @@ var init_status = __esm({
22713
22835
  when: (c) => String(c.checkId).startsWith("cancel.") && c.data?.portalUnlinked === true,
22714
22836
  build: (c) => ({ status: "needs_fix", fix: portalUnlinkedFix(typeof c.data?.portalUrl === "string" ? c.data.portalUrl : void 0) })
22715
22837
  },
22838
+ // Round 34 (pilot 33 item 16, core-discovery: a test run said "nothing to change" for a 5-step cancellation, then the
22839
+ // plan asked the owner "Is this requirement met?" with an unrelated fix): a cancellation that took more steps than
22840
+ // Checkbox's limit is a change the agent can make first (a deep link straight to the cancel confirmation), not a
22841
+ // question for the owner.
22842
+ {
22843
+ code: "cancel_too_many_steps",
22844
+ category: "content",
22845
+ when: (c) => c.checkId === "cancel.flow_friction" && c.status === "needs_review" && /cancelling took \d+ steps \(more than \d+\)/.test(c.text),
22846
+ build: () => ({ status: "needs_fix", fix: CANCEL_STEPS_FIX })
22847
+ },
22716
22848
  // Round 20 (pilot 19 item 1): an acknowledgment that does not mention a trial, on a site where only some plans have
22717
22849
  // one, and Checkbox cannot tell which plan it goes to. Not a fix and not a pass: the plan of the email is unknown.
22718
22850
  {
@@ -24498,6 +24630,9 @@ var init_model = __esm({
24498
24630
  });
24499
24631
 
24500
24632
  // ../rules/src/preflight/disclosure.ts
24633
+ function stripeConsentText(kind) {
24634
+ return kind === "payment_link" ? 'The purchase is completed on a Stripe Payment Link, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Payment Link: check it with checkStripePaymentLink (pass the retrieved link, plink_\u2026, as params). It needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text.' : "The purchase is completed on Stripe Checkout, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Checkout Session: check the params your code sends with checkStripeCheckoutSession.";
24635
+ }
24501
24636
  function resolvePlan(input, prefix = "") {
24502
24637
  const currency = parseCurrency(input.currency, `${prefix}currency`);
24503
24638
  const amount = minorAmount({ price: input.price, amount: input.amount, currency }, `${prefix}price`);
@@ -24516,6 +24651,20 @@ function resolvePlan(input, prefix = "") {
24516
24651
  throw new PreflightInputError(`${prefix}intro_percent_off must be a number above 0 and below 100 (20 for 20% off)`, `${prefix}intro_percent_off`);
24517
24652
  }
24518
24653
  const off = input.intro_amount_off;
24654
+ const majorPrice = input.amount === void 0 && input.price !== void 0 && input.price !== null ? Number(String(input.price).replace(/[^0-9.]/g, "")) : NaN;
24655
+ const looksMajor = (v) => typeof v === "number" && Number.isFinite(majorPrice) && majorPrice > 0 && v > 0 && v < 100 && v <= majorPrice;
24656
+ if (looksMajor(off)) {
24657
+ throw new PreflightInputError(
24658
+ `${prefix}intro_amount_off is in minor units (cents), like ${prefix}amount: ${off} means ${(off / 100).toFixed(2)} off, next to a ${prefix}price of ${majorPrice.toFixed(2)} in major units. For ${off.toFixed(2)} off send ${prefix}intro_amount_off: ${Math.round(off * 100)}, or ${prefix}intro_price: ${Math.max(0, majorPrice - off).toFixed(2)} (the first-period price in major units, like ${prefix}price)`,
24659
+ `${prefix}intro_amount_off`
24660
+ );
24661
+ }
24662
+ if (input.intro_price === void 0 && looksMajor(input.intro_amount)) {
24663
+ throw new PreflightInputError(
24664
+ `${prefix}intro_amount is in minor units (cents), like ${prefix}amount: ${input.intro_amount} means ${(input.intro_amount / 100).toFixed(2)}, next to a ${prefix}price of ${majorPrice.toFixed(2)} in major units. Send ${prefix}intro_price: ${input.intro_amount} (major units, like ${prefix}price) or ${prefix}intro_amount: ${Math.round(input.intro_amount * 100)}`,
24665
+ `${prefix}intro_amount`
24666
+ );
24667
+ }
24519
24668
  if (off !== void 0 && off !== null && !(typeof off === "number" && Number.isInteger(off) && off > 0 && off < amount)) {
24520
24669
  throw new PreflightInputError(`${prefix}intro_amount_off must be a whole number of minor units above 0 and below the recurring price (200 for $2.00 off)`, `${prefix}intro_amount_off`);
24521
24670
  }
@@ -24734,15 +24883,20 @@ function modelDisclosureCheckout(input) {
24734
24883
  return { captures, viewport: VIEWPORT };
24735
24884
  }
24736
24885
  function nextActionFor(checks, replacement, input) {
24737
- const failing = checks.filter((c) => c.status === "fail");
24738
- const review2 = checks.filter((c) => c.status === "needs_review");
24886
+ const own = input.onStripe ? checks.filter((c) => c.check_id !== "signup.consent.affirmative") : checks;
24887
+ const failing = own.filter((c) => c.status === "fail");
24888
+ const review2 = own.filter((c) => c.status === "needs_review");
24739
24889
  const fill2 = replacement.placeholders.length ? ` Replace ${replacement.placeholders.join(", ")} with your real cancellation URL (or pass cancel_url).` : "";
24890
+ const stripeNote = input.onStripe ? ` ${stripeConsentText(input.onStripe)}` : "";
24740
24891
  if (failing.length) {
24741
24892
  return {
24742
24893
  type: "replace_text",
24743
- message: `Failing: ${openChecks(checks)}. Replace the disclosure with ${replacement.short_disclosure_text ? "replacement.short_disclosure_text (one line; replacement.disclosure_text is the fuller version)" : "replacement.disclosure_text"}, show it under the price, before the card and email fields and the purchase button (semibold or boxed, at least your body text size), add an unchecked required checkbox labelled replacement.consent_label (or paste replacement.html), then call this check again.${fill2}`
24894
+ message: input.onStripe ? `Failing: ${openChecks(own)}. Replace the text with ${replacement.short_disclosure_text ? "replacement.short_disclosure_text (one line; replacement.disclosure_text is the fuller version)" : "replacement.disclosure_text"} (or paste replacement.html, the terms only), show it directly above your purchase link (semibold or boxed, at least your body text size), then call this check again.${stripeNote}${fill2}` : `Failing: ${openChecks(checks)}. Replace the disclosure with ${replacement.short_disclosure_text ? "replacement.short_disclosure_text (one line; replacement.disclosure_text is the fuller version)" : "replacement.disclosure_text"}, show it under the price, before the card and email fields and the purchase button (semibold or boxed, at least your body text size), add an unchecked required checkbox labelled replacement.consent_label (or paste replacement.html), then call this check again.${fill2}`
24744
24895
  };
24745
24896
  }
24897
+ if (input.onStripe && !review2.length) {
24898
+ return { type: "fix_and_recheck", message: `The text passes every check that can be judged here. Keep it directly above your purchase link.${stripeNote}${fill2}` };
24899
+ }
24746
24900
  if (review2.length && review2.every((c) => c.check_id === "signup.disclosure.conspicuous")) {
24747
24901
  const same = norm(input.text) === norm(replacement.disclosure_text);
24748
24902
  if (!input.bold && !input.boxed) {
@@ -24791,20 +24945,24 @@ async function checkDisclosure(input, opts = {}) {
24791
24945
  label: str2(c.label, 1e3, "consent.label") ?? DEFAULT_CONSENT_LABEL,
24792
24946
  required: bool2(c.required, true, "consent.required")
24793
24947
  } : void 0;
24948
+ const checkoutType = input.checkout_type;
24949
+ if (checkoutType !== void 0 && checkoutType !== "own_page" && checkoutType !== "stripe_checkout" && checkoutType !== "payment_link") throw new PreflightInputError('checkout_type must be "own_page", "stripe_checkout" or "payment_link"', "checkout_type");
24950
+ const onStripe = checkoutType === "stripe_checkout" || checkoutType === "payment_link" ? checkoutType : void 0;
24951
+ const facts = { ...planFacts(plan2), ...checkoutType ? { checkoutType } : {} };
24794
24952
  const warnings = [];
24795
- if (!c) warnings.push({ code: "consent_unspecified", message: "consent was not given; the check assumes there is no consent checkbox.", param: "consent" });
24953
+ if (!c && !onStripe) warnings.push({ code: "consent_unspecified", message: "consent was not given; the check assumes there is no consent checkbox.", param: "consent" });
24796
24954
  if (placement.contrast_ratio !== void 0 && placement.contrast_ratio < CONSPICUOUS_MIN_CONTRAST) {
24797
24955
  warnings.push({ code: "low_contrast", message: `contrast_ratio ${placement.contrast_ratio} is below ${CONSPICUOUS_MIN_CONTRAST}:1.`, param: "placement.contrast_ratio" });
24798
24956
  }
24799
24957
  const buttonText = str2(input.button_text, 200, "button_text") ?? (plan2.trialDays ? "Start free trial" : "Subscribe");
24800
24958
  const orderSummary = str2(input.order_summary, 1e3, "order_summary");
24801
24959
  const { captures } = modelDisclosureCheckout({ text, buttonText, ...orderSummary ? { orderSummary } : {}, placement, ...consent ? { consent } : {}, asOf });
24802
- const ctx = modelContext(jurisdictions, asOf, planFacts(plan2), { captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24960
+ const ctx = modelContext(jurisdictions, asOf, facts, { captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24803
24961
  const run2 = await runChecks(ctx, SIGNUP_FIX_CHECKS, now);
24804
24962
  let checks = shapeChecks(run2.findings);
24805
24963
  if (!placement.same_page_as_payment) {
24806
24964
  const onPage = modelDisclosureCheckout({ text, buttonText, ...orderSummary ? { orderSummary } : {}, placement: { ...placement, same_page_as_payment: true }, ...consent ? { consent } : {}, asOf });
24807
- const textCtx = modelContext(jurisdictions, asOf, planFacts(plan2), { captures: onPage.captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24965
+ const textCtx = modelContext(jurisdictions, asOf, facts, { captures: onPage.captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
24808
24966
  const byText = new Map(shapeChecks((await runChecks(textCtx, SIGNUP_FIX_CHECKS, now)).findings).map((v) => [v.check_id, v]));
24809
24967
  checks = checks.map((v) => {
24810
24968
  const t = byText.get(v.check_id);
@@ -24820,13 +24978,17 @@ async function checkDisclosure(input, opts = {}) {
24820
24978
  };
24821
24979
  });
24822
24980
  }
24823
- const replacement = await buildReplacement(plan2, jurisdictions, asOf, now);
24981
+ if (onStripe) {
24982
+ const said = stripeConsentText(onStripe);
24983
+ checks = checks.map((v) => v.check_id === "signup.consent.affirmative" && v.status !== "pass" && v.status !== "not_applicable" ? { ...v, status: "needs_review", explanation: said, fix: said } : v);
24984
+ }
24985
+ const replacement = await buildReplacement(plan2, jurisdictions, asOf, now, onStripe ? { onStripe } : {});
24824
24986
  const ownButton = str2(input.button_text, 200, "button_text");
24825
- if (ownButton && buttonFitsPlan(ownButton, plan2)) {
24987
+ if (ownButton && buttonFitsPlan(ownButton, plan2) && !onStripe) {
24826
24988
  replacement.button_text = ownButton;
24827
- replacement.html = htmlDisclosureComponent({ disclosure: replacement.disclosure_text, consentLabel: replacement.consent_label, cta: ownButton }, replacement.sha256);
24989
+ replacement.html = htmlDisclosureComponent({ disclosure: replacement.disclosure_text, consentLabel: replacement.consent_label, cta: ownButton }, replacement.sha256, /data-checkbox-terms="[^"]*" style="[^"]*border:1px solid currentColor/.test(replacement.html) ? { boxed: true } : {});
24828
24990
  }
24829
- return { ...baseReport("disclosure", checks, jurisdictions, asOf, warnings, nextActionFor(checks, replacement, { text, bold: placement.bold, boxed: placement.boxed }), `${input.text}
24991
+ return { ...baseReport("disclosure", checks, jurisdictions, asOf, warnings, nextActionFor(checks, replacement, { text, bold: placement.bold, boxed: placement.boxed, ...onStripe ? { onStripe } : {} }), `${input.text}
24830
24992
  ${input.consent?.label ?? ""}`, plan2), kind: "disclosure", replacement };
24831
24993
  }
24832
24994
  var MAX_DISCLOSURE_CHARS, PREFLIGHT_PLAN_ID, CANCEL_URL_PLACEHOLDER, SAMPLE_CANCEL_URL, DEFAULT_CONSENT_LABEL, norm, TEXT_CONTENT_CHECKS;
@@ -25304,7 +25466,7 @@ function planAsMerchant(shape, merchant) {
25304
25466
  }
25305
25467
  function correct(params, shape, checks, forLink, style2 = "full") {
25306
25468
  const byId = new Map(checks.map((c) => [c.check_id, c]));
25307
- const needText = TEXT_CHECKS.some((id) => open(byId.get(id)));
25469
+ const needText = TEXT_CHECKS.some((id) => open(byId.get(id)) && !isHostedStylingNote(byId.get(id), checks, "stripe_checkout_session"));
25308
25470
  const needConsent = CONSENT_CHECKS2.some((id) => open(byId.get(id))) || !shape.consentRequired && needText;
25309
25471
  const terms2 = merchantTerms(shape.plan, false);
25310
25472
  const full = lockedTexts(terms2);
@@ -25425,6 +25587,11 @@ function withResolvedPrices(input, knownPrices, amountSource = "merchant.price")
25425
25587
  const m = input.merchant ?? {};
25426
25588
  const stated = typeof m.amount === "number" ? m.amount : typeof m.price === "number" ? Math.round(m.price * 10 ** currencyDigitsOf(r.resolved.currency)) : void 0;
25427
25589
  const warnings = [];
25590
+ const sd = isObj(input.params.subscription_data) ? input.params.subscription_data : {};
25591
+ const paramsTrial = typeof sd.trial_period_days === "number" ? sd.trial_period_days : void 0;
25592
+ const priceTrial = r.resolved.trialDays && r.resolved.trialDays > 0 ? r.resolved.trialDays : void 0;
25593
+ const trialNote = priceTrial ? paramsTrial !== void 0 ? paramsTrial === priceTrial ? `, with a ${priceTrial}-day trial on the Price (the same as subscription_data.trial_period_days)` : `, with a ${priceTrial}-day trial on the Price; these params set subscription_data.trial_period_days to ${paramsTrial}, which is the trial this Checkout gives` : `, with a ${priceTrial}-day trial on the Price (trial_period_days); these params set no trial of their own` : "";
25594
+ warnings.push({ code: "price_read", message: `Stripe Price ${r.resolved.id} was read from your connected Stripe account: ${formatMoney(r.resolved.amount, r.resolved.currency)} per ${intervalPhrase(r.resolved.interval, r.resolved.intervalCount)}${trialNote}.`, param: "params.line_items" });
25428
25595
  if (stated !== void 0 && stated !== r.resolved.amount) {
25429
25596
  warnings.push({ code: "merchant_price_contradicts_price", message: `merchant.price says ${formatMoney(stated, r.resolved.currency)}, but ${r.resolved.id} charges ${formatMoney(r.resolved.amount, r.resolved.currency)} per ${intervalPhrase(r.resolved.interval, r.resolved.intervalCount)} in your connected Stripe account; the Price wins and the corrected custom_text states it.`, param: "merchant.price" });
25430
25597
  }
@@ -26145,9 +26312,6 @@ function stripeButtonsKind(capture) {
26145
26312
  const [only] = [...kinds];
26146
26313
  return kinds.size === 1 && only ? only : void 0;
26147
26314
  }
26148
- function stripeConsentText(kind) {
26149
- return kind === "payment_link" ? 'The purchase is completed on a Stripe Payment Link, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Payment Link: check it with checkStripePaymentLink (pass the retrieved link, plink_\u2026, as params). It needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text.' : "The purchase is completed on Stripe Checkout, which collects consent (consent_collection.terms_of_service), so no checkbox is needed on this page. This page check cannot read the Checkout Session: check the params your code sends with checkStripeCheckoutSession.";
26150
- }
26151
26315
  function hasPrice(t) {
26152
26316
  return !!t && (t.price !== void 0 && t.price !== null && t.price !== "" || t.amount !== void 0 && t.amount !== null);
26153
26317
  }
@@ -27746,13 +27910,19 @@ function detectProject(root, processEnv = {}) {
27746
27910
  const payButtons = [];
27747
27911
  const checkoutSessions = [];
27748
27912
  const policyPaths = {};
27913
+ const linkedPolicies = {};
27749
27914
  walk2(root, (abs, rel2) => {
27750
27915
  const route = routeOf(rel2);
27916
+ const served = route && /\.html?$/i.test(rel2) && !/(?:^|\/)index\.html?$/i.test(rel2) ? `/${route}${/\.htm$/i.test(rel2) ? ".htm" : ".html"}` : `/${route}`;
27751
27917
  if (route) {
27752
- for (const p of POLICY_ROUTES) if (!policyPaths[p.kind] && p.re.test(route)) policyPaths[p.kind] = `/${route}`;
27918
+ for (const p of POLICY_ROUTES) if (!policyPaths[p.kind] && p.re.test(route)) policyPaths[p.kind] = served;
27753
27919
  }
27754
27920
  const text = readText(abs);
27755
27921
  if (!text) return;
27922
+ for (const m of text.matchAll(/\bhref=\{?\s*["'`](\/[A-Za-z0-9._~\/-]*)["'`]/g)) {
27923
+ const path = m[1].slice(1).replace(/\/$/, "").replace(/\.html?$/i, "").toLowerCase();
27924
+ for (const p of POLICY_ROUTES) if (!linkedPolicies[p.kind] && p.re.test(path)) linkedPolicies[p.kind] = m[1].replace(/(.)\/$/, "$1");
27925
+ }
27756
27926
  const lines = text.split("\n");
27757
27927
  lines.forEach((line2, i) => {
27758
27928
  for (const p of CHECKOUT_PATTERNS) {
@@ -27772,6 +27942,7 @@ function detectProject(root, processEnv = {}) {
27772
27942
  }
27773
27943
  });
27774
27944
  if (billingPlatform === "custom" && checkoutCode.some((c) => c.kind.startsWith("stripe_"))) billingPlatform = "stripe";
27945
+ Object.assign(policyPaths, linkedPolicies);
27775
27946
  const site = detectSiteUrl(root, env2, pkg);
27776
27947
  const stripeKey = findStripeKey(env2, processEnv, envFile);
27777
27948
  const brand = detectBrand(root, layoutFile, typeof pkg?.name === "string" ? pkg.name : void 0);
@@ -28051,6 +28222,10 @@ function pairCall(root, s2, plans, priceIds, calls) {
28051
28222
  }
28052
28223
  return { ref: literalIds[0] ?? exprs[0] };
28053
28224
  }
28225
+ function supportAddressIn(text) {
28226
+ const all = [...text.matchAll(EMAIL_ADDRESS)].map((m) => m[1]).filter((a) => !/^(?:no-?reply|do-?not-?reply|mailer-daemon|bounce)/i.test(a) && !/@(?:example\.(?:com|org|net)|email\.com|domain\.com)$/i.test(a));
28227
+ return all[0];
28228
+ }
28054
28229
  function scaffoldConfig(root, env2 = {}, facts = {}) {
28055
28230
  const det = detectProject(root, env2);
28056
28231
  const todos = [];
@@ -28187,6 +28362,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28187
28362
  } else stripe.push(entry);
28188
28363
  }
28189
28364
  const emails = [];
28365
+ const supportSeen = /* @__PURE__ */ new Map();
28190
28366
  const pages = [];
28191
28367
  const disclosures = [];
28192
28368
  walk3(root, (rel2) => {
@@ -28203,6 +28379,8 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28203
28379
  const hint = intervalHintOf(basename(rel2).replace(/\.[^.]+$/, ""));
28204
28380
  const byName = hint && plans.length > 1 ? plans.filter((p) => p.interval === hint && (Number(p.interval_count ?? 1) || 1) === 1) : [];
28205
28381
  const plan2 = plans.length > 1 && planTagged ? { plan: "all" } : byName.length === 1 ? { plan: String(byName[0].name) } : planFor();
28382
+ const contact2 = supportAddressIn(text);
28383
+ if (contact2 && !supportSeen.has(contact2)) supportSeen.set(contact2, rel2);
28206
28384
  emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...plan2, ...Object.keys(tags).length ? { merge_tags: tags } : {} });
28207
28385
  found.push(`${rel2}: ${kind} email${Object.keys(tags).length ? ` (its merge tags ${Object.keys(tags).map((t) => `{{${t}}}`).join(", ")} are filled from checkbox.json: see "merge_tags")` : ""}`);
28208
28386
  return;
@@ -28249,6 +28427,11 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28249
28427
  if (!disclosures.length && det.payButtons.length && !det.payButtons.some((b) => inPages(b.file))) {
28250
28428
  notes2.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
28251
28429
  }
28430
+ const [contact] = [...supportSeen.entries()];
28431
+ if (!merchant.support_contact && contact) {
28432
+ merchant.support_contact = contact[0];
28433
+ found.push(`support contact ${contact[0]} (from ${contact[1]}; check it is the address subscribers should write to)`);
28434
+ }
28252
28435
  if (planTodo) todos.push('plan: pick the plan of each disclosure, page and email entry marked TODO ("all" for a page that shows every plan)');
28253
28436
  const checks = {};
28254
28437
  if (stripe.length) checks.stripe_checkout = stripe;
@@ -28267,7 +28450,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
28267
28450
  todoValues: countTodoValues({ merchant, plans, checks })
28268
28451
  };
28269
28452
  }
28270
- var PAGE_ROUTE, IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE;
28453
+ var PAGE_ROUTE, IGNORE, MAX_FILES2, EMAIL_NAMES, IN_EMAIL_DIR, PAGE_FILE, DISCLOSURE_TEXT, unique, PLAN_FILE, EMAIL_ADDRESS;
28271
28454
  var init_scaffold = __esm({
28272
28455
  "src/ci/scaffold.ts"() {
28273
28456
  "use strict";
@@ -28296,6 +28479,7 @@ var init_scaffold = __esm({
28296
28479
  return n;
28297
28480
  };
28298
28481
  PLAN_FILE = /(?:^|\/)(?:plans?|pricing|prices|tiers|subscriptions?|billing|products?)(?:\.config)?\.(?:[cm]?[jt]sx?|json)$/i;
28482
+ EMAIL_ADDRESS = /(?:mailto:)?([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})/g;
28299
28483
  }
28300
28484
  });
28301
28485
 
@@ -28762,7 +28946,8 @@ function pageJob(loaded, e, o, root) {
28762
28946
  const { merchant_name: _m, cancel_url: _c, ...own } = planTerms(config, pickPlan(config, n));
28763
28947
  return own;
28764
28948
  });
28765
- const common3 = { ...e.kind ? { page_kind: e.kind } : {}, ...Object.keys(terms2).length ? { terms: terms2 } : {}, ...every ? { plans: every } : {}, ...jurisdictionsOf(config) };
28949
+ const viaCheckout = e.kind !== "checkout" && (config.checks.stripe_checkout?.length ?? 0) > 0;
28950
+ const common3 = { ...e.kind ? { page_kind: e.kind } : {}, ...viaCheckout ? { checkout_type: "stripe_checkout" } : {}, ...Object.keys(terms2).length ? { terms: terms2 } : {}, ...every ? { plans: every } : {}, ...jurisdictionsOf(config) };
28766
28951
  if (e.source) {
28767
28952
  job.target = { file: rel(loaded, root, e.source), line: 1 };
28768
28953
  job.inputs.push(e.source);
@@ -29078,6 +29263,7 @@ var init_src3 = __esm({
29078
29263
  init_checkout_plans();
29079
29264
  init_notices_on();
29080
29265
  init_stripe_account();
29266
+ init_intro_terms();
29081
29267
  CATALOG2 = [...CATALOG];
29082
29268
  }
29083
29269
  });
@@ -29491,6 +29677,17 @@ function operationFor2(kind) {
29491
29677
  function onlineEngine(client) {
29492
29678
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
29493
29679
  }
29680
+ function withStripeNotChecked(run2, strict) {
29681
+ const strictFailed = strict && run2.exit_code === EXIT.ok;
29682
+ const exit = strictFailed ? EXIT.needs_review : run2.exit_code;
29683
+ return {
29684
+ ...run2,
29685
+ offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE,
29686
+ stripe_not_checked: { reason: STRIPE_OBJECTS_OFFLINE_NOTICE, secret: "CHECKBOX_SECRET_KEY", workflow_diff: STRIPE_ONLINE_WORKFLOW_DIFF, strict_failed: strictFailed },
29687
+ exit_code: exit,
29688
+ result: strictFailed ? `failed: Stripe was NOT checked (an offline run on a trial, price or interval change) and --strict is on (exit ${exit})` : resultOf(run2.status, exit, strict)
29689
+ };
29690
+ }
29494
29691
  function resultOf(status, exit, strict) {
29495
29692
  if (exit === 0) return status === "needs_review" ? "passed with items to review (they do not fail this run)" : "passed";
29496
29693
  if (status === "needs_review") return `failed: items need review and ${strict ? "--strict is on" : 'a "strict" entry or a blocking check makes them fail the run'} (exit ${exit})`;
@@ -29717,8 +29914,30 @@ function unverifiedOffer(r) {
29717
29914
  if (coupon && open2.length) return coupon.message;
29718
29915
  return void 0;
29719
29916
  }
29917
+ function consentOnStripeCovered(report) {
29918
+ const c = (report.checks ?? []).find((x) => x.check_id === "signup.consent.affirmative" && x.status === "needs_review" && !(x.compliance_status && OK_STATUSES.has(x.compliance_status)));
29919
+ if (!c) return report;
29920
+ const was = c.compliance_status;
29921
+ const checks = (report.checks ?? []).map((x) => x === c ? { ...x, status: "not_applicable", compliance_status: "not_applicable", explanation: CONSENT_COVERED, fix: CONSENT_COVERED, compliance: { ...x.compliance ?? {}, fix: null, question: null } } : x);
29922
+ const compliance = report.compliance ?? {};
29923
+ const counts = { ...compliance.counts ?? {} };
29924
+ if (was && typeof counts[was] === "number" && counts[was] > 0) counts[was] = counts[was] - 1;
29925
+ const open2 = OPEN_COUNTS.some((k) => (counts[k] ?? 0) > 0) || checks.some((x) => x.status === "fail" || x.status === "needs_review" && !(x.compliance_status && OK_STATUSES.has(x.compliance_status)));
29926
+ if (!open2 && report.verdict !== "fail") {
29927
+ const pending = counts.pending_live_check ?? 0;
29928
+ return { ...report, checks, verdict: "pass", compliance_status: pending ? "pending_live_check" : "compliant", compliance: { ...compliance, counts, status: pending ? "pending_live_check" : "compliant", label: pending ? `Checks pass \xB7 ${pending} live check${pending === 1 ? "" : "s"} pending` : "Checks pass" } };
29929
+ }
29930
+ const words = {
29931
+ not_checked: [/(\d+) not checked yet/, (n) => `${n} not checked yet`],
29932
+ your_call: [/(\d+) decisions? for you/, (n) => `${n} decision${n === 1 ? "" : "s"} for you`]
29933
+ };
29934
+ let label = compliance.label ?? "";
29935
+ const w = was ? words[was] : void 0;
29936
+ if (w) label = label.replace(w[0], (_m, n) => Number(n) > 1 ? w[1](Number(n) - 1) : "").replace(/^ · | · (?= · )| · $/g, "").replace(/ · · /g, " \xB7 ");
29937
+ return { ...report, checks, compliance: { ...compliance, counts, label } };
29938
+ }
29720
29939
  function sitePlansDifferInCi(message) {
29721
- const m = /\(you sent (.+?); (site_\w+) has (.+?)\), then check again/.exec(message);
29940
+ const m = /\(you sent (.+?); (site_\w+) has (.+?)\)(?:, then check again|\. This result is judged)/.exec(message);
29722
29941
  if (!m) return message;
29723
29942
  return `The site's plans in Checkbox (${m[3]}) are older than checkbox.json (${m[1]}). This run checks against checkbox.json, what this code sells. Once this change is deployed, update the site's plans (updateSetupSession plans[], or \`checkbox init --plan \u2026\`) so the site's own checks and its compliance plan use them; until then the plan's tasks may quote the old terms.`;
29724
29943
  }
@@ -29742,6 +29961,10 @@ async function runJob(job, engine, config, opts = {}) {
29742
29961
  }
29743
29962
  const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
29744
29963
  for (const w of report.warnings ?? []) {
29964
+ if (w.code === "price_read") {
29965
+ Object.assign(base, { stripe: [...base.stripe ?? [], w.message] });
29966
+ continue;
29967
+ }
29745
29968
  const text = w.code === "site_plans_differ" ? sitePlansDifferInCi(w.message) : ciVocabulary(w.message);
29746
29969
  base.warnings.push(text);
29747
29970
  if (ALERT_CODES.has(w.code)) alerts.push(text);
@@ -29752,6 +29975,7 @@ async function runJob(job, engine, config, opts = {}) {
29752
29975
  const pending = Number(report.compliance?.counts?.pending_live_check) || 0;
29753
29976
  report = { ...report, verdict: "pass", compliance_status: pending ? "pending_live_check" : "compliant", compliance: { ...report.compliance ?? {}, label: pending ? `Checks pass \xB7 ${pending} live check${pending === 1 ? "" : "s"} pending` : "Checks pass" } };
29754
29977
  }
29978
+ if (job.type === "page" && job.body?.checkout_type === "stripe_checkout") report = consentOnStripeCovered(report);
29755
29979
  const status = ciStatusOf(report);
29756
29980
  const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
29757
29981
  let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
@@ -29892,7 +30116,7 @@ async function runCi(jobs, engine, opts) {
29892
30116
  result: resultOf(status, exit, opts.strict)
29893
30117
  };
29894
30118
  }
29895
- var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, CI_DISCLAIMER, RANK2, OK_STATUSES, BOXED_HTML, BOXED_LINE, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
30119
+ var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, STRIPE_ONLINE_WORKFLOW_DIFF, CI_DISCLAIMER, RANK2, OK_STATUSES, BOXED_HTML, BOXED_LINE, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, CONSENT_COVERED, OPEN_COUNTS, ALERT_CODES;
29896
30120
  var init_runner = __esm({
29897
30121
  "src/ci/runner.ts"() {
29898
30122
  "use strict";
@@ -29910,7 +30134,19 @@ var init_runner = __esm({
29910
30134
  email: "email",
29911
30135
  notice_schedule: "notice_schedule"
29912
30136
  };
29913
- STRIPE_OBJECTS_OFFLINE_NOTICE = "Stripe objects (Payment Links, and the trial or price set on a Stripe Price) are not checked offline: this change touches a trial, price or interval, so run checkbox ci --online (CHECKBOX_SECRET_KEY, with Stripe connected on the site) to check that Stripe sells the same terms.";
30137
+ STRIPE_OBJECTS_OFFLINE_NOTICE = "Stripe NOT checked: this change touches a trial, price or interval, and this run was offline, so Checkbox did not check that Stripe (the Price, Payment Links) sells the same terms. Run checkbox ci --online with CHECKBOX_SECRET_KEY set (and Stripe connected on the site in Checkbox). In GitHub Actions: add the repository secret CHECKBOX_SECRET_KEY and the --online args shown below.";
30138
+ STRIPE_ONLINE_WORKFLOW_DIFF = [
30139
+ "--- a/.github/workflows/checkbox.yml",
30140
+ "+++ b/.github/workflows/checkbox.yml",
30141
+ "@@",
30142
+ " - uses: checkbox-site/checkbox@v1",
30143
+ "- # with:",
30144
+ "- # strict: true # also fail when a check needs review",
30145
+ "+ with:",
30146
+ "+ args: --online",
30147
+ "+ env:",
30148
+ "+ CHECKBOX_SECRET_KEY: ${{ secrets.CHECKBOX_SECRET_KEY }}"
30149
+ ].join("\n");
29914
30150
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
29915
30151
  RANK2 = { fail: 0, needs_review: 1 };
29916
30152
  OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
@@ -29934,6 +30170,8 @@ var init_runner = __esm({
29934
30170
  cancellation_confirmation: "cancellation confirmation"
29935
30171
  };
29936
30172
  DEFAULT_LABEL = { pass: "Checks pass", fail: "Fixes needed", needs_review: "Needs review", not_applicable: "Not required for this plan" };
30173
+ CONSENT_COVERED = "Consent is collected on Stripe Checkout, not on this page: this run checks it in checkbox.json's stripe_checkout entries (consent_collection.terms_of_service and the renewal terms in custom_text). No checkbox is needed on this page.";
30174
+ OPEN_COUNTS = ["needs_fix", "not_set_up", "your_call", "not_checked"];
29937
30175
  ALERT_CODES = /* @__PURE__ */ new Set(["coupon_unverified", "coupon_read", "merchant_trial_ignored", "merchant_price_ignored", "price_not_in_site_plans", "price_from_setup_plan", "plan_terms_conflict", "trial_mismatch", "intro_mismatch"]);
29938
30176
  }
29939
30177
  });
@@ -29967,10 +30205,13 @@ function renderHuman(run2, c, opts = {}) {
29967
30205
  lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
29968
30206
  if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
29969
30207
  if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
30208
+ const restore = restoreStylingLine(x);
30209
+ if (restore) lines.push(` fix: ${wrap(restore, 110, " ")}`);
29970
30210
  for (const f of x.fixes.slice(1, opts.verbose ? void 0 : 4)) lines.push(/^Plan card [^:]+: Decision for you\b/.test(f) ? ` decision: ${wrap(f.replace(/Decision for you \(not a fix\): /, ""), 110, " ")}` : ` fix: ${wrap(f, 110, " ")}`);
29971
30211
  if (!opts.verbose && x.fixes.length > 4) lines.push(c.dim(` (+${x.fixes.length - 4} more with --verbose)`));
29972
30212
  const alerts = x.alerts ?? [];
29973
30213
  for (const a of alerts) lines.push(c.yellow(` warning: ${wrap(a, 110, " ")}`));
30214
+ for (const st of x.stripe ?? []) lines.push(` stripe: ${wrap(st, 110, " ")}`);
29974
30215
  for (const w of x.warnings.filter((w2) => !alerts.includes(w2) && !PRICE_UNREAD.test(w2)).slice(0, opts.verbose ? 10 : alerts.length ? 0 : 1)) lines.push(c.dim(` note: ${wrap(w, 110, " ")}`));
29975
30216
  for (const w of x.warnings) {
29976
30217
  const id = PRICE_UNREAD.exec(w)?.[1];
@@ -29981,12 +30222,35 @@ function renderHuman(run2, c, opts = {}) {
29981
30222
  if (unreadPrices.length) lines.push("", c.dim(wrap(`note: ${unreadPrices.length === 1 ? `Price ${unreadPrices[0]} was` : `Prices ${unreadPrices.join(", ")} were`} not read (no connected Stripe account has ${unreadPrices.length === 1 ? "it" : "them"}), so the amounts checked come from "plans" in checkbox.json. To check them against Stripe, connect Stripe on the site in Checkbox and run checkbox ci --online.`, 110, " ")));
29982
30223
  const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
29983
30224
  if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
29984
- if (run2.offline_notice) lines.push("", c.yellow(wrap(`Not checked offline: ${run2.offline_notice}`, 110, " ")));
30225
+ if (run2.stripe_not_checked) {
30226
+ lines.push("", (run2.stripe_not_checked.strict_failed ? c.red : c.yellow)(wrap(run2.stripe_not_checked.reason, 110, "")));
30227
+ lines.push(c.dim("Workflow change (.github/workflows/checkbox.yml), plus the repository secret CHECKBOX_SECRET_KEY:"), ...run2.stripe_not_checked.workflow_diff.split("\n").map((l) => c.dim(` ${l}`)));
30228
+ } else if (run2.offline_notice) lines.push("", c.yellow(wrap(run2.offline_notice, 110, " ")));
29985
30229
  lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}${run2.result ? ` (${run2.result.replace(/ \(exit \d+\)$/, "")})` : ""}`);
29986
30230
  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."));
29987
30231
  lines.push(c.dim(run2.disclaimer));
29988
30232
  return lines;
29989
30233
  }
30234
+ function removedStyling(removed) {
30235
+ const parts = [];
30236
+ for (const l of removed) {
30237
+ const jsx = /\bstyle=\{\{([^}]*)\}\}/.exec(l);
30238
+ if (jsx) parts.push(`style={{${jsx[1]}}}`);
30239
+ const html = /\bstyle=(["'])([^"']*)\1/.exec(l);
30240
+ if (html && html[2].trim()) parts.push(`style="${html[2].trim()}"`);
30241
+ const cls = /\b(className|class)=(["'])([^"']*)\2/.exec(l);
30242
+ if (cls && cls[3].trim()) parts.push(`${cls[1]}="${cls[3].trim()}"`);
30243
+ const tag = /<(strong|b)[\s>]/i.exec(l);
30244
+ if (tag) parts.push(`<${tag[1].toLowerCase()}>`);
30245
+ if (parts.length) break;
30246
+ }
30247
+ return parts.length ? parts.join(" ") : void 0;
30248
+ }
30249
+ function restoreStylingLine(x) {
30250
+ if (x.type !== "page" && x.type !== "disclosure") return void 0;
30251
+ const style2 = x.anchored?.removed?.length ? removedStyling(x.anchored.removed) : void 0;
30252
+ return style2 ? `Restore the line with its styling (${style2}), not as plain text: Checkbox checks that the renewal terms stand out next to the button, not only that they are there.` : void 0;
30253
+ }
29990
30254
  function annotations(run2) {
29991
30255
  const out = [];
29992
30256
  for (const x of run2.checks) {
@@ -29995,8 +30259,10 @@ function annotations(run2) {
29995
30259
  if (!level) continue;
29996
30260
  const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
29997
30261
  const removed = x.anchored?.removed?.length ? `This change removed ${x.anchored.removed.length === 1 ? "a line" : `${x.anchored.removed.length} lines`} here: ${x.anchored.removed.slice(0, 2).map((l) => `"${l.trim().slice(0, 120)}"`).join(", ")}${x.anchored.removed.length > 2 ? ", \u2026" : ""}` : "";
30262
+ const corrected = !x.corrected ? "" : x.corrected.format === "text" && x.corrected.body.length <= 1500 ? `${x.corrected.title}:
30263
+ ${x.corrected.body}` : `${x.corrected.title}: see the job summary.`;
29998
30264
  const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
29999
- ${x.fix}` : `Could not run: ${x.message ?? ""}` : [removed, x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), x.corrected ? `${x.corrected.title}: see the job summary.` : ""].filter(Boolean).join("\n");
30265
+ ${x.fix}` : `Could not run: ${x.message ?? ""}` : [removed, restoreStylingLine(x) ?? "", x.status === "pass" && !x.fix ? "" : x.fix ?? x.label, ...x.fixes.slice(1, 4).map((f) => `\u2022 ${f}`), ...alerts.map((a) => `Warning: ${a}`), corrected].filter(Boolean).join("\n");
30000
30266
  out.push(`::${level} ${props.join(",")}::${escData(body)}`);
30001
30267
  }
30002
30268
  if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
@@ -30009,11 +30275,14 @@ function stepSummary(run2) {
30009
30275
  lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
30010
30276
  if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
30011
30277
  if (run2.result) lines.push(`Result: ${run2.result}.`, "");
30012
- if (run2.offline_notice) lines.push(`\u26A0\uFE0F ${run2.offline_notice}`, "");
30278
+ if (run2.stripe_not_checked) {
30279
+ lines.push(`${run2.stripe_not_checked.strict_failed ? "\u274C" : "\u26A0\uFE0F"} **Stripe NOT checked.** ${run2.stripe_not_checked.reason.replace(/^Stripe NOT checked: /, "")}`, "");
30280
+ lines.push("<details><summary>Workflow change that checks Stripe (plus the repository secret <code>CHECKBOX_SECRET_KEY</code>)</summary>", "", "```diff", run2.stripe_not_checked.workflow_diff, "```", "", "</details>", "");
30281
+ } else if (run2.offline_notice) lines.push(`\u26A0\uFE0F ${run2.offline_notice}`, "");
30013
30282
  lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
30014
30283
  for (const x of run2.checks) {
30015
30284
  const fix0 = x.status === "error" || x.status === "skipped" ? [x.message, x.fix].filter(Boolean).join(" ") : x.fix ? `${x.fix}${x.fixes.length > 1 ? ` (+${x.fixes.length - 1} more)` : ""}` : x.status === "needs_review" && x.message ? x.message : "";
30016
- const fix = [fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`)].filter(Boolean).join("\n");
30285
+ const fix = [restoreStylingLine(x) ?? "", fix0, ...(x.alerts ?? []).map((a) => `\u26A0\uFE0F ${a}`), ...(x.stripe ?? []).map((st) => `Stripe: ${st}`)].filter(Boolean).join("\n");
30017
30286
  lines.push(`| ${STATUS_MD[x.status]} ${cell(x.name)}<br><sub>${TYPE_LABEL[x.type]}</sub> | ${cell(x.label)} | ${cell(fix)} | \`${cell(where(x))}\` |`);
30018
30287
  }
30019
30288
  const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
@@ -30625,16 +30894,17 @@ ${message}
30625
30894
  const run0 = await runCi(jobs, engine, { config: loaded.config, configPath: configName, strict, extra, ...notices ? { notices, noticesTarget: { file: relative5(workspace, path).split("\\").join("/") || configName, line: noticesLine } } : {} });
30626
30895
  const baseRef = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
30627
30896
  if (baseRef) anchorToChanges(run0.checks, ctx.cwd, workspace, baseRef);
30628
- const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? { ...run0, offline_notice: STRIPE_OBJECTS_OFFLINE_NOTICE } : run0;
30897
+ const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? withStripeNotChecked(run0, strict) : run0;
30629
30898
  ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
30630
30899
  if (gha) {
30631
30900
  for (const a of annotations(run2)) emit(a);
30632
30901
  writeGithubFile(ctx.env.GITHUB_STEP_SUMMARY, stepSummary(run2));
30633
- if (run2.offline_notice) emit(`::notice title=Checkbox%3A Stripe not checked offline::${run2.offline_notice}`);
30902
+ if (run2.stripe_not_checked) emit(`::${run2.stripe_not_checked.strict_failed ? "error" : "warning"} title=Checkbox%3A Stripe NOT checked::${`${run2.stripe_not_checked.reason}
30903
+ ${run2.stripe_not_checked.workflow_diff}`.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")}`);
30634
30904
  writeGithubFile(ctx.env.GITHUB_OUTPUT, `status=${run2.status}
30635
30905
  exit_code=${run2.exit_code}
30636
30906
  result=${run2.result}
30637
- `);
30907
+ ${run2.stripe_not_checked ? "stripe_checked=false\n" : ""}`);
30638
30908
  }
30639
30909
  return run2.exit_code;
30640
30910
  }
@@ -31265,7 +31535,8 @@ function planEnv(d, v) {
31265
31535
  [names.secret, v.secretKey],
31266
31536
  [names.publishable, v.publishableKey],
31267
31537
  ...v.liveSecretKey ? [[names.liveSecret, v.liveSecretKey]] : [],
31268
- ...v.livePublishableKey ? [[names.livePublishable, v.livePublishableKey]] : []
31538
+ ...v.livePublishableKey ? [[names.livePublishable, v.livePublishableKey]] : [],
31539
+ ...v.apiBase ? [["CHECKBOX_API_BASE", v.apiBase]] : []
31269
31540
  ];
31270
31541
  const existing = read(d.root, d.envFile);
31271
31542
  let text = existing ?? "";
@@ -31276,7 +31547,7 @@ function planEnv(d, v) {
31276
31547
  if (re.test(text)) {
31277
31548
  if (!text.match(re)[0].endsWith(`=${val}`)) {
31278
31549
  text = text.replace(re, `${k}=${val}`);
31279
- added.push(`${k}=${mask(val)}`);
31550
+ added.push(`${k}=${k === "CHECKBOX_API_BASE" ? val : mask(val)}`);
31280
31551
  }
31281
31552
  } else missing.push([k, val]);
31282
31553
  }
@@ -31284,7 +31555,7 @@ function planEnv(d, v) {
31284
31555
  const block = ["# Checkbox (subscription compliance) \u2014 written by `checkbox init`. Secret: never commit this file.", ...missing.map(([k, val]) => `${k}=${val}`)];
31285
31556
  text = `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${block.join("\n")}
31286
31557
  `;
31287
- added.push(...missing.map(([k, val]) => `${k}=${mask(val)}`));
31558
+ added.push(...missing.map(([k, val]) => `${k}=${k === "CHECKBOX_API_BASE" ? val : mask(val)}`));
31288
31559
  }
31289
31560
  if (existing === text) return { file: d.envFile, action: "unchanged", summary: `${d.envFile} already has the Checkbox keys`, added: [] };
31290
31561
  return { file: d.envFile, action: existing === null ? "create" : "modify", summary: `Keys written to ${d.envFile} (${entries.map(([k]) => k).join(", ")})`, contents: text, added };
@@ -31410,7 +31681,8 @@ function saveState(ctx, root, patch) {
31410
31681
  }
31411
31682
  function withEvaluatedAt(c) {
31412
31683
  const t = c.evaluated;
31413
- return typeof t === "number" && t > 0 ? { evaluated_at: new Date(t * 1e3).toISOString(), ...c } : c;
31684
+ const at = typeof t === "number" && t > 0 ? new Date(t * 1e3).toISOString() : typeof c.evaluated_at === "string" && c.evaluated_at ? c.evaluated_at : null;
31685
+ return { ...c, evaluated: !!at, evaluated_at: at };
31414
31686
  }
31415
31687
  async function ask2(question) {
31416
31688
  const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
@@ -31585,8 +31857,8 @@ function resultOf2(status, d, session, changes, extra, apiBase) {
31585
31857
  install: session.install,
31586
31858
  stripe: { ...session.stripe, ...extra.stripe ?? {} },
31587
31859
  ...extra.live && Object.keys(extra.live).length ? { live: extra.live } : {},
31588
- // Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp): the CLI says
31589
- // when, as evaluated_at (ISO 8601), next to the API's field.
31860
+ // Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp); round 34:
31861
+ // evaluated is a boolean, evaluated_at (ISO 8601) says when.
31590
31862
  compliance: session.compliance ? withEvaluatedAt(session.compliance) : session.compliance,
31591
31863
  ...session.go_live ? { go_live: session.go_live } : {},
31592
31864
  steps: session.steps.map((s2) => ({ key: s2.key, status: s2.status, summary: s2.summary })),
@@ -31706,7 +31978,7 @@ var init_init = __esm({
31706
31978
  const dryRun = bool(flags, "dry-run") === true;
31707
31979
  const yes = bool(flags, "yes") === true || ctx.flags.json;
31708
31980
  const out = ctx.out;
31709
- const apiBase = (ctx.flags.apiBase ?? ctx.env.CHECKBOX_API_BASE ?? ctx.config.load().api_base ?? DEFAULT_API_BASE).replace(/\/+$/, "");
31981
+ const apiBase = (ctx.credentials()?.apiBase ?? ctx.flags.apiBase ?? ctx.env.CHECKBOX_API_BASE ?? ctx.config.load().api_base ?? DEFAULT_API_BASE).replace(/\/+$/, "");
31710
31982
  const d = detectProject(root, ctx.env);
31711
31983
  const state = loadState(ctx, root);
31712
31984
  const remembered = state.facts ?? {};
@@ -31904,7 +32176,7 @@ var init_init = __esm({
31904
32176
  if (token) warnings.push(`This folder is deployed as it is (${d.framework}), so the secret key was not written to a file in it: it is saved in the Checkbox CLI config (\`checkbox plan\`, \`checkbox verify\` use it). Server code that calls the API should read it from your host's secret settings (CHECKBOX_SECRET_KEY).`);
31905
32177
  } else {
31906
32178
  if (token && envTracked) warnings.push(trackedEnvWarning(d.envFile));
31907
- else if (token) changes.push(planEnv(d, { secretKey: token.secret_key, publishableKey: token.publishable_key, ...token.live_secret_key ? { liveSecretKey: token.live_secret_key } : {}, ...token.live_publishable_key ? { livePublishableKey: token.live_publishable_key } : {} }));
32179
+ else if (token) changes.push(planEnv(d, { secretKey: token.secret_key, publishableKey: token.publishable_key, ...token.live_secret_key ? { liveSecretKey: token.live_secret_key } : {}, ...token.live_publishable_key ? { livePublishableKey: token.live_publishable_key } : {}, ...apiBase !== DEFAULT_API_BASE ? { apiBase } : {} }));
31908
32180
  const gi = planGitignore(d);
31909
32181
  if (gi) changes.push(gi);
31910
32182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkbox-site/cli",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "The checkbox CLI: auto-renewal law and subscription compliance checks (FTC ROSCA, California ARL, New York and other states) for Stripe Checkout params, pages, disclosures and emails, from a terminal, CI or an AI coding agent. Runs offline.",
5
5
  "keywords": [
6
6
  "checkbox",