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