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