@checkbox-site/cli 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/checkbox.js +338 -56
  3. package/package.json +16 -13
package/README.md CHANGED
@@ -75,7 +75,7 @@ both exist, the CLI prints which one it used (stderr), and `checkbox whoami` sho
75
75
 
76
76
  | Command | What it does |
77
77
  |---|---|
78
- | `checkbox init [--yes] [--json]` | one browser approval (device login), then keys in `.env.local`, snippet in the root layout, site + Terms / policies registered, Stripe test subscription (with permission; only a Stripe restricted TEST key, `rk_test_…` in `STRIPE_RESTRICTED_KEY`, is used: `sk_test_` and live keys are refused with how to make one), first checks, owner email; `--verify` re-checks after a deploy; `--ci` only adds CI (`checkbox ci init`) |
78
+ | `checkbox init [--yes] [--json]` | one browser approval (device login), then keys in `.env.local` (not when that file is tracked by git or the folder is deployed as it is: the key then stays only in `~/.config/checkbox/config.json`), the SRI-pinned snippet in the root layout, site + Terms / policies registered, Stripe test subscription (with permission; only a Stripe restricted TEST key, `rk_test_…` in `STRIPE_RESTRICTED_KEY`, is used: `sk_test_` and live keys are refused with how to make one), first checks, owner email; `--verify` re-checks after a deploy; `--ci` only adds CI (`checkbox ci init`) |
79
79
  | `checkbox login [--api-key <key>\|--stdin]` | verifies the key with `GET /v1/account`, then saves it |
80
80
  | `checkbox whoami` | account, mode (test/live) and where the key came from |
81
81
  | `checkbox logout` | forgets the stored key |
package/dist/checkbox.js CHANGED
@@ -3335,7 +3335,7 @@ var init_operations = __esm({
3335
3335
  /**
3336
3336
  * Register the Checkout Session params your code uses.
3337
3337
  *
3338
- * Registers exactly the object your code passes to stripe.checkout.sessions.create (customer, URLs and secrets are not needed and are ignored; `warnings` says what was not stored). It is judged exactly like checkStripeCheckoutSession, discounts included. A config with the same name, or for the same Price ids, replaces the older one (`replaced`). Test runs mirror it into Stripe test mode; when it matches your latest live checkout, the scanned test page counts as an observation of your live checkout. Re-register after changing your checkout code, then call createTestRun.
3338
+ * Registers exactly the object your code passes to stripe.checkout.sessions.create, as `params` (that object sent alone as the body is accepted too; customer, URLs and secrets are not needed and are ignored; `warnings` says what was not stored). It is judged exactly like checkStripeCheckoutSession, discounts included. A config with the same name, or for the same Price ids, replaces the older one (`replaced`). Test runs mirror it into Stripe test mode; when it matches your latest live checkout, the scanned test page counts as an observation of your live checkout. Re-register after changing your checkout code, then call createTestRun.
3339
3339
  *
3340
3340
  * @param site Unique identifier of the site (site_…).
3341
3341
  * @see POST /v1/sites/{site}/stripe_checkout_configs (operation `createStripeCheckoutConfig`, scope `test_runs:write`)
@@ -3617,7 +3617,7 @@ var SDK_VERSION;
3617
3617
  var init_version = __esm({
3618
3618
  "../sdk-node/src/version.ts"() {
3619
3619
  "use strict";
3620
- SDK_VERSION = "0.2.3";
3620
+ SDK_VERSION = "0.2.4";
3621
3621
  }
3622
3622
  });
3623
3623
 
@@ -11321,6 +11321,9 @@ function introAmountOf(amount, o) {
11321
11321
  return void 0;
11322
11322
  }
11323
11323
  function unverifiedReason(ctx) {
11324
+ const known = ctx.referencePlans ?? [];
11325
+ if (known.length) return `none of your plans (${known.slice(0, 4).map((p) => p.label ?? p.name ?? p.id).join(", ")}) was matched to this checkout's price (the Price id it sells is not one Checkbox has for them)`;
11326
+ if ((ctx.checkoutConfigs?.length ?? 0) > 0) return "the price this checkout sells is not in your registered Checkout configs or your billing platform's prices";
11324
11327
  const chosen = ctx.facts?.planPriceId;
11325
11328
  if (chosen && ctx.config) return `the chosen price ${chosen} is not among the active recurring prices read from your billing platform`;
11326
11329
  if (chosen) return `the chosen price ${chosen} could not be read (no billing platform connected)`;
@@ -11461,8 +11464,8 @@ function sameProductName(a, b) {
11461
11464
  function declaredPriceIds(x) {
11462
11465
  return [x.price_id, x.stripe_price, x.priceId, x.id].filter((v) => typeof v === "string" && v.trim().length > 0);
11463
11466
  }
11464
- function reconcileDeclaredPlans(raw, billing, platform = "Stripe") {
11465
- const prices = billing.filter((p) => p && p.amount > 0 && p.interval);
11467
+ function reconcileDeclaredPlans(raw, billing, platform = "Stripe", checkoutTrials) {
11468
+ const prices = billing.filter((p) => p && p.amount > 0 && p.interval).map((p) => checkoutTrials?.has(p.id) ? { ...p, trialDays: checkoutTrials.get(p.id) } : p);
11466
11469
  const out = [...raw];
11467
11470
  const conflicts = [];
11468
11471
  if (!prices.length || !raw.length) return { plans: out, conflicts };
@@ -14917,7 +14920,8 @@ function inAppPath(input) {
14917
14920
  return { kind: "in_app", state: "works", detail: "a test cancellation completed online" };
14918
14921
  }
14919
14922
  if (hostedStep2) return void 0;
14920
- const kinds = (flow.blockers ?? []).map(classifyBlocker);
14923
+ const onHosted = (flow.steps ?? []).some((s2) => s2.source !== "snippet" && isHostedCancelUrl(s2.url));
14924
+ const kinds = (flow.blockers ?? []).map(classifyBlocker).filter((k) => !(onHosted && k === "contact"));
14921
14925
  if (kinds.some((k) => k === "contact" || k === "not_found" || k === "obstacle")) {
14922
14926
  return { kind: "in_app", state: "broken", detail: `the test cancellation did not complete (${flow.blockers.join("; ")})` };
14923
14927
  }
@@ -14994,7 +14998,7 @@ function offlineOnlyCancellation(input) {
14994
14998
  const hosted = input.ctx.hostedCancel;
14995
14999
  const helpHits = [];
14996
15000
  for (const c of captures) {
14997
- if (isFetchedDocument(c) || c.pageProblem || c.pageKind === "checkout") continue;
15001
+ if (isFetchedDocument(c) || c.pageProblem || c.pageKind === "checkout" || isHostedCancelUrl(c.url)) continue;
14998
15002
  const sentence = contactOnlySentence(c.text);
14999
15003
  if (!sentence) continue;
15000
15004
  if (ownOnlineCancelControls(c, hosted).length) continue;
@@ -15005,7 +15009,7 @@ function offlineOnlyCancellation(input) {
15005
15009
  }
15006
15010
  return { where: `${c.pageKind === "account" ? "account page" : c.pageKind === "cancel" ? "cancel page" : "page"} ${c.url.split("#")[0]}`, quote: sentence, capture: c };
15007
15011
  }
15008
- const contactOnlyUrls = new Set(captures.filter((c) => !isFetchedDocument(c) && contactOnlySentence(c.text)).map((c) => stripUrl(c.url)));
15012
+ const contactOnlyUrls = new Set(captures.filter((c) => !isFetchedDocument(c) && !isHostedCancelUrl(c.url) && contactOnlySentence(c.text)).map((c) => stripUrl(c.url)));
15009
15013
  const onlineControl = captures.some(
15010
15014
  (c) => !isFetchedDocument(c) && ownOnlineCancelControls(c, hosted).some((el) => !contactOnlyUrls.has(stripUrl(hrefOf(el) ?? "", c.url)))
15011
15015
  );
@@ -16851,6 +16855,17 @@ function cancelBeforeTrialMatch(text) {
16851
16855
  if (byDate && TRIAL_ENDS_DATE_RE.test(text)) return byDate[0];
16852
16856
  return null;
16853
16857
  }
16858
+ function statedTrialMismatch(a, p, offers) {
16859
+ const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
16860
+ if (!stated.length) return void 0;
16861
+ const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
16862
+ if (!own.length) return void 0;
16863
+ const fits = (days, trial) => days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
16864
+ if (stated.some((t) => own.some((o) => fits(t.days, o.trialDays)))) return void 0;
16865
+ const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
16866
+ 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`;
16867
+ return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
16868
+ }
16854
16869
  var ACK_MAX_DELAY_HOURS, NO_ACK_EMAIL_MESSAGE, ackCache, SIGNUP_LINK_TOLERANCE_MS, sent, terms, cancellationInstructions, CANCELLING_RE, CANCEL_BEFORE_TRIAL_RE, MONTH, DATE, CANCEL_BEFORE_NO_CHARGE_RE, CANCEL_BEFORE_DATE_RE, TRIAL_ENDS_DATE_RE, trialTerms, CONFIRMATION_CHECKS;
16855
16870
  var init_confirmation = __esm({
16856
16871
  "../rules/src/checks/confirmation.ts"() {
@@ -17160,6 +17175,15 @@ ${v.pageText}`);
17160
17175
  );
17161
17176
  const units = trialDocs.map((doc) => {
17162
17177
  const a = doc.analysis;
17178
+ const wrongTrial = statedTrialMismatch(a, ackPlan(doc.email, input, offers), offers);
17179
+ if (wrongTrial) {
17180
+ return docUnit(doc, {
17181
+ status: "fail",
17182
+ detail: `says ${wrongTrial.stated}, but ${wrongTrial.plan}: state the trial the subscriber really gets`,
17183
+ confidence: 0.8,
17184
+ meta: { trialLengthMismatch: true }
17185
+ });
17186
+ }
17163
17187
  const method = a.cancelMethod[0];
17164
17188
  const timing = a.trialLengths[0] ?? a.postTrialCharge[0];
17165
17189
  const beforeMatch = cancelBeforeTrialMatch(a.text);
@@ -22221,7 +22245,7 @@ function classifyRaw(input) {
22221
22245
  }
22222
22246
  }
22223
22247
  function fixFromFail(c) {
22224
- return withPlanTerms(contentFixLine(String(c.checkId), c.data) ?? contentFixFromText(String(c.checkId), c.detail) ?? introFixFromText(c) ?? planFrequencyFix(c) ?? FAIL_FIX[String(c.checkId)] ?? "Apply the change in the task for this check.", c.data);
22248
+ return withPlanTerms(contentFixLine(String(c.checkId), c.data) ?? contentFixFromText(String(c.checkId), c.detail) ?? introFixFromText(c) ?? trialLengthFixFromText(c) ?? planFrequencyFix(c) ?? FAIL_FIX[String(c.checkId)] ?? "Apply the change in the task for this check.", c.data);
22225
22249
  }
22226
22250
  function withPlanTerms(fix, data) {
22227
22251
  if (!/<(?:price|interval)>/.test(fix)) return fix;
@@ -22241,6 +22265,11 @@ function introFixFromText(c) {
22241
22265
  const m = /introductory price of (\S+?)(?: \(\d+% off\))? for (the first [^—,]+?)\s*(?:,|—)/i.exec(c.detail);
22242
22266
  return m ? `State the introductory price (${m[1]} for ${m[2]}) and when it ends, then the regular price from the next charge, in the renewal line.` : "State the introductory price and how long it lasts, then the regular price from the next charge, in the renewal line.";
22243
22267
  }
22268
+ function trialLengthFixFromText(c) {
22269
+ if (c.checkId !== "confirmation.email.trial_terms") return void 0;
22270
+ const m = /says “([^”]+)”, but .*?\b(?:has|gives?) a ([\d -]+?or [\d]+|\d+)-day free trial/.exec(c.detail);
22271
+ return m ? `The acknowledgment says \u201C${m[1]}\u201D, but the plan has a ${m[2]}-day free trial: state the trial the subscriber really gets, and how to cancel before it ends.` : void 0;
22272
+ }
22244
22273
  function planFrequencyFix(c) {
22245
22274
  if (c.checkId !== "signup.disclosure.frequency") return void 0;
22246
22275
  const every = /\bplans? (?:is|are) billed (every [a-z0-9 ]+?)(?= \/ | —|[.;,]|$)/i.exec(c.detail)?.[1];
@@ -22326,7 +22355,7 @@ function classifyFindings(findings) {
22326
22355
  info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
22327
22356
  }));
22328
22357
  }
22329
- var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22358
+ var STATUS_LABELS, CHECK_TIERS, LIVE_CHECKS, has, isStripeInput, HOSTED_BEFORE_LINE_NOTE, HOSTED_CHECKOUT_NOTE, family, isEmailCheck, ask, PLAN_FACTS_FIX, STRIPE_CANCEL_UNREAD_FIX, BILLING_CONTRADICTS_FIX, VERIFIED_FROM_STRIPE_NOTE, VERIFIED_FROM_HOSTED_NOTE, HOSTED_UNLINKED_FIX, NOT_CHECKED_CANCEL_FIX, EMAIL_EXISTS, RULES, STATUS_RULES, NY_EMAIL_ONLY_LABEL, NY_EMAIL_ONLY_NOTE, STRIPE_HOSTED_UNITS, PLATFORM_NAMES2, PORTAL_REQUIRED_FIX, PORTAL_IN_APP_BROKEN_FIX, PORTAL_OFFLINE_FIX, PAYMENT_LINK_PORTAL_FIX, PAYMENT_LINK_OFFLINE_FIX, RENEWAL_LINE_EXAMPLE, FAIL_FIX, CHECKS_PASS_UNREAD_LABEL, STATUS_RANK;
22330
22359
  var init_status = __esm({
22331
22360
  "../rules/src/status.ts"() {
22332
22361
  "use strict";
@@ -22522,7 +22551,7 @@ var init_status = __esm({
22522
22551
  },
22523
22552
  { code: "receipt_without_terms", category: "content", when: has(/only a receipt without subscription terms/), build: () => ({ status: "needs_fix", fix: "Send an acknowledgment that restates the renewal terms (price, frequency, renews until cancelled) and how to cancel; a plain receipt is not enough." }) },
22524
22553
  // Audit P2: Stripe wording (custom_text) only for Stripe inputs; any other checkout gets the same line, platform-neutral.
22525
- { code: "processor_default_line", category: "content", when: has(/payment processor.s default line|authorization line is not the renewal terms|no automatic-renewal statement was found/), build: (c) => ({ status: "needs_fix", fix: withPlanTerms(`Add your own renewal line next to the pay button${isStripeInput(c) ? " (Stripe custom_text.submit)" : ""}: "Renews automatically every <interval> at <price> until you cancel. Cancel anytime with your cancel link."`, c.data) }) },
22554
+ { code: "processor_default_line", category: "content", when: has(/payment processor.s default line|authorization line is not the renewal terms|no automatic-renewal statement was found/), build: (c) => ({ status: "needs_fix", fix: withPlanTerms(`Add your own renewal line next to the pay button${isStripeInput(c) ? " (Stripe custom_text.submit)" : ""}: "${RENEWAL_LINE_EXAMPLE} Cancel anytime with your cancel link."`, c.data) }) },
22526
22555
  { code: "renewal_without_until_cancel", category: "content", when: has(/but not that it continues until the customer cancels/), build: () => ({ status: "needs_fix", fix: 'Add "until you cancel" to the renewal line.' }) },
22527
22556
  { code: "terms_hidden", category: "content", when: has(/collapsed <details>|\(nearly\) the colour of its background|nearly the colour of the background|hidden by a stylesheet|sr-only/), build: () => ({ status: "needs_fix", fix: "Show the renewal line as plain visible text next to the button (not collapsed, not faded into the background)." }) },
22528
22557
  // Pilot round 11 (change-guard): an intro / discounted price the Stripe session does not apply.
@@ -22985,8 +23014,9 @@ var init_status = __esm({
22985
23014
  PORTAL_OFFLINE_FIX = 'Turn on "Cancel subscriptions" in the Stripe customer portal and link it from your account page, then replace the "email / call us to cancel" line with "Cancel anytime online in your account".';
22986
23015
  PAYMENT_LINK_PORTAL_FIX = `Turn on "Cancel subscriptions" in the Stripe customer portal (Stripe Dashboard \u2192 Settings \u2192 Billing \u2192 Customer portal), copy its billing.stripe.com login link and add it to your site's footer as a "Cancel subscription" link.`;
22987
23016
  PAYMENT_LINK_OFFLINE_FIX = `${PAYMENT_LINK_PORTAL_FIX} Then replace the "email / call us to cancel" line with "Cancel anytime online" and that link.`;
23017
+ RENEWAL_LINE_EXAMPLE = "<price> every <interval> (plus applicable taxes). Renews automatically until you cancel.";
22988
23018
  FAIL_FIX = {
22989
- "signup.disclosure.autorenew_language": 'Add one line under the price, before the card and email fields: "Renews automatically every <interval> at <price> until you cancel."',
23019
+ "signup.disclosure.autorenew_language": `Add one line under the price, before the card and email fields: "${RENEWAL_LINE_EXAMPLE}"`,
22990
23020
  "signup.disclosure.price": "State the recurring price in the renewal line (the amount actually charged each time).",
22991
23021
  "signup.disclosure.frequency": `State how often it is charged, in the plan's own interval ("every month", "every year").`,
22992
23022
  "signup.disclosure.cancellation_policy": 'Add "Cancel anytime online" to the renewal line, with your cancel link.',
@@ -25251,6 +25281,24 @@ var init_stripe2 = __esm({
25251
25281
  }
25252
25282
  });
25253
25283
 
25284
+ // ../rules/src/preflight/stripe-verdict.ts
25285
+ function stripeParamsVerdict(r) {
25286
+ if (r.verdict === "not_applicable") return { verdict: "not_applicable", open: [] };
25287
+ const open2 = r.checks.filter((c) => c.status === "fail" || c.status === "needs_review" && !(c.compliance_status && SATISFIED_COMPLIANCE.has(c.compliance_status))).map((c) => ({ check_id: c.check_id, status: c.status, title: c.title ?? c.check_id, detail: c.explanation ?? "" })).sort((a, b) => (a.status === "fail" ? 0 : 1) - (b.status === "fail" ? 0 : 1));
25288
+ if (r.verdict === "fail" || r.compliance_status === "needs_fix" || open2.some((c) => c.status === "fail")) return { verdict: "fail", open: open2 };
25289
+ const coupon = (r.warnings ?? []).find((w) => w.code === "coupon_unverified");
25290
+ if (open2.length) return { verdict: "needs_review", open: open2, ...coupon ? { reason: coupon.message } : {} };
25291
+ if (coupon) return { verdict: "needs_review", open: [], reason: coupon.message };
25292
+ return { verdict: "pass", open: [] };
25293
+ }
25294
+ var SATISFIED_COMPLIANCE;
25295
+ var init_stripe_verdict = __esm({
25296
+ "../rules/src/preflight/stripe-verdict.ts"() {
25297
+ "use strict";
25298
+ SATISFIED_COMPLIANCE = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
25299
+ }
25300
+ });
25301
+
25254
25302
  // ../rules/src/preflight/email.ts
25255
25303
  function linkify(escaped) {
25256
25304
  return escaped.replace(/https?:\/\/[^\s<]+/g, (raw) => {
@@ -25434,6 +25482,10 @@ function contextFor(kind, occ, plan2, jurisdictions, asOf, email) {
25434
25482
  function longDate2(d) {
25435
25483
  return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
25436
25484
  }
25485
+ function supportContactLine(contact, opts = {}) {
25486
+ if (contact || opts.required) return `Questions? Reply to this email or contact us at ${contact || SUPPORT_CONTACT_PLACEHOLDER}.`;
25487
+ return "Questions? Reply to this email.";
25488
+ }
25437
25489
  function emailTemplate(kind, plan2, opts = {}) {
25438
25490
  const brand = plan2.merchantName ?? "";
25439
25491
  const sub = `${brand ? `${brand} ` : ""}subscription`;
@@ -25441,7 +25493,7 @@ function emailTemplate(kind, plan2, opts = {}) {
25441
25493
  const every = intervalPhrase(plan2.interval, plan2.intervalCount);
25442
25494
  const cancel = plan2.cancelUrl ?? CANCEL_URL_PLACEHOLDER;
25443
25495
  const effect = plan2.cancelTiming === "immediately" ? "cancellation takes effect immediately" : "cancellation takes effect at the end of your current billing period";
25444
- const contact = opts.supportContact ? `Questions? Contact us at ${opts.supportContact}.` : "Questions? Reply to this email.";
25496
+ const contact = supportContactLine(opts.supportContact);
25445
25497
  const lines = (...l) => l.filter(Boolean).join("\n\n");
25446
25498
  const intro = plan2.introAmount !== void 0 && plan2.introAmount < plan2.amount ? `${formatMoney(plan2.introAmount, plan2.currency)} for ${plan2.introPeriods && plan2.introPeriods > 1 ? `your first ${plan2.introPeriods} billing periods` : `your first ${every}`}` : "";
25447
25499
  const introLine = intro ? `Introductory price: ${intro}, then ${money3} every ${every}.` : "";
@@ -25449,11 +25501,13 @@ function emailTemplate(kind, plan2, opts = {}) {
25449
25501
  case "acknowledgment":
25450
25502
  return {
25451
25503
  subject: `Your ${sub} is confirmed`,
25504
+ // Round 31 (pilot 30 item 22): the same sentences, in the same order, as the "You send" acknowledgment template
25505
+ // (apps/api notices/merchant-templates.ts), so the plan, checkEmail and the due notice give one email.
25452
25506
  text: lines(
25453
- `Thanks for subscribing${brand ? ` to ${brand}` : ""}! This email confirms your subscription terms.`,
25454
- introLine,
25507
+ `Thanks for subscribing${brand ? ` to ${brand}` : ""}. This email confirms your subscription terms.`,
25455
25508
  plan2.trialDays ? `Your free trial lasts ${plan2.trialDays} days and ends on {{trial_end_date}}. To avoid being charged, cancel before your trial ends at ${cancel} \u2014 it takes less than a minute. If you do nothing, your subscription starts automatically on {{trial_end_date}} and you will be charged ${money3} every ${every}.` : "",
25456
- `Your ${sub} automatically renews every ${every} at ${money3} until you cancel. Your next charge is on {{next_charge_date}}.`,
25509
+ `Your ${sub} automatically renews every ${every} at ${money3} until you cancel. Your ${plan2.trialDays ? "first" : "next"} charge is on {{next_charge_date}}.`,
25510
+ introLine,
25457
25511
  `How to cancel: go to ${cancel} \u2014 you can cancel anytime online; ${effect}.`,
25458
25512
  contact
25459
25513
  ),
@@ -25509,7 +25563,7 @@ function emailTemplate(kind, plan2, opts = {}) {
25509
25563
  `To stop future charges, cancel online anytime at ${cancel}.`,
25510
25564
  // Pilot round 11 (invariant test): Georgia's charge notice must give the business's contact information;
25511
25565
  // "reply to this email" is not one, so without a support contact the template asks for it.
25512
- opts.supportContact ? contact : `Questions? Contact us at ${SUPPORT_CONTACT_PLACEHOLDER}.`
25566
+ supportContactLine(opts.supportContact, { required: true })
25513
25567
  ),
25514
25568
  // Round 12: the template used {{support_contact}} without listing it as a merge tag to fill.
25515
25569
  tags: ["{{charge_date}}", "{{next_charge_date}}", ...opts.supportContact ? [] : [SUPPORT_CONTACT_PLACEHOLDER]]
@@ -26050,6 +26104,7 @@ __export(preflight_exports, {
26050
26104
  PREFLIGHT_DISCLAIMER: () => PREFLIGHT_DISCLAIMER,
26051
26105
  PREFLIGHT_DOCS_URL: () => PREFLIGHT_DOCS_URL,
26052
26106
  PreflightInputError: () => PreflightInputError,
26107
+ SATISFIED_COMPLIANCE: () => SATISFIED_COMPLIANCE,
26053
26108
  STRIPE_HOSTED_STYLING_NOTE: () => STRIPE_HOSTED_STYLING_NOTE,
26054
26109
  checkDisclosure: () => checkDisclosure,
26055
26110
  checkEmail: () => checkEmail,
@@ -26071,6 +26126,8 @@ __export(preflight_exports, {
26071
26126
  runChecks: () => runChecks,
26072
26127
  shapeChecks: () => shapeChecks,
26073
26128
  signupStripeObjects: () => signupStripeObjects,
26129
+ stripeParamsVerdict: () => stripeParamsVerdict,
26130
+ supportContactLine: () => supportContactLine,
26074
26131
  withIntroOffers: () => withIntroOffers
26075
26132
  });
26076
26133
  var init_preflight = __esm({
@@ -26079,6 +26136,7 @@ var init_preflight = __esm({
26079
26136
  init_common();
26080
26137
  init_disclosure();
26081
26138
  init_stripe2();
26139
+ init_stripe_verdict();
26082
26140
  init_email();
26083
26141
  init_html_page();
26084
26142
  init_page();
@@ -26699,8 +26757,8 @@ var init_config3 = __esm({
26699
26757
  });
26700
26758
 
26701
26759
  // src/commands/init/literal.ts
26702
- function parseObjectLiteral(source, env2 = {}) {
26703
- const p = new Parser(source, env2);
26760
+ function parseObjectLiteral(source, env2 = {}, consts = {}) {
26761
+ const p = new Parser(source, env2, consts);
26704
26762
  if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
26705
26763
  const v = p.object("");
26706
26764
  return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
@@ -26710,10 +26768,11 @@ var init_literal = __esm({
26710
26768
  "src/commands/init/literal.ts"() {
26711
26769
  "use strict";
26712
26770
  UNKNOWN = Symbol("unknown");
26713
- Parser = class {
26714
- constructor(s2, env2) {
26771
+ Parser = class _Parser {
26772
+ constructor(s2, env2, consts = {}) {
26715
26773
  this.s = s2;
26716
26774
  this.env = env2;
26775
+ this.consts = consts;
26717
26776
  }
26718
26777
  i = 0;
26719
26778
  unresolved = [];
@@ -26803,6 +26862,12 @@ var init_literal = __esm({
26803
26862
  if (name === "false") return false;
26804
26863
  if (name === "null") return null;
26805
26864
  if (name === "undefined") return void 0;
26865
+ if (Object.prototype.hasOwnProperty.call(this.consts, name)) {
26866
+ const sub = new _Parser(this.consts[name], this.env);
26867
+ const v = sub.value("");
26868
+ if (v !== UNKNOWN && v !== void 0 && path) this.fromEnv[path] = name;
26869
+ return v;
26870
+ }
26806
26871
  const env2 = /^(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(name);
26807
26872
  if (env2) {
26808
26873
  if (this.env[env2[1]] !== void 0 && path) this.fromEnv[path] = name;
@@ -27147,7 +27212,13 @@ function sessionLiteral(text, index) {
27147
27212
  else if (ch === quote2) quote2 = null;
27148
27213
  continue;
27149
27214
  }
27150
- if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27215
+ if (ch === "/" && text[i + 1] === "/") {
27216
+ const nl = text.indexOf("\n", i);
27217
+ i = nl === -1 ? text.length : nl;
27218
+ } else if (ch === "/" && text[i + 1] === "*") {
27219
+ const end = text.indexOf("*/", i + 2);
27220
+ i = end === -1 ? text.length : end + 1;
27221
+ } else if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27151
27222
  else if (ch === "{") depth++;
27152
27223
  else if (ch === "}") {
27153
27224
  depth--;
@@ -27156,6 +27227,58 @@ function sessionLiteral(text, index) {
27156
27227
  }
27157
27228
  return null;
27158
27229
  }
27230
+ function declaredExpr(text, from) {
27231
+ let depth = 0;
27232
+ let quote2 = null;
27233
+ for (let i = from; i < text.length && i - from < 2e3; i++) {
27234
+ const ch = text[i];
27235
+ if (quote2) {
27236
+ if (ch === "\\") i++;
27237
+ else if (ch === quote2) quote2 = null;
27238
+ continue;
27239
+ }
27240
+ if (ch === '"' || ch === "'" || ch === "`") quote2 = ch;
27241
+ else if (ch === "(" || ch === "[" || ch === "{") depth++;
27242
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
27243
+ else if ((ch === ";" || ch === "\n") && depth <= 0) return text.slice(from, i).trim();
27244
+ }
27245
+ return text.slice(from, from + 2e3).trim();
27246
+ }
27247
+ function constDeclarations(text, exportedOnly) {
27248
+ const out = {};
27249
+ const re = exportedOnly ? /(?:^|\n)[ \t]*export\s+const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g : /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g;
27250
+ for (const m of text.matchAll(re)) if (!(m[1] in out)) out[m[1]] = declaredExpr(text, m.index + m[0].length);
27251
+ return out;
27252
+ }
27253
+ function resolveImport(root, fromFile, spec) {
27254
+ const dir = fromFile.includes("/") ? fromFile.slice(0, fromFile.lastIndexOf("/")) : "";
27255
+ const bases = spec.startsWith(".") ? [join4(dir, spec)] : /^[@~]\//.test(spec) ? [spec.slice(2), join4("src", spec.slice(2))] : [];
27256
+ for (const b of bases) {
27257
+ const base = b.split("\\").join("/").replace(/\.(?:[cm]?js|jsx)$/, "");
27258
+ for (const f of [b, ...[".ts", ".tsx", ".js", ".mjs", ".jsx", "/index.ts", "/index.js"].map((e) => `${base}${e}`)]) {
27259
+ if (/\.[cm]?[jt]sx?$/.test(f) && existsSync4(join4(root, f))) return f.split("\\").join("/");
27260
+ }
27261
+ }
27262
+ return void 0;
27263
+ }
27264
+ function constantsFor(root, relFile, text) {
27265
+ const out = {};
27266
+ for (const m of text.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
27267
+ const target = resolveImport(root, relFile, m[2]);
27268
+ if (!target) continue;
27269
+ let exported;
27270
+ try {
27271
+ exported = constDeclarations(readFileSync5(join4(root, target), "utf8"), true);
27272
+ } catch {
27273
+ continue;
27274
+ }
27275
+ for (const part of m[1].split(",")) {
27276
+ const [name, alias] = part.trim().split(/\s+as\s+/).map((x) => x.trim());
27277
+ if (name && exported[name] !== void 0) out[alias || name] = exported[name];
27278
+ }
27279
+ }
27280
+ return { ...out, ...constDeclarations(text, false) };
27281
+ }
27159
27282
  function detectProject(root, processEnv = {}) {
27160
27283
  const pkg = readJson(join4(root, "package.json"));
27161
27284
  const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
@@ -27192,7 +27315,7 @@ function detectProject(root, processEnv = {}) {
27192
27315
  checkoutSessions.push({ file: rel2, line: line2, unresolved: ["<params are not an object literal>"] });
27193
27316
  continue;
27194
27317
  }
27195
- const parsed = parseObjectLiteral(lit, { ...env2, ...Object.fromEntries(Object.entries(processEnv).filter((e) => typeof e[1] === "string" && /^(STRIPE_|NEXT_PUBLIC_STRIPE_|PRICE_)/.test(e[0]))) });
27318
+ const parsed = parseObjectLiteral(lit, { ...env2, ...Object.fromEntries(Object.entries(processEnv).filter((e) => typeof e[1] === "string" && /^(STRIPE_|NEXT_PUBLIC_STRIPE_|PRICE_)/.test(e[0]))) }, constantsFor(root, rel2, text));
27196
27319
  checkoutSessions.push({ file: rel2, line: line2, ...parsed.value ? { params: redactSecretsDeep(parsed.value) } : {}, unresolved: parsed.unresolved });
27197
27320
  }
27198
27321
  });
@@ -27330,6 +27453,33 @@ function paramsBuilder(root, file, line2) {
27330
27453
  } else if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${fn}\\b|export\\s+const\\s+${fn}\\b`).test(text)) target = file;
27331
27454
  return target ? { module: `${target}#${fn}`, takesArgs } : void 0;
27332
27455
  }
27456
+ function plansSoldBy(params, plans) {
27457
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
27458
+ const out = [];
27459
+ for (const li of items) {
27460
+ const pd = li?.price_data;
27461
+ if (!pd || typeof pd.unit_amount !== "number") continue;
27462
+ const match = plans.find((p) => (typeof p.amount === "number" ? p.amount : typeof p.price === "number" ? Math.round(p.price * 100) : NaN) === pd.unit_amount && p.interval === pd.recurring?.interval);
27463
+ if (match && !out.includes(match)) out.push(match);
27464
+ }
27465
+ return out;
27466
+ }
27467
+ function cancelUrlInCode(sessions) {
27468
+ const urls = /* @__PURE__ */ new Set();
27469
+ for (const s2 of sessions) {
27470
+ const text = JSON.stringify(s2.params?.custom_text ?? "");
27471
+ for (const m of text.matchAll(/cancel[^"]{0,80}?(https:\/\/[^\s"'<>)]+?)(?=[.,;:!?]?(?:[\s"'<>)]|$))/gi)) urls.add(m[1]);
27472
+ }
27473
+ return urls.size === 1 ? [...urls][0] : void 0;
27474
+ }
27475
+ function pageSource(root, built) {
27476
+ const route = /(?:^|\/)(?:public|dist|out|build|static|_site|www)\/(?:(.*)\/)?([^/]+?)(?:\/index)?\.html?$/i.exec(built);
27477
+ if (!route) return void 0;
27478
+ const path = [route[1], route[2]].filter(Boolean).join("/");
27479
+ const bases = [`app/${path}/page`, `src/app/${path}/page`, `app/(marketing)/${path}/page`, `pages/${path}`, `src/pages/${path}`, `src/routes/${path}/+page`, `pages/${path}/index`, `src/pages/${path}/index`];
27480
+ const hits = bases.flatMap((b) => [".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".astro", ".mdx"].map((e) => `${b}${e}`)).filter((f) => existsSync5(join5(root, f)));
27481
+ return hits.length === 1 ? hits[0] : void 0;
27482
+ }
27333
27483
  function scaffoldConfig(root, env2 = {}, facts = {}) {
27334
27484
  const det = detectProject(root, env2);
27335
27485
  const todos = [];
@@ -27348,13 +27498,21 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27348
27498
  merchant.cancel_url = cancelFact;
27349
27499
  found.push(`cancel URL ${cancelFact} (from checkbox init)`);
27350
27500
  } else if (siteUrl && cancelPath) merchant.cancel_url = `${originOf(siteUrl)}${cancelPath}`;
27351
- else {
27501
+ else if (cancelUrlInCode(det.checkoutSessions)) {
27502
+ merchant.cancel_url = cancelUrlInCode(det.checkoutSessions);
27503
+ found.push(`cancel URL ${String(merchant.cancel_url)} (from the Checkout custom_text)`);
27504
+ } else {
27352
27505
  merchant.cancel_url = "TODO: https://\u2026 page where subscribers cancel online";
27353
27506
  todos.push("merchant.cancel_url: the page where subscribers cancel online");
27354
27507
  }
27355
27508
  if (fact("support_email")) merchant.support_contact = fact("support_email");
27509
+ const initPlans = Array.isArray(facts.plans) ? facts.plans : [];
27510
+ const initName = (p) => {
27511
+ const m = initPlans.find((x) => typeof x.name === "string" && x.amount === p.amount && x.interval === p.interval && (Number(x.interval_count ?? 1) || 1) === (p.interval_count ?? 1));
27512
+ return m ? String(m.name) : void 0;
27513
+ };
27356
27514
  const plans = det.plans.map((p, i) => ({
27357
- name: p.name ?? `Plan ${i + 1}`,
27515
+ name: p.name ?? initName(p) ?? `Plan ${i + 1}`,
27358
27516
  amount: p.amount,
27359
27517
  currency: p.currency,
27360
27518
  interval: p.interval,
@@ -27393,8 +27551,13 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27393
27551
  todos.push("plans: the price, interval and trial of each plan (the checkout uses Price ids, which cannot be read from code)");
27394
27552
  }
27395
27553
  const planNames = plans.map((p) => String(p.name));
27396
- const planFor = () => plans.length > 1 ? { plan: `TODO: one of ${planNames.map((n) => JSON.stringify(n)).join(", ")}` } : {};
27397
- if (plans.length > 1) todos.push("plan: pick the plan of each disclosure, page and email entry");
27554
+ const oneOf = planNames.map((n) => JSON.stringify(n)).join(", ");
27555
+ let planTodo = false;
27556
+ const planFor = (kind = "entry") => {
27557
+ if (plans.length <= 1) return {};
27558
+ planTodo = true;
27559
+ return { plan: kind === "page" ? `TODO: "all" (the page shows every plan) or one of ${oneOf}` : `TODO: one of ${oneOf}` };
27560
+ };
27398
27561
  const stripe = [];
27399
27562
  const perFile = /* @__PURE__ */ new Map();
27400
27563
  for (const s2 of det.checkoutSessions) {
@@ -27425,7 +27588,12 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27425
27588
  } else if (s2.unresolved.length) found.push(`${s2.file}:${s2.line}: Checkout Session (${s2.unresolved.length} value(s) are expressions, read as-is)`);
27426
27589
  else found.push(`${s2.file}:${s2.line}: Checkout Session`);
27427
27590
  entry.strict = true;
27428
- stripe.push(entry);
27591
+ const sold = s2.params ? plansSoldBy(s2.params, plans) : [];
27592
+ if (plans.length === 1 || sold.length === 1) stripe.push({ ...entry, plan: String((sold[0] ?? plans[0]).name) });
27593
+ else if (s2.params && plans.length > 1 && !plans.some((p) => String(p.name).startsWith("TODO"))) {
27594
+ for (const p of plans) stripe.push({ ...entry, name: unique(names, `${String(entry.name).replace(/\)$/, "")}: ${String(p.name)})`), plan: String(p.name) });
27595
+ names.delete(String(entry.name));
27596
+ } else stripe.push(entry);
27429
27597
  }
27430
27598
  const emails = [];
27431
27599
  const pages = [];
@@ -27445,7 +27613,8 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27445
27613
  }
27446
27614
  if (PAGE_FILE.test(rel2)) {
27447
27615
  const kind = /checkout|subscribe/i.test(rel2) ? "checkout" : "pricing";
27448
- pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor() });
27616
+ const src = pageSource(root, rel2);
27617
+ pages.push({ name: unique(names, `Page: ${rel2}`), file: rel2, kind, ...planFor("page"), ...src ? { source: src } : {} });
27449
27618
  found.push(`${rel2}: ${kind} page`);
27450
27619
  return;
27451
27620
  }
@@ -27465,6 +27634,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
27465
27634
  if (!disclosures.length && det.payButtons.length) {
27466
27635
  todos.push(`The renewal disclosure lives in code (${det.payButtons[0].file}); keep its text in a .txt file the component imports and add it under checks.disclosures, or check the rendered page under checks.pages`);
27467
27636
  }
27637
+ if (planTodo) todos.push('plan: pick the plan of each disclosure, page and email entry marked TODO ("all" for a page that shows every plan)');
27468
27638
  const checks = {};
27469
27639
  if (stripe.length) checks.stripe_checkout = stripe;
27470
27640
  if (disclosures.length) checks.disclosures = disclosures;
@@ -27737,7 +27907,7 @@ function moduleErrorMessage(stderr, dir, flags = []) {
27737
27907
  const first2 = lines.find((l) => /^(?:[A-Z]\w*)?Error\b.*:/.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "";
27738
27908
  return first2.replace(/file:\/\/\S+/g, (u) => rel2(u)).replace(/\s+/g, " ").slice(0, 400);
27739
27909
  }
27740
- function paramsFromSource(text, file, call = 1, env2 = {}) {
27910
+ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
27741
27911
  const calls = [...text.matchAll(/checkout\.sessions\.create\s*\(/g)];
27742
27912
  if (!calls.length) throw new InputError(`${file} has no stripe.checkout.sessions.create( call`);
27743
27913
  const m = calls[call - 1];
@@ -27745,7 +27915,7 @@ function paramsFromSource(text, file, call = 1, env2 = {}) {
27745
27915
  const line2 = text.slice(0, m.index).split("\n").length;
27746
27916
  const lit = sessionLiteral(text, m.index + m[0].length);
27747
27917
  if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
27748
- const parsed = parseObjectLiteral(lit, env2);
27918
+ const parsed = parseObjectLiteral(lit, env2, consts);
27749
27919
  if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
27750
27920
  return { params: parsed.value, line: line2, unresolved: parsed.unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
27751
27921
  }
@@ -27849,7 +28019,8 @@ async function stripeJob(loaded, e, o, root) {
27849
28019
  } else {
27850
28020
  const src = e.source;
27851
28021
  job.inputs.push(src);
27852
- const r = paramsFromSource(readRel(dir, src), src, e.call ?? 1, projectEnv(dir));
28022
+ const srcText = readRel(dir, src);
28023
+ const r = paramsFromSource(srcText, src, e.call ?? 1, projectEnv(dir), constantsFor(dir, src, srcText));
27853
28024
  job.target = { file: rel(loaded, root, src), line: r.line };
27854
28025
  job.fromSource = true;
27855
28026
  params = r.params;
@@ -27861,9 +28032,16 @@ async function stripeJob(loaded, e, o, root) {
27861
28032
  }
27862
28033
  }
27863
28034
  if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
28035
+ const items0 = Array.isArray(params.line_items) ? params.line_items : [];
28036
+ if (!e.plan && (config.plans?.length ?? 0) > 1 && !items0.some((li) => li && typeof li === "object" && li.price_data)) {
28037
+ throw new InputError(`"${e.name}" does not say which plan it sells, and its price is a Price id or an expression Checkbox cannot read: add "plan": one of ${(config.plans ?? []).map((p) => JSON.stringify(p.name)).join(", ")} to this entry (one entry per plan when one call serves several plans).`);
28038
+ }
27864
28039
  const merchant = planTerms(config, pickPlan(config, e.plan));
27865
28040
  const drift = planDrift(params, pickPlan(config, e.plan));
27866
28041
  if (drift) job.warnings.push(drift);
28042
+ const soldPlan = pickPlan(config, e.plan);
28043
+ const overrides = soldPlan ? planAsSold(params, soldPlan) : void 0;
28044
+ if (soldPlan && overrides) job.sold = { plan: soldPlan.name, overrides };
27867
28045
  const items = Array.isArray(params.line_items) ? params.line_items : [];
27868
28046
  if (items.length && items.every((li) => li && typeof li === "object" && li.price_data)) for (const k of ["price", "amount", "currency", "interval", "interval_count"]) delete merchant[k];
27869
28047
  job.body = { params, ...Object.keys(merchant).length ? { merchant } : {}, ...jurisdictionsOf(config) };
@@ -27872,6 +28050,29 @@ async function stripeJob(loaded, e, o, root) {
27872
28050
  }
27873
28051
  return job;
27874
28052
  }
28053
+ function planAsSold(params, plan2) {
28054
+ const sd = params.subscription_data ?? {};
28055
+ const trial = typeof sd.trial_period_days === "number" ? sd.trial_period_days : void 0;
28056
+ const out = {};
28057
+ if (trial !== void 0 && trial !== (plan2.trial_days ?? 0)) out.trial_days = trial;
28058
+ if (trial === void 0 && plan2.trial_days && plan2.trial_days > 0 && sd.trial_end === void 0) out.trial_days = 0;
28059
+ const items = Array.isArray(params.line_items) ? params.line_items : [];
28060
+ const pd = items.map((li) => li && typeof li === "object" ? li.price_data : void 0).find((x) => x && typeof x === "object");
28061
+ const rec = pd && typeof pd.recurring === "object" ? pd.recurring : void 0;
28062
+ if (rec && typeof rec.interval === "string" && rec.interval !== plan2.interval && ["day", "week", "month", "year"].includes(rec.interval)) out.interval = rec.interval;
28063
+ const price3 = typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
28064
+ if (pd && typeof pd.unit_amount === "number" && price3 !== void 0 && /^(?:usd|eur|gbp|cad|aud|nzd)$/i.test(String(pd.currency ?? plan2.currency ?? "usd")) && (!pd.currency || pd.currency === (plan2.currency ?? "usd")) && Math.abs(pd.unit_amount - Math.round(price3 * 100)) >= 1) {
28065
+ out.price = pd.unit_amount / 100;
28066
+ }
28067
+ return Object.keys(out).length ? out : void 0;
28068
+ }
28069
+ function describeSold(o) {
28070
+ const parts = [];
28071
+ if (o.trial_days !== void 0) parts.push(o.trial_days ? `a ${o.trial_days}-day trial` : "no trial");
28072
+ const charge = [o.price !== void 0 ? Number(o.price).toFixed(2) : "", o.interval ? `every ${o.interval}` : ""].filter(Boolean).join(" ");
28073
+ if (charge) parts.push(charge);
28074
+ return parts.join(", ");
28075
+ }
27875
28076
  function planDrift(params, plan2) {
27876
28077
  if (!plan2) return void 0;
27877
28078
  const sd = params.subscription_data ?? {};
@@ -27887,7 +28088,7 @@ function planDrift(params, plan2) {
27887
28088
  if (pd && typeof pd.unit_amount === "number" && price3 !== void 0 && /^(?:usd|eur|gbp|cad|aud|nzd)$/i.test(String(pd.currency ?? plan2.currency ?? "usd")) && (!pd.currency || pd.currency === (plan2.currency ?? "usd")) && Math.abs(pd.unit_amount - Math.round(price3 * 100)) >= 1) {
27888
28089
  out.push(`the params charge ${(pd.unit_amount / 100).toFixed(2)}, plan "${plan2.name}" ${price3.toFixed(2)}`);
27889
28090
  }
27890
- return out.length ? `The params and checkbox.json disagree: ${out.join("; ")}. The checks use the params; update "plans" in checkbox.json so the required emails and pages are checked for the plan you really sell.` : void 0;
28091
+ return out.length ? `The params and checkbox.json disagree: ${out.join("; ")}. The Checkout check uses the params, and the emails, pages and disclosures of plan "${plan2.name}" are checked against both until they agree: update "plans" in checkbox.json to the plan you really sell.` : void 0;
27891
28092
  }
27892
28093
  function disclosureJob(loaded, e, root) {
27893
28094
  const { config, dir } = loaded;
@@ -27993,9 +28194,35 @@ async function emailJob(loaded, e, o, root, planName = e.plan, name = e.name) {
27993
28194
  }
27994
28195
  async function resolveJobs(loaded, o) {
27995
28196
  const root = o.root ?? loaded.dir;
28197
+ const jobs = [];
28198
+ for (const e of loaded.config.checks.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
28199
+ const dependent = await planJobs(loaded, o, root);
28200
+ jobs.push(...dependent);
28201
+ const seen = /* @__PURE__ */ new Set();
28202
+ for (const sj of jobs.filter((j) => j.type === "stripe_checkout" && j.sold)) {
28203
+ const key = JSON.stringify(sj.sold);
28204
+ if (seen.has(key)) continue;
28205
+ seen.add(key);
28206
+ const { plan: planName, overrides } = sj.sold;
28207
+ const config = { ...loaded.config, plans: (loaded.config.plans ?? []).map((p) => p.name === planName ? { ...p, ...overrides } : p) };
28208
+ const variant = await planJobs({ ...loaded, config }, o, root);
28209
+ const sold = describeSold(overrides);
28210
+ variant.forEach((v, i) => {
28211
+ const orig = dependent[i];
28212
+ if (orig.error || orig.skip || !v.body || JSON.stringify(v.body) === JSON.stringify(orig.body)) return;
28213
+ jobs.push({
28214
+ ...v,
28215
+ name: `${orig.name} (as "${sj.name}" sells it: ${sold})`,
28216
+ inputs: [.../* @__PURE__ */ new Set([...v.inputs, ...sj.inputs])],
28217
+ warnings: [`The params and checkbox.json disagree: "${sj.name}" sells plan "${planName}" with ${sold}, which checkbox.json does not say. This check judges ${orig.name} against what the params sell; the same check against checkbox.json runs too, and both must pass. Update "plans" in checkbox.json (or the params) so they agree.`, ...v.warnings]
28218
+ });
28219
+ });
28220
+ }
28221
+ return jobs;
28222
+ }
28223
+ async function planJobs(loaded, o, root) {
27996
28224
  const c = loaded.config.checks;
27997
28225
  const jobs = [];
27998
- for (const e of c.stripe_checkout ?? []) jobs.push(await stripeJob(loaded, e, o, root));
27999
28226
  for (const e of c.disclosures ?? []) jobs.push(disclosureJob(loaded, e, root));
28000
28227
  for (const e of c.pages ?? []) jobs.push(pageJob(loaded, e, o, root));
28001
28228
  for (const e of c.emails ?? []) {
@@ -28555,7 +28782,7 @@ function onlineEngine(client) {
28555
28782
  return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
28556
28783
  }
28557
28784
  function ciVocabulary(text) {
28558
- return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored").replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
28785
+ return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored").replace(/comes from merchant\.price and is not verified against the Price\. Connect Stripe on the site, or register the params with createStripeCheckoutConfig on a site whose Stripe account has this Price\./g, "comes from the plan's price in checkbox.json and is not verified against the Price. Connect Stripe on the site in Checkbox, then run checkbox ci --online (CHECKBOX_SECRET_KEY) so Checkbox reads the Price.").replace(/comes from merchant\.price\b/g, "comes from the plan's price in checkbox.json").replace(/\bmerchant\.price says\b/g, "The plan's price in checkbox.json says").replace(/\(or pass merchant\.cancel_url\)/g, "(or set merchant.cancel_url in checkbox.json)").replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
28559
28786
  }
28560
28787
  function pricingPageFixes(fixes) {
28561
28788
  const reworded = fixes.map(
@@ -28565,10 +28792,18 @@ function pricingPageFixes(fixes) {
28565
28792
  const renewalLineFor = /* @__PURE__ */ new Set();
28566
28793
  for (const f of reworded) {
28567
28794
  const card = /^(Plan card [^:]+): /.exec(f)?.[1] ?? "";
28568
- const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price)/.test(f);
28795
+ const isRenewalLine = /\b(?:Add one line under the price|Put the renewal line under the price|Move the renewal line under the price|Add your own renewal line next to the (?:pay|plan's) button)/.test(f);
28569
28796
  if (isRenewalLine) {
28570
- if (renewalLineFor.has(card)) continue;
28571
- renewalLineFor.add(card);
28797
+ const cards = [...card.matchAll(/“([^”]+)”/g)].map((m) => m[1]);
28798
+ const fresh = cards.filter((c) => !renewalLineFor.has(c));
28799
+ if (cards.length ? !fresh.length : renewalLineFor.has(card)) continue;
28800
+ if (!cards.length) renewalLineFor.add(card);
28801
+ for (const c of fresh) renewalLineFor.add(c);
28802
+ if (cards.length && fresh.length < cards.length) {
28803
+ const g = `Plan card ${fresh.map((c) => `\u201C${c}\u201D`).join(", ")}: ${f.slice(card.length + 2)}`;
28804
+ if (!out.includes(g)) out.push(g);
28805
+ continue;
28806
+ }
28572
28807
  }
28573
28808
  if (!out.includes(f)) out.push(f);
28574
28809
  }
@@ -28594,12 +28829,23 @@ function fixesOf(r, onlyNeedsFix = false) {
28594
28829
  failing.sort((a, b) => (a.compliance_status === "needs_fix" ? 0 : 1) - (b.compliance_status === "needs_fix" ? 0 : 1) || (RANK2[a.status] ?? 2) - (RANK2[b.status] ?? 2));
28595
28830
  for (const c of failing) {
28596
28831
  const base = c.compliance?.fix || c.fix || c.compliance?.question?.text || c.explanation || c.title || c.check_id;
28597
- const cards = failingCards(c.explanation);
28832
+ let cards = failingCards(c.explanation);
28833
+ const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
28834
+ if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
28835
+ cards = cards.filter((x) => !plain.includes(x));
28836
+ const standOut = `Plan card ${plain.map((x) => `\u201C${x}\u201D`).join(", ")}: ${STAND_OUT_FIX}`;
28837
+ if (!out.includes(standOut)) out.push(standOut);
28838
+ }
28598
28839
  const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
28599
28840
  if (text && !out.includes(text)) out.push(text);
28600
28841
  }
28601
28842
  return out;
28602
28843
  }
28844
+ function cardClauses(explanation) {
28845
+ if (!explanation) return [];
28846
+ const ms = [...explanation.matchAll(/plan button “([^”]{1,80})”(?: in the card “([^”]{1,80})”)?/g)];
28847
+ return ms.map((m, i) => ({ card: m[2] ?? m[1], clause: explanation.slice(m.index + m[0].length, ms[i + 1]?.index ?? explanation.length) }));
28848
+ }
28603
28849
  function failingCards(explanation) {
28604
28850
  if (!explanation) return [];
28605
28851
  if (/\(\d+ checkout page views? checked\) — e\.g\./.test(explanation)) return [];
@@ -28768,18 +29014,29 @@ async function runJob(job, engine, config, opts = {}) {
28768
29014
  engine.mode === "offline" ? `The intro price is declared in checkbox.json, not verified: offline, Checkbox cannot read ${what} from Stripe. Run with --online (CHECKBOX_SECRET_KEY, and the site's Stripe key connected) so Checkbox reads it and compares its percent / amount off and duration with the declared intro.` : `The intro price is declared in checkbox.json, not verified: Checkbox could not read ${what} through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?). Until it is read, its percent / amount off and duration are not compared with the declared intro.`
28769
29015
  );
28770
29016
  }
28771
- const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon ? "needs_review" : status;
29017
+ const sharedReview = !unread && !declaredCoupon && status === "pass" && job.type === "stripe_checkout" ? stripeParamsVerdict(report) : void 0;
29018
+ const unreadCoupon = sharedReview?.verdict === "needs_review" && sharedReview.reason ? sharedReview.reason : void 0;
29019
+ if (unreadCoupon) {
29020
+ const id = /discounts use (coupon|promotion code) (\S+?),/.exec(unreadCoupon);
29021
+ const what = id ? `${id[1]} ${id[2]}` : "the coupon";
29022
+ fixes.unshift(
29023
+ engine.mode === "offline" ? `The params apply ${what}, which Checkbox cannot read offline, so its discount and duration (what the shopper is charged first) are not checked. Declare the intro on the plan in checkbox.json (intro_percent_off, intro_amount_off or intro_price, with intro_periods) and state it in custom_text.submit, or run with --online so Checkbox reads the coupon.` : `The params apply ${what}, which Checkbox could not read through the site's connected Stripe key (is the site known, via --site or CHECKBOX_SITE, and does the key have Coupons: Read?), so its discount and duration are not checked. Declare the intro on the plan in checkbox.json and state it in custom_text.submit, or let Checkbox read the coupon.`
29024
+ );
29025
+ }
29026
+ const finalStatus = unread ? literal.length ? "fail" : "needs_review" : declaredCoupon || unreadCoupon ? "needs_review" : status;
28772
29027
  const corrected = unread ? void 0 : correctedOf(job.type, report, job.partial, job.placeholders);
28773
29028
  if (corrected?.format === "json" && job.placeholders) {
28774
29029
  const unset = job.warnings.filter((w) => /(?:was|were) not set when Checkbox ran/.test(w));
28775
29030
  if (unset.length) Object.assign(base, { alerts: [...unset, ...base.alerts ?? []] });
28776
29031
  }
28777
- const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || status === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
29032
+ const blocking = unread || status === "needs_review" && (job.type === "page" || job.type === "disclosure") && (report.compliance_status === "incomplete" || report.compliance_status === "not_checked") || // Round 31 (pilot 30 item 2, headless-agent-pr false pass: a "strict" entry with a declared-but-unverified coupon exited
29033
+ // 0): the entry's strictness applies to the status the check reports (finalStatus), not the engine's raw verdict.
29034
+ finalStatus === "needs_review" && job.type === "stripe_checkout" && (!!job.strict || !!offer);
28778
29035
  return {
28779
29036
  ...base,
28780
29037
  status: finalStatus,
28781
29038
  ...blocking ? { blocking: true } : {},
28782
- label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
29039
+ label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : unreadCoupon ? "Needs review \xB7 coupon not read" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
28783
29040
  ...fixes[0] ? { fix: fixes[0] } : {},
28784
29041
  fixes,
28785
29042
  ...corrected ? { corrected } : {},
@@ -28850,7 +29107,7 @@ async function runCi(jobs, engine, opts) {
28850
29107
  disclaimer: CI_DISCLAIMER
28851
29108
  };
28852
29109
  }
28853
- var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
29110
+ var ENGINE_KIND, CI_DISCLAIMER, RANK2, OK_STATUSES, STAND_OUT_FIX, NOTICE_TO_EMAIL, EMAIL_LABEL, DEFAULT_LABEL, ALERT_CODES;
28854
29111
  var init_runner = __esm({
28855
29112
  "src/ci/runner.ts"() {
28856
29113
  "use strict";
@@ -28858,6 +29115,7 @@ var init_runner = __esm({
28858
29115
  init_http();
28859
29116
  init_src();
28860
29117
  init_types2();
29118
+ init_stripe_verdict();
28861
29119
  init_config3();
28862
29120
  ENGINE_KIND = {
28863
29121
  stripe_checkout: "stripe_checkout_session",
@@ -28869,6 +29127,7 @@ var init_runner = __esm({
28869
29127
  CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
28870
29128
  RANK2 = { fail: 0, needs_review: 1 };
28871
29129
  OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
29130
+ STAND_OUT_FIX = "Make its renewal line stand out from the text around it: larger type, bold (weight 600 or more), a contrasting colour, or a border.";
28872
29131
  NOTICE_TO_EMAIL = {
28873
29132
  acknowledgment: "acknowledgment",
28874
29133
  trial_ending: "trial_reminder",
@@ -29884,6 +30143,16 @@ var init_device = __esm({
29884
30143
  import { execFileSync as execFileSync3 } from "node:child_process";
29885
30144
  import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
29886
30145
  import { dirname as dirname8, join as join10 } from "node:path";
30146
+ function pinnedRelease(body) {
30147
+ const b = body;
30148
+ if (!b || typeof b.version !== "string" || !/^\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(b.version)) return void 0;
30149
+ if (typeof b.integrity !== "string" || !/^sha384-[A-Za-z0-9+/]{64}$/.test(b.integrity)) return void 0;
30150
+ return { version: b.version, integrity: b.integrity };
30151
+ }
30152
+ function snippetScriptTag(s2, jsx = false, attrs = "") {
30153
+ const pin = s2.pinned ? ` integrity="${s2.pinned.integrity}" ${jsx ? "crossOrigin" : "crossorigin"}="anonymous"` : "";
30154
+ return `<script${attrs} async src="${srcOf(s2)}"${pin} data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`;
30155
+ }
29887
30156
  function read(root, rel2) {
29888
30157
  try {
29889
30158
  return readFileSync11(join10(root, rel2), "utf8");
@@ -29892,19 +30161,20 @@ function read(root, rel2) {
29892
30161
  }
29893
30162
  }
29894
30163
  function srcOf(s2) {
29895
- return `${s2.apiBase.replace(/\/+$/, "")}/s.js`;
30164
+ const api = s2.apiBase.replace(/\/+$/, "");
30165
+ return s2.pinned ? `${api}/s/${s2.pinned.version}.js` : `${api}/s.js`;
29896
30166
  }
29897
30167
  function jsxBlock(s2, indent) {
29898
30168
  return [
29899
30169
  `${indent}{/* ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet) */}`,
29900
- `${indent}<script async src="${srcOf(s2)}" data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`,
30170
+ `${indent}${snippetScriptTag(s2, true)}`,
29901
30171
  `${indent}{/* ${MARK_END} */}`
29902
30172
  ];
29903
30173
  }
29904
30174
  function htmlBlock(s2, indent, attrs = "") {
29905
30175
  return [
29906
30176
  `${indent}<!-- ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet) -->`,
29907
- `${indent}<script${attrs} async src="${srcOf(s2)}" data-key="${s2.snippetKey}" data-api="${s2.apiBase.replace(/\/+$/, "")}"></script>`,
30177
+ `${indent}${snippetScriptTag(s2, false, attrs)}`,
29908
30178
  `${indent}<!-- ${MARK_END} -->`
29909
30179
  ];
29910
30180
  }
@@ -29963,7 +30233,7 @@ function planSnippet(d, s2) {
29963
30233
  const root = d.root;
29964
30234
  const file = d.layoutFile;
29965
30235
  const manual = `Add this tag inside <head> on every page (your root layout / template):
29966
- ${s2.scriptTag}`;
30236
+ ${s2.pinned ? snippetScriptTag(s2) : s2.scriptTag}`;
29967
30237
  if (!file) return { change: null, manual };
29968
30238
  const existing = read(root, file);
29969
30239
  const jsx = (f) => f === "nextjs-app" || f === "nextjs-pages" || f === "remix" || f === "react-router";
@@ -29979,15 +30249,15 @@ function planSnippet(d, s2) {
29979
30249
  }
29980
30250
  return { change: null, manual };
29981
30251
  }
29982
- if (existing.includes(`data-key="${s2.snippetKey}"`) && existing.includes(MARK_START)) {
29983
- return { change: { file, action: "unchanged", summary: "Snippet already installed", added: [] } };
30252
+ if (existing.includes(`data-key="${s2.snippetKey}"`) && existing.includes(MARK_START) && existing.includes(`src="${srcOf(s2)}"`) && (!s2.pinned || existing.includes(s2.pinned.integrity))) {
30253
+ return { change: { file, action: "unchanged", summary: s2.pinned ? `Snippet already installed (pinned release ${s2.pinned.version} with SRI)` : "Snippet already installed", added: [] } };
29984
30254
  }
29985
30255
  const isJsx = jsx(d.framework);
29986
30256
  const indentRe = isJsx ? d.framework === "nextjs-pages" ? /<Head\b[^>]*>/ : /<head\b[^>]*>/ : /<\/head>/i;
29987
30257
  const indent = isJsx ? indentOf2(existing, indentRe) : indentOf2(existing, /<\/head>/i, " ");
29988
30258
  const block = isJsx ? jsxBlock(s2, indent) : htmlBlock(s2, indent, d.framework === "astro" ? " is:inline" : "");
29989
30259
  const replaced = replaceMarked(existing, block);
29990
- if (replaced !== null) return { change: { file, action: "modify", summary: "Snippet tag updated (new site key)", contents: replaced, added: block } };
30260
+ if (replaced !== null) return { change: { file, action: "modify", summary: existing.includes(`data-key="${s2.snippetKey}"`) ? s2.pinned ? `Snippet tag pinned to release ${s2.pinned.version} with SRI` : "Snippet tag updated" : "Snippet tag updated (new site key)", contents: replaced, added: block } };
29991
30261
  let next = null;
29992
30262
  switch (d.framework) {
29993
30263
  case "nextjs-app": {
@@ -30018,7 +30288,7 @@ ${indentOf2(existing, /<Head\s*\/>/, "")}</Head>`) : null);
30018
30288
  }
30019
30289
  if (!next) return { change: null, manual: `Could not find <head> in ${file}. ${manual}` };
30020
30290
  if (!snippetPlacedSafely(next, isJsx)) return { change: null, manual: `Could not place the snippet safely in ${file} (its <head> is not where Checkbox can patch it without breaking the file), so ${file} was NOT changed. ${manual}` };
30021
- return { change: { file, action: "modify", summary: `Snippet added to <head> in ${file}`, contents: next, added: block } };
30291
+ return { change: { file, action: "modify", summary: `Snippet added to <head> in ${file}${s2.pinned ? ` (pinned release ${s2.pinned.version} with SRI)` : ""}`, contents: next, added: block } };
30022
30292
  }
30023
30293
  function envVarNames(d) {
30024
30294
  return {
@@ -30126,7 +30396,7 @@ ${jsxBlock(s2, " ").join("\n")}
30126
30396
  NUXT_PLUGIN = (s2) => `// ${MARK_START} \u2014 Checkbox subscription-compliance monitoring (https://checkbox.site/docs/snippet)
30127
30397
  export default defineNuxtPlugin(() => {
30128
30398
  useHead({
30129
- script: [{ src: '${srcOf(s2)}', async: true, 'data-key': '${s2.snippetKey}', 'data-api': '${s2.apiBase.replace(/\/+$/, "")}' }],
30399
+ script: [{ src: '${srcOf(s2)}', async: true,${s2.pinned ? ` integrity: '${s2.pinned.integrity}', crossorigin: 'anonymous',` : ""} 'data-key': '${s2.snippetKey}', 'data-api': '${s2.apiBase.replace(/\/+$/, "")}' }],
30130
30400
  });
30131
30401
  });
30132
30402
  // ${MARK_END}
@@ -30227,7 +30497,7 @@ async function syncCheckoutConfigs(client, site, sessions, root) {
30227
30497
  const call = (perFile.get(s2.file) ?? 0) + 1;
30228
30498
  perFile.set(s2.file, call);
30229
30499
  if (!registrable(s2.params, s2.unresolved)) {
30230
- out.push({ file: s2.file, line: s2.line, skipped: s2.unresolved.length ? `values not readable statically: ${s2.unresolved.slice(0, 5).join(", ")}` : "not a subscription-mode Checkout Session" });
30500
+ out.push({ file: s2.file, line: s2.line, skipped: s2.unresolved.length ? `values not readable statically: ${s2.unresolved.slice(0, 5).join(", ")} (a variable, call or template string). Next: register the exact params your server sends with createStripeCheckoutConfig (POST /v1/sites/{site}/stripe_checkout_configs), or, once Stripe is connected, create one Checkout Session in Stripe test mode so Checkbox reads it` : "not a subscription-mode Checkout Session" });
30231
30501
  continue;
30232
30502
  }
30233
30503
  try {
@@ -30265,6 +30535,13 @@ function configLines(configs, now = Date.now()) {
30265
30535
  };
30266
30536
  return ["Checkout params registered:", ...configs.map((c) => c.id ? ` ${c.file}:${c.line} \u2192 ${c.id}${age(c.registered_at)}${c.replaced?.length ? ` (replaced ${c.replaced.length} older config${c.replaced.length === 1 ? "" : "s"})` : ""}` : ` ${c.file}${c.line ? `:${c.line}` : ""}: ${c.skipped}`)];
30267
30537
  }
30538
+ async function snippetRelease(client) {
30539
+ try {
30540
+ return pinnedRelease(await client.get("/s/latest.json"));
30541
+ } catch {
30542
+ return void 0;
30543
+ }
30544
+ }
30268
30545
  function registrable(params, unresolved) {
30269
30546
  if (!params) return false;
30270
30547
  if (unresolved.some((u) => /^(mode|line_items|subscription_data|custom_text|consent_collection)\b/.test(u))) return false;
@@ -30364,7 +30641,7 @@ var init_init = __esm({
30364
30641
  init_patch2();
30365
30642
  VERSION_FLAGS = {
30366
30643
  string: ["site-url", "checkout-url", "account-url", "terms-url", "privacy-url", "refund-url", "cancellation-url", "wait-seconds", "agent", "support-email", "merchant-name", "plan"],
30367
- boolean: ["yes", "dry-run", "open", "wait", "verify", "complete", "live", "stripe", "snippet", "relogin"],
30644
+ boolean: ["yes", "dry-run", "open", "wait", "verify", "complete", "live", "stripe", "snippet", "pin", "relogin"],
30368
30645
  alias: { y: "yes" }
30369
30646
  };
30370
30647
  interactive = (ctx) => !ctx.flags.json && !!process.stdin.isTTY && !!process.stderr.isTTY;
@@ -30376,8 +30653,11 @@ var init_init = __esm({
30376
30653
  "Run it in the project root. What it does (idempotent; re-run any time):",
30377
30654
  " 1. detects the framework, billing platform, checkout code and pay buttons",
30378
30655
  ' 2. signs you in: prints ONE link ("Open \u2026 and click Approve") and opens the browser; new accounts are free',
30379
- " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it)",
30380
- " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments)",
30656
+ " 3. writes CHECKBOX_SECRET_KEY and the publishable key to .env.local / .env (and git-ignores it); when that file is",
30657
+ " tracked by git, or the folder is deployed as it is, the key is not written to it and stays only in the CLI config",
30658
+ " (~/.config/checkbox/config.json), which the output says",
30659
+ " 4. installs the Checkbox snippet in the root layout (marked with checkbox:snippet comments): the pinned release",
30660
+ " (/s/<version>.js with integrity= and crossorigin=, Subresource Integrity); --no-pin uses the unpinned /s.js",
30381
30661
  " 5. registers the site, Terms / refund / cancellation policy URLs (fetched and checked by Checkbox)",
30382
30662
  " 6. with your permission, uses the project's Stripe restricted TEST key (rk_test_\u2026; sk_test_ and live keys are refused) for a synthetic test subscription",
30383
30663
  " 7. runs the first checks and prints the next step (`checkbox plan --json`); the human gets the summary email",
@@ -30623,7 +30903,9 @@ var init_init = __esm({
30623
30903
  if (session.install.script_tag && session.install.snippet_key) {
30624
30904
  if (bool(flags, "snippet") === false) manualSnippet = session.install.script_tag;
30625
30905
  else {
30626
- const plan2 = planSnippet(d, { scriptTag: session.install.script_tag, snippetKey: session.install.snippet_key, apiBase });
30906
+ const pinned = bool(flags, "pin") === false ? void 0 : await snippetRelease(client);
30907
+ if (!pinned && bool(flags, "pin") !== false) warnings.push("The pinned snippet release (/s/latest.json) could not be read, so the unpinned /s.js tag was used. Run `checkbox init` again to pin it with Subresource Integrity.");
30908
+ const plan2 = planSnippet(d, { scriptTag: session.install.script_tag, snippetKey: session.install.snippet_key, apiBase, ...pinned ? { pinned } : {} });
30627
30909
  if (plan2.change) changes.push(plan2.change);
30628
30910
  if (plan2.manual) manualSnippet = plan2.manual;
30629
30911
  }
package/package.json CHANGED
@@ -1,32 +1,35 @@
1
1
  {
2
2
  "name": "@checkbox-site/cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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
+ "checkbox",
6
7
  "subscription",
7
8
  "subscription-compliance",
8
- "subscription compliance",
9
9
  "auto-renewal",
10
- "auto renewal",
11
- "autorenewal",
12
10
  "automatic-renewal",
11
+ "auto-renewal-law",
13
12
  "compliance",
14
13
  "rosca",
15
- "click-to-cancel",
16
14
  "california-arl",
17
- "auto-renewal-law",
15
+ "click-to-cancel",
16
+ "stripe",
17
+ "stripe-checkout",
18
+ "ci",
19
+ "github-actions",
20
+ "cli",
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "ai-agent",
24
+ "subscription compliance",
25
+ "auto renewal",
26
+ "autorenewal",
18
27
  "auto-renewal-compliance",
19
28
  "subscription-cancellation",
20
29
  "renewal-reminder",
21
30
  "stripe-subscriptions",
22
31
  "ftc",
23
- "negative-option",
24
- "stripe",
25
- "stripe-checkout",
26
- "mcp",
27
- "ci",
28
- "github-actions",
29
- "cli"
32
+ "negative-option"
30
33
  ],
31
34
  "license": "MIT",
32
35
  "author": "Checkbox <support@checkbox.site>",