@checkbox-site/cli 0.4.7 → 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 +804 -208
- 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
|
*/
|
|
@@ -3295,7 +3296,7 @@ var init_operations = __esm({
|
|
|
3295
3296
|
/**
|
|
3296
3297
|
* Store a billing test-mode key.
|
|
3297
3298
|
*
|
|
3298
|
-
* Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); the owner must have ticked
|
|
3299
|
+
* Stores the site's Stripe TEST-mode restricted key (rk_test_…), after checking it with Stripe the way the dashboard's Connect Stripe does. An agent may call this itself once the owner approved the setup; every store is logged on the site's setup timeline and Billing connection line, and the human can revoke it in one click. Live keys are refused (test_key_not_test_mode: a human adds live keys in the dashboard); the owner must have ticked the box “Let your agent store a Stripe test-mode restricted key for this project” (agent sign-in) or “Let this app store a Stripe test-mode restricted key for your sites” (app connection) (checkbox id consent-stripe-test-key) on the approval page of the agent's sign-in or connection (else test_key_declined); a key whose Stripe account looks like another site's is refused with billing_account_mismatch (with confirm_account_mismatch: true the key is connected on hold until a person confirms the account in the dashboard; only a person can confirm it); secret keys (sk_test_…) are refused (test_key_not_restricted). On a test-mode site without a Stripe connection the key also becomes the site's Stripe connection: one key for checks, fixes (only after the human approves each) and test runs. The key's checklist: Customers: Write, Products: Read, Prices: Read, Coupons: Read, Subscriptions: Write, Test clocks: Write, Customer portal: Write, Checkout Sessions: Write, Payment Links: Write, Webhook Endpoints: Write. Replaces an existing key. Next: createTestRun.
|
|
3299
3300
|
*
|
|
3300
3301
|
* @param site Unique identifier of the site (site_…).
|
|
3301
3302
|
* @see POST /v1/sites/{site}/test_credentials (operation `createTestCredential`, scope `test_runs:write`)
|
|
@@ -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
|
|
|
@@ -4757,12 +4758,16 @@ var init_stripe_ids = __esm({
|
|
|
4757
4758
|
function stripeKeyPermissionLine(filter) {
|
|
4758
4759
|
return STRIPE_KEY_PERMISSIONS.filter(filter ?? (() => true)).map((p) => `${p.resource}: ${p.access}`).join(", ");
|
|
4759
4760
|
}
|
|
4761
|
+
function testKeyBoxName(via = "either") {
|
|
4762
|
+
const label = via === "device" ? `\u201C${DEVICE_TEST_KEY_LABEL}\u201D` : via === "oauth" ? `\u201C${OAUTH_TEST_KEY_LABEL}\u201D` : `\u201C${DEVICE_TEST_KEY_LABEL}\u201D (agent sign-in) or \u201C${OAUTH_TEST_KEY_LABEL}\u201D (app connection)`;
|
|
4763
|
+
return `the box ${label} (checkbox id ${TEST_KEY_CHECKBOX_ID})`;
|
|
4764
|
+
}
|
|
4760
4765
|
function approvalKeyAsk(opts = {}) {
|
|
4761
4766
|
if (opts.stripe === false) return "";
|
|
4762
4767
|
const create = `create a restricted key in Stripe Test mode (${STRIPE_TEST_API_KEYS_URL} \u2192 Create restricted key) ticking ${stripeKeyPermissionLine()}, and put it in the project's env file (never in the chat)`;
|
|
4763
|
-
return `${opts.stripe ? "In the same message" : "If the site sells through Stripe, in the same message"}, ask them to tick
|
|
4768
|
+
return `${opts.stripe ? "In the same message" : "If the site sells through Stripe, in the same message"}, ask them to tick ${testKeyBoxName("device")} on that page${opts.keyFound ? "" : ` and, if the project has no rk_test_ key yet, to ${create}`}: one approval then covers the key and the test subscription.`;
|
|
4764
4769
|
}
|
|
4765
|
-
var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL;
|
|
4770
|
+
var STRIPE_KEY_PERMISSIONS, STRIPE_TEST_API_KEYS_URL, DEVICE_TEST_KEY_LABEL, OAUTH_TEST_KEY_LABEL, TEST_KEY_CHECKBOX_ID;
|
|
4766
4771
|
var init_stripe_key = __esm({
|
|
4767
4772
|
"../core/src/stripe-key.ts"() {
|
|
4768
4773
|
"use strict";
|
|
@@ -4779,6 +4784,9 @@ var init_stripe_key = __esm({
|
|
|
4779
4784
|
{ resource: "Webhook Endpoints", access: "Write", why: "Registers Checkbox\u2019s webhook endpoint, so renewals and cancellations are seen as they happen.", uses: ["checks"], writeWhen: "Used once, when you connect the key, to add Checkbox\u2019s own webhook endpoint (and to remove it when you disconnect); nothing else." }
|
|
4780
4785
|
];
|
|
4781
4786
|
STRIPE_TEST_API_KEYS_URL = "https://dashboard.stripe.com/test/apikeys";
|
|
4787
|
+
DEVICE_TEST_KEY_LABEL = "Let your agent store a Stripe test-mode restricted key for this project";
|
|
4788
|
+
OAUTH_TEST_KEY_LABEL = "Let this app store a Stripe test-mode restricted key for your sites";
|
|
4789
|
+
TEST_KEY_CHECKBOX_ID = "consent-stripe-test-key";
|
|
4782
4790
|
}
|
|
4783
4791
|
});
|
|
4784
4792
|
|
|
@@ -4786,16 +4794,25 @@ var init_stripe_key = __esm({
|
|
|
4786
4794
|
function goLiveSteps(input = {}) {
|
|
4787
4795
|
const out = [];
|
|
4788
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." });
|
|
4789
|
-
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 });
|
|
4790
4799
|
if (!input.liveKeyConnected)
|
|
4791
4800
|
out.push({
|
|
4792
4801
|
key: "live_stripe_key",
|
|
4793
4802
|
who: "person",
|
|
4794
|
-
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."}`
|
|
4804
|
+
});
|
|
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
|
|
4795
4811
|
});
|
|
4796
|
-
if (input.checkoutKind === "payment_link")
|
|
4797
|
-
out.push({ key: "live_checkout", who: "person", text:
|
|
4798
|
-
|
|
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")
|
|
4799
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)." });
|
|
4800
4817
|
out.push({
|
|
4801
4818
|
key: "promote_site",
|
|
@@ -4804,12 +4821,15 @@ function goLiveSteps(input = {}) {
|
|
|
4804
4821
|
});
|
|
4805
4822
|
return out;
|
|
4806
4823
|
}
|
|
4807
|
-
var STRIPE_PUBLIC_DETAILS, GO_LIVE_TERMS_URL_STEP, 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;
|
|
4808
4825
|
var init_go_live = __esm({
|
|
4809
4826
|
"../core/src/go-live.ts"() {
|
|
4810
4827
|
"use strict";
|
|
4828
|
+
STRIPE_PORTAL_SETTINGS = "https://dashboard.stripe.com/settings/billing/portal";
|
|
4811
4829
|
STRIPE_PUBLIC_DETAILS = "https://dashboard.stripe.com/settings/public";
|
|
4812
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.';
|
|
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.";
|
|
4813
4833
|
GO_LIVE_STEPS = goLiveSteps().map((s2) => s2.text);
|
|
4814
4834
|
}
|
|
4815
4835
|
});
|
|
@@ -4853,6 +4873,13 @@ var init_secrets = __esm({
|
|
|
4853
4873
|
}
|
|
4854
4874
|
});
|
|
4855
4875
|
|
|
4876
|
+
// ../core/src/site-unreachable.ts
|
|
4877
|
+
var init_site_unreachable = __esm({
|
|
4878
|
+
"../core/src/site-unreachable.ts"() {
|
|
4879
|
+
"use strict";
|
|
4880
|
+
}
|
|
4881
|
+
});
|
|
4882
|
+
|
|
4856
4883
|
// ../core/src/index.ts
|
|
4857
4884
|
var init_src2 = __esm({
|
|
4858
4885
|
"../core/src/index.ts"() {
|
|
@@ -4864,6 +4891,7 @@ var init_src2 = __esm({
|
|
|
4864
4891
|
init_stripe_key();
|
|
4865
4892
|
init_go_live();
|
|
4866
4893
|
init_secrets();
|
|
4894
|
+
init_site_unreachable();
|
|
4867
4895
|
}
|
|
4868
4896
|
});
|
|
4869
4897
|
|
|
@@ -5902,7 +5930,7 @@ var init_new_york = __esm({
|
|
|
5902
5930
|
NYC_EFFECTIVE = "2026-10-01";
|
|
5903
5931
|
NYC = "6 RCNY \xA7 5-110.1";
|
|
5904
5932
|
NYC_RULE_VERIFIED = "2026-10-31";
|
|
5905
|
-
NYC_NOTE = "NYC DCWP rule (adopted July 10, 2026; effective Oct. 1, 2026), verified against the adopted text on 2026-10-31. Applies to New York City consumers; evaluated wherever US-NY applies (
|
|
5933
|
+
NYC_NOTE = "NYC DCWP rule (adopted July 10, 2026; effective Oct. 1, 2026), verified against the adopted text on 2026-10-31. Applies to New York City consumers; evaluated wherever US-NY applies (how far the rule reaches outside the city is not settled yet). Exempt (\xA7 5-110.3): municipal franchise holders, DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers.";
|
|
5906
5934
|
PART_W_NOTE = "Current text from L. 2025, ch. 58, Part W, effective Nov. 5, 2025.";
|
|
5907
5935
|
PRE_PART_W = "Prior version: L. 2020, ch. 267 text in force Feb. 9, 2021 - Nov. 4, 2025 (sunset when Part W took effect); kept so evaluations as of earlier dates apply the law then in force.";
|
|
5908
5936
|
NEW_YORK_REQUIREMENTS = [
|
|
@@ -6351,12 +6379,12 @@ var init_new_york = __esm({
|
|
|
6351
6379
|
sourceUrl: NYC_RULE_URL,
|
|
6352
6380
|
effectiveDate: NYC_EFFECTIVE,
|
|
6353
6381
|
lastVerified: NYC_RULE_VERIFIED,
|
|
6354
|
-
summary: "City rule mirroring GBL \xA7 527-a: material terms (including the deadline to act) before consent or billing information; cancellation as easy as consent, in the same medium and every consent medium, with an online option (website or email) for in-person sign-ups; no obstruction or unreasonable save offers; 15-45 day renewal notice (initial term of one year or more renewing for six months or more) and 3-21 day trial notice (trials over a month) in the consumer-selected channel; material-change notice at least 5 business days and no more than 30 days before the change (Checkbox
|
|
6382
|
+
summary: "City rule mirroring GBL \xA7 527-a: material terms (including the deadline to act) before consent or billing information; cancellation as easy as consent, in the same medium and every consent medium, with an online option (website or email) for in-person sign-ups; no obstruction or unreasonable save offers; 15-45 day renewal notice (initial term of one year or more renewing for six months or more) and 3-21 day trial notice (trials over a month) in the consumer-selected channel; material-change notice at least 5 business days and no more than 30 days before the change (Checkbox Notices counts the business days, skipping weekends and public holidays). Violators refund every amount charged after the first cancellation attempt (\xA7 5-110.2); DCWP penalties $525 / $1,050 / $3,500. Checked together with New York State's rules.",
|
|
6355
6383
|
status: "in_force",
|
|
6356
6384
|
confidence: "medium",
|
|
6357
6385
|
verification: "primary",
|
|
6358
6386
|
requirementIdPrefixes: ["US-NY.NYC."],
|
|
6359
|
-
scope: "New York City consumers. Exempt (\xA7 5-110.3): municipal franchise holders (e.g. cable), DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers.
|
|
6387
|
+
scope: "New York City consumers. Exempt (\xA7 5-110.3): municipal franchise holders (e.g. cable), DFS-regulated entities, licensed alarm operators, banks and licensed financial institutions, and service-contract sellers. How far the rule reaches outside the city, and how it works alongside the Attorney General's enforcement of the state law, are not settled yet (hence medium confidence).",
|
|
6360
6388
|
amendments: [
|
|
6361
6389
|
{ citation: "DCWP Notice of Adoption (proposed Apr. 8, 2026; hearing May 8, 2026; adopted July 10, 2026)", effectiveDate: NYC_EFFECTIVE, summary: 'Adds 6 RCNY Part 8 "Click to Cancel" (\xA7\xA7 5-110 to 5-110.3).' }
|
|
6362
6390
|
]
|
|
@@ -10222,11 +10250,11 @@ function findRecurringPrices(text, amounts, freqs) {
|
|
|
10222
10250
|
let best = null;
|
|
10223
10251
|
for (const f of freqs) {
|
|
10224
10252
|
const after = f.index >= money3.index;
|
|
10225
|
-
const
|
|
10226
|
-
if (
|
|
10227
|
-
if (
|
|
10253
|
+
const gap2 = after ? f.index - (money3.index + money3.text.length) : money3.index - (f.index + f.text.length);
|
|
10254
|
+
if (gap2 < -1) continue;
|
|
10255
|
+
if (gap2 > (after ? 45 : 60)) continue;
|
|
10228
10256
|
if (SENTENCE_BREAK_RE.test(between(text, money3, f))) continue;
|
|
10229
|
-
if (!best ||
|
|
10257
|
+
if (!best || gap2 < best.gap) best = { f, gap: gap2 };
|
|
10230
10258
|
}
|
|
10231
10259
|
if (best) {
|
|
10232
10260
|
const start = Math.min(money3.index, best.f.index);
|
|
@@ -11505,10 +11533,10 @@ function withIntroOffers(declared, billing, offers) {
|
|
|
11505
11533
|
}
|
|
11506
11534
|
function shortPlanTerms(p) {
|
|
11507
11535
|
const digits = currencyDigitsOf(p.currency);
|
|
11508
|
-
const
|
|
11536
|
+
const major2 = p.amount / 10 ** digits;
|
|
11509
11537
|
const cur = (p.currency || "usd").toLowerCase();
|
|
11510
11538
|
const sym = { usd: "$", eur: "\u20AC", gbp: "\xA3", jpy: "\xA5" }[cur];
|
|
11511
|
-
const num3 = Number.isInteger(
|
|
11539
|
+
const num3 = Number.isInteger(major2) ? String(major2) : major2.toFixed(digits);
|
|
11512
11540
|
const money3 = sym ? `${sym}${num3}` : `${num3} ${cur.toUpperCase()}`;
|
|
11513
11541
|
const every = (p.intervalCount || 1) > 1 ? ` every ${p.intervalCount} ${p.interval}s` : `/${p.interval}`;
|
|
11514
11542
|
const trial = (p.trialDays ?? 0) > 0 ? ` with a ${p.trialDays}-day free trial` : "";
|
|
@@ -11778,11 +11806,11 @@ function formatMoney(amount, currency) {
|
|
|
11778
11806
|
} catch {
|
|
11779
11807
|
digits = 2;
|
|
11780
11808
|
}
|
|
11781
|
-
const
|
|
11809
|
+
const major2 = amount / 10 ** digits;
|
|
11782
11810
|
try {
|
|
11783
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency: code2, minimumFractionDigits: digits }).format(
|
|
11811
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: code2, minimumFractionDigits: digits }).format(major2);
|
|
11784
11812
|
} catch {
|
|
11785
|
-
return `${
|
|
11813
|
+
return `${major2.toFixed(digits)} ${code2}`;
|
|
11786
11814
|
}
|
|
11787
11815
|
}
|
|
11788
11816
|
function intervalPhrase(interval, count = 1) {
|
|
@@ -12201,18 +12229,15 @@ function checkoutSessionsForSite(sessions, captures, checkoutType) {
|
|
|
12201
12229
|
const ownCardForm = captures.some((c) => OWN_CARD_FORM_RE.test(c.text ?? ""));
|
|
12202
12230
|
if (checkoutType !== "stripe_checkout" && !linksToCheckout && ownCardForm) return [];
|
|
12203
12231
|
if (checkoutType !== "stripe_checkout" && !linksToCheckout && stripePayLinksIn(captures).length) return [];
|
|
12204
|
-
const
|
|
12205
|
-
|
|
12206
|
-
|
|
12207
|
-
if (s2.
|
|
12208
|
-
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;
|
|
12209
12236
|
const key = (s2.lineItems ?? []).map((li) => li.price ?? `${li.amount}|${li.interval}|${li.intervalCount}`).sort().join(",");
|
|
12210
|
-
|
|
12211
|
-
|
|
12212
|
-
|
|
12213
|
-
|
|
12214
|
-
}
|
|
12215
|
-
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);
|
|
12216
12241
|
}
|
|
12217
12242
|
function paymentLinkParams(link, opts = {}) {
|
|
12218
12243
|
const items = (link.lineItems ?? []).filter((li) => li && (li.interval || li.price));
|
|
@@ -13405,7 +13430,7 @@ function checkoutConfigViews(ctx) {
|
|
|
13405
13430
|
return (ctx.checkoutConfigs ?? []).filter((c) => c && c.capture).map((c) => ({
|
|
13406
13431
|
...buildSignupView(c.capture),
|
|
13407
13432
|
label: `${c.kind === "payment_link" ? "Stripe Payment Link" : "Stripe Checkout config"} \u201C${c.name ?? c.id}\u201D`,
|
|
13408
|
-
checkoutConfig: { id: c.id, ...c.name ? { name: c.name } : {}, consentRequired: c.consentRequired, ...c.kind ? { kind: c.kind } : {}, ...c.url ? { url: c.url } : {} },
|
|
13433
|
+
checkoutConfig: { id: c.id, ...c.name ? { name: c.name } : {}, consentRequired: c.consentRequired, ...c.kind ? { kind: c.kind } : {}, ...c.url ? { url: c.url } : {}, ...typeof c.trialDays === "number" ? { trialDays: c.trialDays } : {} },
|
|
13409
13434
|
// A Payment Link is read from Stripe on every billing read: it shows the link as of that read.
|
|
13410
13435
|
...c.kind === "payment_link" && readAt ? { observedAt: readAt } : {}
|
|
13411
13436
|
}));
|
|
@@ -13873,7 +13898,7 @@ function platformKey(platform) {
|
|
|
13873
13898
|
}
|
|
13874
13899
|
function portalUnlinkedFix(portalUrl) {
|
|
13875
13900
|
const login = portalUrl && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(portalUrl) ? portalUrl : "billing.stripe.com/p/login/\u2026";
|
|
13876
|
-
return `Add a \u201CManage subscription\u201D link
|
|
13901
|
+
return `Add a \u201CManage subscription\u201D link that opens your Stripe customer portal (${login}) to your account page, or to your home page or footer if your site has no account page, then run a scan so Checkbox sees it.`;
|
|
13877
13902
|
}
|
|
13878
13903
|
function paymentLinkCancelRemediation(portalUrl, loginPageOn) {
|
|
13879
13904
|
const link = portalUrl && /^https:\/\/billing\.stripe\.com\//.test(portalUrl) ? portalUrl : void 0;
|
|
@@ -14979,6 +15004,18 @@ var init_help_pages = __esm({
|
|
|
14979
15004
|
});
|
|
14980
15005
|
|
|
14981
15006
|
// ../rules/src/checks/cancel-paths.ts
|
|
15007
|
+
function mayOpenPortal(el, pageUrl2) {
|
|
15008
|
+
const href = el.href;
|
|
15009
|
+
if (!href) return true;
|
|
15010
|
+
try {
|
|
15011
|
+
const target = new URL(href, pageUrl2);
|
|
15012
|
+
const page = new URL(pageUrl2);
|
|
15013
|
+
if (target.host !== page.host) return true;
|
|
15014
|
+
return /(?:^|\/)(?:api|portal|billing|billing-portal|create-portal-session|customer-portal)(?:\/|$|[-_?])/i.test(target.pathname);
|
|
15015
|
+
} catch {
|
|
15016
|
+
return true;
|
|
15017
|
+
}
|
|
15018
|
+
}
|
|
14982
15019
|
function inAppPath(input) {
|
|
14983
15020
|
const flow = input.ctx.cancellation?.via === "stripe_portal" ? void 0 : input.ctx.cancellation;
|
|
14984
15021
|
if (flow) {
|
|
@@ -15014,7 +15051,7 @@ function inAppPath(input) {
|
|
|
15014
15051
|
return true;
|
|
15015
15052
|
}
|
|
15016
15053
|
});
|
|
15017
|
-
if (leadsOnline.length) return { kind: "in_app", state: "offered", detail: `your own page offers a cancel option (${p.url})`, url: p.url };
|
|
15054
|
+
if (leadsOnline.length) return { kind: "in_app", state: "offered", detail: `your own page offers a cancel option (${p.url})`, url: p.url, ...leadsOnline.every((el) => mayOpenPortal(el, p.url)) ? { mayOpenPortal: true } : {} };
|
|
15018
15055
|
}
|
|
15019
15056
|
if (input.ctx.facts?.cancelsInApp === true) return { kind: "in_app", state: "unconfirmed", detail: "subscribers cancel in your own app, but Checkbox has not seen a cancellation complete there yet" };
|
|
15020
15057
|
return void 0;
|
|
@@ -15692,6 +15729,16 @@ var init_cancellation = __esm({
|
|
|
15692
15729
|
{ unverified: true, source: "snippet" }
|
|
15693
15730
|
);
|
|
15694
15731
|
}
|
|
15732
|
+
if (controls.length && cfg?.selfServeOnline === false && controls.every((el) => mayOpenPortal(el, page.url))) {
|
|
15733
|
+
return makeResult(
|
|
15734
|
+
id,
|
|
15735
|
+
"needs_review",
|
|
15736
|
+
`A cancel page with an online cancel option (\u201C${quote(controls[0].text)}\u201D) was found at ${page.url}, but cancellation is off in your billing platform's customer portal: if this option opens the portal, subscribers cannot cancel there. Not yet verified by a cancellation test: run a test run, or turn cancellation on in the portal`,
|
|
15737
|
+
[...captureRefs(page), ...configRefs],
|
|
15738
|
+
0.5,
|
|
15739
|
+
{ inAppOffered: true, url: page.url, selfServeOnline: false, portalCancelOff: true }
|
|
15740
|
+
);
|
|
15741
|
+
}
|
|
15695
15742
|
if (controls.length) {
|
|
15696
15743
|
return makeResult(
|
|
15697
15744
|
id,
|
|
@@ -16115,6 +16162,16 @@ var init_config2 = __esm({
|
|
|
16115
16162
|
}
|
|
16116
16163
|
if (c.selfServeOnline === false) {
|
|
16117
16164
|
const flow = input.ctx.cancellation;
|
|
16165
|
+
if (inApp?.state === "offered" && inApp.mayOpenPortal && model.hosted?.state !== "offered" && !(model.offline && !model.offline.soft)) {
|
|
16166
|
+
return makeResult(
|
|
16167
|
+
id,
|
|
16168
|
+
"needs_review",
|
|
16169
|
+
`Self-serve cancellation is off in ${name}'s portal. Your own page offers a cancel option (${inApp.url}), but Checkbox has not followed it to a finished cancellation yet: if it opens the ${name} customer portal, subscribers cannot cancel there. Run a test run (it cancels through your page), or turn cancellation on in the portal`,
|
|
16170
|
+
refs(input),
|
|
16171
|
+
0.6,
|
|
16172
|
+
{ ...data, ownCancelPage: true, inAppOfferedUntested: true, inAppUnobserved: true }
|
|
16173
|
+
);
|
|
16174
|
+
}
|
|
16118
16175
|
if (model.otherOnlineWorks) {
|
|
16119
16176
|
const via = inApp?.state === "works" && flow ? "subscribers cancel online through your own page (a test cancellation completed; see \u201Ccancel online\u201D)" : model.hosted?.state === "offered" ? `subscribers cancel through the Checkbox hosted cancellation page (${model.hosted.url}); see \u201Ccancel online\u201D` : `your own page offers a cancel option (${inApp?.url}); online cancellation is judged on that page (see \u201Ccancel online\u201D), so the portal setting is not required`;
|
|
16120
16177
|
return makeResult(
|
|
@@ -16320,8 +16377,8 @@ function introEmailFinding(rawText, plans, opts) {
|
|
|
16320
16377
|
const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
|
|
16321
16378
|
if (!usable.length) return void 0;
|
|
16322
16379
|
const text = normalizeText(rawText);
|
|
16323
|
-
const
|
|
16324
|
-
const regular = [...usable.map((p2) =>
|
|
16380
|
+
const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
|
|
16381
|
+
const regular = [...usable.map((p2) => major2(p2, p2.amount)), ...opts.extraAmounts ?? []];
|
|
16325
16382
|
const isRegular = (n) => regular.some((r) => Math.abs(r - n) < 5e-3);
|
|
16326
16383
|
const offRegular = (sentence) => findAmounts(sentence).filter((m) => {
|
|
16327
16384
|
if (typeof m.amount !== "number" || !(m.amount > 0) || isRegular(m.amount)) return false;
|
|
@@ -16368,7 +16425,7 @@ function introEmailFinding(rawText, plans, opts) {
|
|
|
16368
16425
|
};
|
|
16369
16426
|
}
|
|
16370
16427
|
const fits = (p2) => {
|
|
16371
|
-
const intro =
|
|
16428
|
+
const intro = major2(p2, p2.introAmount);
|
|
16372
16429
|
const pct = (1 - p2.introAmount / p2.amount) * 100;
|
|
16373
16430
|
return statedAmounts.every((m) => Math.abs(m.amount - intro) < 5e-3) && percents.every((x) => Math.abs(x.value - pct) <= 0.5);
|
|
16374
16431
|
};
|
|
@@ -16397,6 +16454,7 @@ function introEmailFinding(rawText, plans, opts) {
|
|
|
16397
16454
|
fix: `State how long the promo really lasts: ${introTermsPhrase(p2)}.`
|
|
16398
16455
|
};
|
|
16399
16456
|
}
|
|
16457
|
+
if (statesIntroEndDate(text)) return void 0;
|
|
16400
16458
|
const p = fitting[0];
|
|
16401
16459
|
return {
|
|
16402
16460
|
status: "needs_review",
|
|
@@ -16406,29 +16464,32 @@ function introEmailFinding(rawText, plans, opts) {
|
|
|
16406
16464
|
fix: `State how long the promo lasts and the price after it: ${introTermsPhrase(p)}.`
|
|
16407
16465
|
};
|
|
16408
16466
|
}
|
|
16467
|
+
function statesIntroEndDate(text) {
|
|
16468
|
+
return INTRO_END_DATE_RES.some((re) => re.test(text));
|
|
16469
|
+
}
|
|
16409
16470
|
function introFirstChargeFinding(rawText, plans) {
|
|
16410
16471
|
const usable = plans.filter((p2) => p2.amount > 0 && p2.interval);
|
|
16411
16472
|
if (!usable.length) return void 0;
|
|
16412
16473
|
const text = normalizeText(rawText);
|
|
16413
16474
|
const stated = findAmounts(text).filter((m) => typeof m.amount === "number" && m.amount > 0);
|
|
16414
|
-
const
|
|
16475
|
+
const major2 = (p2, minor) => minor / 10 ** currencyDigitsOf(p2.currency);
|
|
16415
16476
|
const states = (n) => stated.some((m) => Math.abs(m.amount - n) < 5e-3);
|
|
16416
|
-
const named = usable.filter((p2) => states(
|
|
16477
|
+
const named = usable.filter((p2) => states(major2(p2, p2.amount)));
|
|
16417
16478
|
if (!named.length || !named.every(hasIntro)) return void 0;
|
|
16418
|
-
if (named.some((p2) => states(
|
|
16479
|
+
if (named.some((p2) => states(major2(p2, p2.introAmount)))) return void 0;
|
|
16419
16480
|
if (new RegExp(PERCENT_OFF_RE.source, "i").test(text)) return void 0;
|
|
16420
16481
|
const p = named[0];
|
|
16421
16482
|
const regular = money(p.amount, p.currency);
|
|
16422
16483
|
const nameOf = p.label?.trim() || (p.name?.trim() ? `plan \u201C${p.name.trim()}\u201D` : `the ${regular} ${planEvery(p)} plan`);
|
|
16423
16484
|
return {
|
|
16424
16485
|
status: "fail",
|
|
16425
|
-
quote: stated.find((m) => Math.abs(m.amount -
|
|
16486
|
+
quote: stated.find((m) => Math.abs(m.amount - major2(p, p.amount)) < 5e-3)?.text ?? regular,
|
|
16426
16487
|
reason: "intro_first_charge",
|
|
16427
16488
|
detail: `states ${regular} as the charge after the trial, but ${nameOf} has an introductory price: the first charge after the trial is ${money(p.introAmount, p.currency)} (${introTermsPhrase(p)}) \u2014 state the intro price for that charge, then the regular price`,
|
|
16428
16489
|
fix: `State the first charge after the trial and the price after it: ${introTermsPhrase(p)}.`
|
|
16429
16490
|
};
|
|
16430
16491
|
}
|
|
16431
|
-
var PERCENT_OFF_RE, DURATION_NUM, DURATION_UNIT, FIRST_DURATION_RE, FOR_DURATION_RE, VAGUE_DURATION_RE, TRIAL_DURATION_AROUND_RE, BILLED_LONGER_RE, INTRO_CUE_RE, NOT_A_PRICE_AROUND_RE, TRIAL_ENDING_RE;
|
|
16492
|
+
var PERCENT_OFF_RE, DURATION_NUM, DURATION_UNIT, FIRST_DURATION_RE, FOR_DURATION_RE, VAGUE_DURATION_RE, TRIAL_DURATION_AROUND_RE, BILLED_LONGER_RE, INTRO_CUE_RE, NOT_A_PRICE_AROUND_RE, DATE_RE_SRC, INTRO_WORD, REGULAR_PRICE, gap, INTRO_END_DATE_RES, TRIAL_ENDING_RE;
|
|
16432
16493
|
var init_intro_terms = __esm({
|
|
16433
16494
|
"../rules/src/checks/intro-terms.ts"() {
|
|
16434
16495
|
"use strict";
|
|
@@ -16444,6 +16505,15 @@ var init_intro_terms = __esm({
|
|
|
16444
16505
|
BILLED_LONGER_RE = /\bbilled\s+(?:annually|yearly|every\s+(?:\d{1,2}|twelve|two|three|six)\s+(?:months|years))\b|\bsingle\s+upfront\s+charge\b/i;
|
|
16445
16506
|
INTRO_CUE_RE = /\bintro(?:ductory)?\b|\bpromo(?:tional)?\b|\bdiscount(?:ed)?\b|\bspecial\s+(?:price|offer|rate)\b|\bfirst\s+\w+|\bthen\b|\bafter\s+(?:that|which)\b/i;
|
|
16446
16507
|
NOT_A_PRICE_AROUND_RE = /\b(?:tax(?:es)?|vat|fees?|shipping|delivery\s+fee|credit|refund(?:ed)?|balance|deposit|tip)\b/i;
|
|
16508
|
+
DATE_RE_SRC = "(?:(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?(?:,?\\s+\\d{4})?|\\d{1,2}\\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\.?(?:\\s+\\d{4})?|\\d{4}-\\d{2}-\\d{2}|\\d{1,2}/\\d{1,2}/\\d{2,4})";
|
|
16509
|
+
INTRO_WORD = "(?:discount|coupon|promo(?:tion|tional)?(?:\\s+(?:price|rate|code))?|intro(?:ductory)?(?:\\s+(?:price|rate|offer|period))?|special\\s+(?:price|offer|rate)|offer)";
|
|
16510
|
+
REGULAR_PRICE = "(?:regular|full|standard|normal|usual)\\s+(?:price|rate)";
|
|
16511
|
+
gap = (n) => `(?:[^.\\n]|\\.(?=\\d)){0,${n}}?`;
|
|
16512
|
+
INTRO_END_DATE_RES = [
|
|
16513
|
+
new RegExp(`\\b${INTRO_WORD}\\b${gap(80)}\\b(?:ends?|ending|expires?|runs?\\s+out|lasts?\\s+(?:until|through)|is\\s+valid\\s+(?:until|through)|until|through)\\b${gap(100)}${DATE_RE_SRC}`, "i"),
|
|
16514
|
+
new RegExp(`\\b${REGULAR_PRICE}\\b${gap(100)}\\b(?:from|on|starting(?:\\s+on)?|beginning(?:\\s+on)?|as\\s+of|after)\\s+${DATE_RE_SRC}`, "i"),
|
|
16515
|
+
new RegExp(`\\b(?:starting|from|beginning|on|as\\s+of)\\s+${DATE_RE_SRC}${gap(100)}\\b${REGULAR_PRICE}\\b`, "i")
|
|
16516
|
+
];
|
|
16447
16517
|
TRIAL_ENDING_RE = /\btrial\b[^.\n]{0,60}?\b(?:ends?|ending|expires?|is\s+over|runs?\s+out)\b|\b(?:after|when|once)\s+(?:your|the)\s+(?:free\s+)?trial\b/i;
|
|
16448
16518
|
}
|
|
16449
16519
|
});
|
|
@@ -16594,7 +16664,14 @@ function noticeContent(e, opts = {}) {
|
|
|
16594
16664
|
if (when) quotes.trial_end = quote(when);
|
|
16595
16665
|
const afterTrial = opts.firstChargeAfterTrial === "always" || opts.firstChargeAfterTrial === "when_trial_mentioned" && TRIAL_ENDING_RE.test(body);
|
|
16596
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);
|
|
16597
|
-
|
|
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
|
+
};
|
|
16598
16675
|
}
|
|
16599
16676
|
function missingElements(content, required) {
|
|
16600
16677
|
return required.filter((el) => !content.present.includes(el));
|
|
@@ -16606,6 +16683,12 @@ function describeMissing(missing) {
|
|
|
16606
16683
|
return missing.map((m) => ELEMENT_LABELS[m]).join(", ");
|
|
16607
16684
|
}
|
|
16608
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) {
|
|
16609
16692
|
if (typeof data?.contentIntro?.fix === "string" && !(Array.isArray(data?.contentMissing) && data.contentMissing.length)) return data.contentIntro.fix;
|
|
16610
16693
|
if ((checkId === "signup.disclosure.price" || checkId === "confirmation.email.terms") && Array.isArray(data?.units)) {
|
|
16611
16694
|
const line2 = data.units.find((x) => x && x.status !== "pass" && typeof x.introFix === "string")?.introFix;
|
|
@@ -16622,7 +16705,10 @@ function contentFixFromText(checkId, text) {
|
|
|
16622
16705
|
const noun = NOTICE_NOUN[checkId];
|
|
16623
16706
|
if (!noun) return void 0;
|
|
16624
16707
|
const m = new RegExp(`^the ${noun} \\(.*?\\) .*? does not state (.+?)(?: correctly \\(| \u2014 )`, "i").exec(text.trim());
|
|
16625
|
-
|
|
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;
|
|
16626
16712
|
}
|
|
16627
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;
|
|
16628
16714
|
var init_notice_content = __esm({
|
|
@@ -16709,6 +16795,19 @@ function offerLabel(o) {
|
|
|
16709
16795
|
const price3 = `${(o.amount / 10 ** digits).toFixed(digits)} ${o.currency.toUpperCase()} every ${o.intervalCount > 1 ? `${o.intervalCount} ${o.interval}s` : o.interval}`;
|
|
16710
16796
|
return o.name ? `${o.name} (${price3})` : price3;
|
|
16711
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
|
+
}
|
|
16712
16811
|
var DAYS;
|
|
16713
16812
|
var init_email_plan = __esm({
|
|
16714
16813
|
"../rules/src/checks/email-plan.ts"() {
|
|
@@ -16781,11 +16880,29 @@ var init_forwarded = __esm({
|
|
|
16781
16880
|
|
|
16782
16881
|
// ../rules/src/checks/confirmation.ts
|
|
16783
16882
|
function isPostSignupEmail(e) {
|
|
16784
|
-
if (e.kind !== "other" || !e.scanId || !e.relatedSignupAt) return false;
|
|
16883
|
+
if (e.kind !== "other" || !(e.scanId || testRunAckEmail(e)) || !e.relatedSignupAt) return false;
|
|
16785
16884
|
const received = toDate(e.receivedAt);
|
|
16786
16885
|
const signup = toDate(e.relatedSignupAt);
|
|
16787
16886
|
return !!received && !!signup && received.getTime() >= signup.getTime() - HOUR_MS;
|
|
16788
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
|
+
}
|
|
16789
16906
|
function findAcknowledgments(emails) {
|
|
16790
16907
|
const cached = ackCache.get(emails);
|
|
16791
16908
|
if (cached) return cached;
|
|
@@ -16795,6 +16912,7 @@ function findAcknowledgments(emails) {
|
|
|
16795
16912
|
const acknowledged = new Set(explicit.map((e) => recipientOf(e)).filter((r) => r !== ""));
|
|
16796
16913
|
for (const e of emails) {
|
|
16797
16914
|
if (e.kind === "acknowledgment") acks.push(e);
|
|
16915
|
+
else if (acknowledgmentCandidate(e, explicit)) acks.push(e);
|
|
16798
16916
|
else if (acknowledged.has(recipientOf(e))) continue;
|
|
16799
16917
|
else if (explicit.length && e.kind === "receipt" && (forwardedInfo(e).forwarded || isMerchantOfRecordEmail(e) || sentStraightToCheckbox(e))) continue;
|
|
16800
16918
|
else if (e.kind === "receipt" || isPostSignupEmail(e)) {
|
|
@@ -16830,6 +16948,18 @@ function noAckResult(checkId, input, set) {
|
|
|
16830
16948
|
}
|
|
16831
16949
|
if (set.receipts.length) {
|
|
16832
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
|
+
}
|
|
16833
16963
|
return makeResult(
|
|
16834
16964
|
checkId,
|
|
16835
16965
|
"needs_review",
|
|
@@ -16920,7 +17050,7 @@ function withoutRevisedDrafts(emails) {
|
|
|
16920
17050
|
const draft2 = (e) => !e.subscriptionRef && !e.relatedSignupAt && !e.testRun && !isCheckboxSentEmail(e);
|
|
16921
17051
|
const trial = (e) => /\b(?:free\s+)?trial\b/i.test(`${e.subject ?? ""} ${emailBody(e)}`);
|
|
16922
17052
|
const at = (e) => toDate(e.receivedAt)?.getTime() ?? 0;
|
|
16923
|
-
return emails.filter((e) => !(draft2(e) && sender(e) && emails.some((o) => o !== e &&
|
|
17053
|
+
return emails.filter((e) => !(draft2(e) && sender(e) && emails.some((o) => o !== e && sender(o) === sender(e) && trial(o) === trial(e) && at(o) > at(e))));
|
|
16924
17054
|
}
|
|
16925
17055
|
function latestPerTemplateAndPlan(emails, input) {
|
|
16926
17056
|
const offers = contextOffers(input.ctx);
|
|
@@ -17005,18 +17135,7 @@ function cancelBeforeTrialMatch(text) {
|
|
|
17005
17135
|
if (byDate && TRIAL_ENDS_DATE_RE.test(text)) return byDate[0];
|
|
17006
17136
|
return null;
|
|
17007
17137
|
}
|
|
17008
|
-
|
|
17009
|
-
const stated = a.trialLengths.filter((t) => typeof t.days === "number" && t.days > 0);
|
|
17010
|
-
if (!stated.length) return void 0;
|
|
17011
|
-
const own = p?.offer && p.offer.trialDays > 0 ? [p.offer] : p?.offer ? [] : offers.filter((o) => o.trialDays > 0);
|
|
17012
|
-
if (!own.length) return void 0;
|
|
17013
|
-
const fits = (days, trial) => days === trial || days % 30 === 0 && Math.abs(days - trial) <= Math.max(1, days / 30) * 2;
|
|
17014
|
-
if (stated.some((t) => own.some((o) => fits(t.days, o.trialDays)))) return void 0;
|
|
17015
|
-
const lengths = [...new Set(own.map((o) => o.trialDays))].sort((x, y) => x - y);
|
|
17016
|
-
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`;
|
|
17017
|
-
return { stated: `\u201C${stated[0].text}\u201D`, plan: plan2 };
|
|
17018
|
-
}
|
|
17019
|
-
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;
|
|
17020
17139
|
var init_confirmation = __esm({
|
|
17021
17140
|
"../rules/src/checks/confirmation.ts"() {
|
|
17022
17141
|
"use strict";
|
|
@@ -17032,6 +17151,8 @@ var init_confirmation = __esm({
|
|
|
17032
17151
|
ACK_MAX_DELAY_HOURS = 24;
|
|
17033
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.";
|
|
17034
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;
|
|
17035
17156
|
SIGNUP_LINK_TOLERANCE_MS = 30 * 6e4;
|
|
17036
17157
|
sent = {
|
|
17037
17158
|
id: "confirmation.email.sent",
|
|
@@ -17466,6 +17587,11 @@ function wholeDaysBefore(days, tie = "up") {
|
|
|
17466
17587
|
function sentInWindow(days, w) {
|
|
17467
17588
|
return wholeDaysBefore(days, "up") <= w.maxDays && days >= w.minDays - MIN_DAYS_GRACE_DAYS;
|
|
17468
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
|
+
}
|
|
17469
17595
|
function windowCloseMs(dueMs, w) {
|
|
17470
17596
|
return dueMs - (w.minDays - MIN_DAYS_GRACE_DAYS) * DAY_MS;
|
|
17471
17597
|
}
|
|
@@ -17829,35 +17955,64 @@ function contentGap(required, emails, opts = {}) {
|
|
|
17829
17955
|
const content = noticeContent(email, opts);
|
|
17830
17956
|
const wrongKeys = Object.keys(content.wrong ?? {});
|
|
17831
17957
|
const missing = [.../* @__PURE__ */ new Set([...missingElements(content, required), ...wrongKeys])];
|
|
17832
|
-
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 } : {} };
|
|
17833
17960
|
}
|
|
17834
17961
|
return null;
|
|
17835
17962
|
}
|
|
17836
|
-
function contentFail(spec, required,
|
|
17837
|
-
const intro =
|
|
17838
|
-
const sentWhat = isPreflightEmail(
|
|
17839
|
-
const introData =
|
|
17840
|
-
|
|
17963
|
+
function contentFail(spec, required, gap2, timing, refs2, data) {
|
|
17964
|
+
const intro = gap2.intro;
|
|
17965
|
+
const sentWhat = isPreflightEmail(gap2.email) ? "is a draft that" : `${timing}, but it`;
|
|
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
|
+
}
|
|
17981
|
+
if (!gap2.missing.length && intro) {
|
|
17841
17982
|
return makeResult(
|
|
17842
17983
|
spec.checkId,
|
|
17843
17984
|
intro.status,
|
|
17844
|
-
`The ${spec.noun} (${emailLabel(
|
|
17845
|
-
[...emailRefs(
|
|
17985
|
+
`The ${spec.noun} (${emailLabel(gap2.email)}) ${sentWhat} ${intro.detail}`,
|
|
17986
|
+
[...emailRefs(gap2.email), ...refs2],
|
|
17846
17987
|
intro.status === "fail" ? 0.8 : 0.5,
|
|
17847
|
-
{ ...data, contentMissing: [], contentEmailId:
|
|
17988
|
+
{ ...data, contentMissing: [], contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...introData }
|
|
17848
17989
|
);
|
|
17849
17990
|
}
|
|
17850
17991
|
return makeResult(
|
|
17851
17992
|
spec.checkId,
|
|
17852
17993
|
"fail",
|
|
17853
|
-
`The ${spec.noun} (${emailLabel(
|
|
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
|
|
17854
17995
|
// element says its own reason.
|
|
17855
|
-
|
|
17856
|
-
[...emailRefs(
|
|
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}` : ""}`,
|
|
17997
|
+
[...emailRefs(gap2.email), ...refs2],
|
|
17857
17998
|
0.8,
|
|
17858
|
-
{ ...data, contentMissing:
|
|
17999
|
+
{ ...data, contentMissing: gap2.missing, contentEmailId: gap2.email.id, ...isCheckboxSentEmail(gap2.email) ? { contentByCheckbox: true } : {}, ...gap2.wrong ? { contentWrong: gap2.wrong } : {}, ...introData }
|
|
17859
18000
|
);
|
|
17860
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
|
+
}
|
|
17861
18016
|
async function runReminder(spec, input) {
|
|
17862
18017
|
const id = spec.checkId;
|
|
17863
18018
|
const config = input.ctx.config;
|
|
@@ -17908,7 +18063,8 @@ async function runReminder(spec, input) {
|
|
|
17908
18063
|
const contentOpts = {
|
|
17909
18064
|
productNames: productNames2,
|
|
17910
18065
|
...planOpts,
|
|
17911
|
-
...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) } : {}
|
|
17912
18068
|
};
|
|
17913
18069
|
const runStep = spec.kind === "trial_ending" ? "trial_reminder" : spec.kind === "annual_renewal" ? "renewal" : null;
|
|
17914
18070
|
const runStamped = runStep ? emails.filter((e) => testRunDaysBefore(e, runStep)) : [];
|
|
@@ -17955,8 +18111,8 @@ async function runReminder(spec, input) {
|
|
|
17955
18111
|
const w = conflicting ? "within each state\u2019s window" : why(window);
|
|
17956
18112
|
if (counted.length) {
|
|
17957
18113
|
if (!outside.length && !missing.length && !partial.length) {
|
|
17958
|
-
const
|
|
17959
|
-
if (
|
|
18114
|
+
const gap2 = contentGap(required, matched.flatMap((j) => j.email ? [j.email] : []), contentOpts);
|
|
18115
|
+
if (gap2) return contentFail(spec, required, gap2, "was sent on time", refs2(matched), data);
|
|
17960
18116
|
return makeResult(
|
|
17961
18117
|
id,
|
|
17962
18118
|
"pass",
|
|
@@ -18047,12 +18203,12 @@ async function runReminder(spec, input) {
|
|
|
18047
18203
|
return contentFail(spec, required, newestGap, t ? `was sent about ${t.days} days ahead (required: ${conflicting ? w : why(t.w)})` : "was captured (its timing could not be verified)", configRefs, data);
|
|
18048
18204
|
}
|
|
18049
18205
|
if (inWindow.length) {
|
|
18050
|
-
const
|
|
18051
|
-
if (
|
|
18206
|
+
const gap2 = contentGap(required, inWindow.map((t) => t.e), contentOpts);
|
|
18207
|
+
if (gap2) return contentFail(spec, required, gap2, `was sent about ${inWindow[0].days} days ahead (required: ${conflicting ? w : why(inWindow[0].w)})`, configRefs, data);
|
|
18052
18208
|
return makeResult(
|
|
18053
18209
|
id,
|
|
18054
18210
|
"pass",
|
|
18055
|
-
`${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)})`,
|
|
18056
18212
|
[...emailRefs(inWindow[0].e), ...configRefs],
|
|
18057
18213
|
0.7,
|
|
18058
18214
|
data
|
|
@@ -18060,8 +18216,8 @@ async function runReminder(spec, input) {
|
|
|
18060
18216
|
}
|
|
18061
18217
|
if (nearWindow.length) {
|
|
18062
18218
|
const n = nearWindow[0];
|
|
18063
|
-
const
|
|
18064
|
-
if (
|
|
18219
|
+
const gap2 = contentGap(required, nearWindow.map((t) => t.e), contentOpts);
|
|
18220
|
+
if (gap2) return contentFail(spec, required, gap2, `was sent about ${n.days} days ahead (required: ${conflicting ? w : why(n.w)})`, configRefs, data);
|
|
18065
18221
|
data.timingNearWindow = { days: n.days, required: conflicting ? w : why(n.w), roundingRule: DAYS_ROUNDING_RULE, ...n.range ? { range: n.range } : {} };
|
|
18066
18222
|
data.timingOutsideWindow = { days: n.days, required: conflicting ? w : why(n.w) };
|
|
18067
18223
|
return makeResult(
|
|
@@ -18209,6 +18365,7 @@ var init_reminder = __esm({
|
|
|
18209
18365
|
"../rules/src/checks/reminder.ts"() {
|
|
18210
18366
|
"use strict";
|
|
18211
18367
|
init_email_plan();
|
|
18368
|
+
init_offer();
|
|
18212
18369
|
init_text();
|
|
18213
18370
|
init_business_days();
|
|
18214
18371
|
init_context();
|
|
@@ -18915,8 +19072,21 @@ function otherPlanCard(view, input) {
|
|
|
18915
19072
|
const known = referencePlans(input.ctx).map((p) => p.amount / 10 ** currencyDigits(p.currency));
|
|
18916
19073
|
return !amounts.some((n) => known.some((k) => Math.abs(k - n) < 5e-3));
|
|
18917
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
|
+
}
|
|
18918
19088
|
async function overViews(checkId, input, headlines, evaluateView) {
|
|
18919
|
-
const views = input.data.signupViews;
|
|
19089
|
+
const views = boughtPlanViews(input.data.signupViews, input);
|
|
18920
19090
|
if (views.length === 0) return missingData(checkId, NO_CHECKOUT_CAPTURE_MESSAGE);
|
|
18921
19091
|
const outcomes = [];
|
|
18922
19092
|
for (const view of views) outcomes.push({ view, unit: await evaluateView(view) });
|
|
@@ -19222,7 +19392,7 @@ function introPriceUnit(u, view, plans, promosDeclared = false) {
|
|
|
19222
19392
|
function setOffAdvice(sizePx, contrast, weight, ref) {
|
|
19223
19393
|
const refWeight = ref.fontWeight ?? 400;
|
|
19224
19394
|
const larger = Math.ceil(ref.fontSizePx * SET_OFF_MIN_SIZE_RATIO);
|
|
19225
|
-
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)"];
|
|
19226
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("; ")}`;
|
|
19227
19397
|
}
|
|
19228
19398
|
function trialObservedOutsideCheckout(input) {
|
|
@@ -19357,10 +19527,10 @@ function gatedByOwnBox(renewalBoxes, path) {
|
|
|
19357
19527
|
return renewalBoxes.some((b) => b.required === true && !b.checkedByDefault && consentGate(b, path) === "gated");
|
|
19358
19528
|
}
|
|
19359
19529
|
function boxNearCta(box, cta) {
|
|
19360
|
-
const
|
|
19361
|
-
return
|
|
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;
|
|
19531
|
+
return gap2 <= 200;
|
|
19362
19532
|
}
|
|
19363
|
-
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;
|
|
19364
19534
|
var init_signup = __esm({
|
|
19365
19535
|
"../rules/src/checks/signup.ts"() {
|
|
19366
19536
|
"use strict";
|
|
@@ -19461,6 +19631,7 @@ var init_signup = __esm({
|
|
|
19461
19631
|
PRODUCT_TITLE_RE = /^(?:[A-Z0-9][\w'&.+]*\s+){0,6}[A-Z0-9][\w'&.+]*\s*[-–—|:·]\s*[$€£]\s?\d[\d,]*(?:\.\d{1,2})?\s*$/;
|
|
19462
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;
|
|
19463
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";
|
|
19464
19635
|
price = {
|
|
19465
19636
|
id: "signup.disclosure.price",
|
|
19466
19637
|
usesText: true,
|
|
@@ -19863,8 +20034,18 @@ var init_signup = __esm({
|
|
|
19863
20034
|
const offers = contextOffers(input.ctx);
|
|
19864
20035
|
const loose = !view.plan || view.plan.how === "only_plan";
|
|
19865
20036
|
const cardOffer = loose && (view.offer || view.checkoutConfig) && offers.length > 1 ? offerForView(view, offers) : void 0;
|
|
19866
|
-
const
|
|
20037
|
+
const paramsTrial = view.checkoutConfig?.trialDays;
|
|
20038
|
+
const planTrial = typeof paramsTrial === "number" ? paramsTrial : cardOffer ? cardOffer.trialDays : view.plan ? offerTrialDays(offers, view.plan.plan, input.ctx.facts) : void 0;
|
|
19867
20039
|
const planName = cardOffer ? offerName(cardOffer) : view.plan?.label;
|
|
20040
|
+
if (mentioned && paramsTrial === 0) {
|
|
20041
|
+
const said = [...a.trialLengths, ...p.trialLengths][0] ?? [...a.trialMentions, ...p.trialMentions][0];
|
|
20042
|
+
return unit(
|
|
20043
|
+
"fail",
|
|
20044
|
+
`the text promises a free trial${said ? ` (\u201C${quote(said)}\u201D)` : ""}, but the ${view.checkoutConfig?.kind === "payment_link" ? "Payment Link" : "Checkout params"} run none (no subscription_data.trial_period_days), so Stripe charges the full price today \u2014 add the trial to subscription_data, or remove the promise from the text`,
|
|
20045
|
+
0.85,
|
|
20046
|
+
{ ...said ? { quote: quote(said) } : {}, meta: { planTrialDays: 0, paramsTrialDays: 0, trialMismatch: true } }
|
|
20047
|
+
);
|
|
20048
|
+
}
|
|
19868
20049
|
if (!mentioned) {
|
|
19869
20050
|
if (planTrial === 0 && offers.some((o) => o.trialDays > 0)) {
|
|
19870
20051
|
const withTrial = offers.filter((o) => o.trialDays > 0).map(offerName);
|
|
@@ -19889,7 +20070,7 @@ var init_signup = __esm({
|
|
|
19889
20070
|
const meta = {};
|
|
19890
20071
|
if (length && "days" in length && length.days !== null) meta.disclosedTrialDays = length.days;
|
|
19891
20072
|
if (input.facts.trialDays) meta.configuredTrialDays = input.facts.trialDays;
|
|
19892
|
-
const ownPlanOnly = (!!view.plan || !!cardOffer) && (!!view.checkoutConfig || !!view.offer) && planTrial !== void 0;
|
|
20073
|
+
const ownPlanOnly = (typeof paramsTrial === "number" || !!view.plan || !!cardOffer) && (!!view.checkoutConfig || !!view.offer) && planTrial !== void 0;
|
|
19893
20074
|
const trialPlans = [
|
|
19894
20075
|
...(input.ctx.config?.plans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: configPlanSource(input.ctx) })),
|
|
19895
20076
|
...(input.ctx.referencePlans ?? []).map((pl) => ({ trialDays: pl.trialDays, source: pl.source }))
|
|
@@ -20195,16 +20376,20 @@ ${view.pageText}`;
|
|
|
20195
20376
|
if (setOff && !setOff.differentiators.length) {
|
|
20196
20377
|
return unit(
|
|
20197
20378
|
"needs_review",
|
|
20198
|
-
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}` : ""}`,
|
|
20199
20380
|
0.55,
|
|
20200
|
-
|
|
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 }
|
|
20201
20384
|
);
|
|
20202
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` : "";
|
|
20203
20388
|
return unit(
|
|
20204
20389
|
"pass",
|
|
20205
|
-
`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}`,
|
|
20206
20391
|
terms2.tier === 2 ? 0.65 : 0.85,
|
|
20207
|
-
{ meta: { ...meta, ...setOffMeta }, quote: q2 }
|
|
20392
|
+
{ meta: { ...meta, ...setOffMeta, ...smallerNearButton ? { hostedSmallerNearButton: { fontSizePx: smallerNearButton.style.fontSizePx, quote: quote(labelOf(smallerNearButton)) } } : {} }, quote: q2 }
|
|
20208
20393
|
);
|
|
20209
20394
|
}
|
|
20210
20395
|
if (best.a.status === "needs_review") return unit("needs_review", best.a.warnings.join("; "), 0.5, { meta, quote: q2 });
|
|
@@ -22199,6 +22384,27 @@ var init_brand_scope = __esm({
|
|
|
22199
22384
|
}
|
|
22200
22385
|
});
|
|
22201
22386
|
|
|
22387
|
+
// ../rules/src/set-off-fix.ts
|
|
22388
|
+
function setOffFix(text) {
|
|
22389
|
+
if (/measured on the Stripe-hosted page/.test(text)) return HOSTED_SET_OFF_FIX;
|
|
22390
|
+
const own = /legible \((\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
|
|
22391
|
+
const ref = /look like the surrounding text \(about (\d+(?:\.\d+)?)px, weight (\d+)/.exec(text);
|
|
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.';
|
|
22393
|
+
const refPx = Number(ref[1]);
|
|
22394
|
+
const refWeight = Number(ref[2]);
|
|
22395
|
+
const larger = Math.ceil(refPx * 1.15);
|
|
22396
|
+
const measured = own ? `It is ${own[1]}px, weight ${own[2]}; the text around it is about ${refPx}px, weight ${refWeight}${refWeight >= 600 ? " (already bold, so a bolder weight cannot set it apart)" : ""}.` : `The text around it is about ${refPx}px, weight ${refWeight}.`;
|
|
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")'];
|
|
22398
|
+
return `Set the renewal line apart in a way Checkbox can measure. ${measured} Any one of these is enough: ${ways.join("; ")}.`;
|
|
22399
|
+
}
|
|
22400
|
+
var HOSTED_SET_OFF_FIX;
|
|
22401
|
+
var init_set_off_fix = __esm({
|
|
22402
|
+
"../rules/src/set-off-fix.ts"() {
|
|
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.';
|
|
22405
|
+
}
|
|
22406
|
+
});
|
|
22407
|
+
|
|
22202
22408
|
// ../rules/src/status.ts
|
|
22203
22409
|
function tierOf(checkId) {
|
|
22204
22410
|
return CHECK_TIERS[checkId] ?? "required";
|
|
@@ -22295,10 +22501,18 @@ function cancelTestProblem(raw) {
|
|
|
22295
22501
|
}
|
|
22296
22502
|
function policyUnreadableFix(text) {
|
|
22297
22503
|
const n = /\((\d+) characters/.exec(text)?.[1];
|
|
22298
|
-
|
|
22299
|
-
|
|
22504
|
+
const doc = /\b(terms of service|cancellation policy|refund policy|privacy policy)\s*\((https?:\/\/[^\s),]+)/i.exec(text);
|
|
22505
|
+
const kind = doc?.[1]?.toLowerCase();
|
|
22506
|
+
const url = doc?.[2];
|
|
22507
|
+
const page = !kind || kind === "terms of service" ? "Terms page" : `${kind} page`;
|
|
22508
|
+
const named = url ? `Your ${page} (${url})` : `Your ${page}`;
|
|
22509
|
+
if (url && /\/p\/login\//i.test(url)) {
|
|
22510
|
+
return `${url} is a Stripe customer portal sign-in page, not a policy page, so there is no ${kind === "terms of service" || !kind ? "Terms" : kind} text to check there. Keep it as your cancel link (account page, emails); remove it as the ${kind === "terms of service" || !kind ? "Terms" : kind} URL (updateSetupSession ${kind === "refund policy" ? "refund_url" : kind === "cancellation policy" ? "cancellation_url" : "terms_url"}: null) or replace it with the page that states the policy. Checkbox reads it again on the next check.`;
|
|
22300
22511
|
}
|
|
22301
|
-
|
|
22512
|
+
if (n !== void 0 || /almost no text/i.test(text)) {
|
|
22513
|
+
return `${named} has only ${n !== void 0 ? `${n} characters` : "a few words"} of text, so there is nothing for Checkbox to check yet: add sections on how the subscription renews, how to cancel and your refund policy (or, if the page is built with JavaScript, serve that text in the HTML). Checkbox reads it again on the next check.`;
|
|
22514
|
+
}
|
|
22515
|
+
return `Checkbox could not read ${url ? `your ${page} (${url})` : `your ${page}`}: register a public URL that serves the policy text without a login (Site settings, or updateSite terms_url). Checkbox reads it again on the next check.`;
|
|
22302
22516
|
}
|
|
22303
22517
|
function finish(base, reason, checkId, tier = tierOf(checkId)) {
|
|
22304
22518
|
if (reason === "ny_owner_decision") return { ...base, label: NY_EMAIL_ONLY_LABEL, reason, tier };
|
|
@@ -22374,6 +22588,9 @@ function classifyRaw(input) {
|
|
|
22374
22588
|
if (input.origin !== "preflight" && input.data?.verifiedFrom === "checkbox_hosted") {
|
|
22375
22589
|
return finish({ status: "compliant", note: VERIFIED_FROM_HOSTED_NOTE }, "verified_from_checkbox_settings", checkId);
|
|
22376
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
|
+
}
|
|
22377
22594
|
if (input.origin !== "preflight" && LIVE_CHECKS.has(checkId)) {
|
|
22378
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);
|
|
22379
22596
|
return finish({ status: "verified_live" }, "verdict_pass_live", checkId);
|
|
@@ -22529,10 +22746,11 @@ function classifyFindings(findings) {
|
|
|
22529
22746
|
info: classify({ checkId: f.checkId, status: f.status, detail: f.detail, missing: f.evidence?.missing, data: f.data, origin: "evaluation", siblings })
|
|
22530
22747
|
}));
|
|
22531
22748
|
}
|
|
22532
|
-
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;
|
|
22533
22750
|
var init_status = __esm({
|
|
22534
22751
|
"../rules/src/status.ts"() {
|
|
22535
22752
|
"use strict";
|
|
22753
|
+
init_set_off_fix();
|
|
22536
22754
|
init_notice_content();
|
|
22537
22755
|
init_notices_on();
|
|
22538
22756
|
init_plan_terms();
|
|
@@ -22594,7 +22812,8 @@ var init_status = __esm({
|
|
|
22594
22812
|
has = (re) => (c) => re.test(c.text);
|
|
22595
22813
|
isStripeInput = (c) => (c.kind ?? "").startsWith("stripe_") || onlyStripeHostedOpen(c.data) || /checkout\.stripe\.com|buy\.stripe\.com|stripe|custom_text/.test(c.text);
|
|
22596
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.';
|
|
22597
|
-
|
|
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.";
|
|
22598
22817
|
family = (c) => String(c.checkId).split(".")[0];
|
|
22599
22818
|
isEmailCheck = (id) => id.startsWith("confirmation.") || id.startsWith("reminder.");
|
|
22600
22819
|
ask = (text, recommended, why) => ({ text, recommended, why });
|
|
@@ -22605,6 +22824,7 @@ var init_status = __esm({
|
|
|
22605
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.";
|
|
22606
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.`;
|
|
22607
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.`;
|
|
22608
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/;
|
|
22609
22829
|
RULES = [
|
|
22610
22830
|
// Round 23 (pilot 22, ai-builder "soft false pass"): a test cancellation worked in the Stripe customer portal, but no
|
|
@@ -22615,6 +22835,16 @@ var init_status = __esm({
|
|
|
22615
22835
|
when: (c) => String(c.checkId).startsWith("cancel.") && c.data?.portalUnlinked === true,
|
|
22616
22836
|
build: (c) => ({ status: "needs_fix", fix: portalUnlinkedFix(typeof c.data?.portalUrl === "string" ? c.data.portalUrl : void 0) })
|
|
22617
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
|
+
},
|
|
22618
22848
|
// Round 20 (pilot 19 item 1): an acknowledgment that does not mention a trial, on a site where only some plans have
|
|
22619
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.
|
|
22620
22850
|
{
|
|
@@ -22821,17 +23051,30 @@ var init_status = __esm({
|
|
|
22821
23051
|
{ code: "far_from_button", category: "content", when: has(/far from the purchase button|only far from/), build: () => ({ status: "needs_fix", fix: "Move the renewal line directly above (or right below) the pay button." }) },
|
|
22822
23052
|
{ code: "below_button_small", category: "content", when: has(/directly below the purchase button/), build: () => ({ status: "needs_fix", fix: "Move the renewal line under the price, before the card and email fields and the pay button, at body size." }) },
|
|
22823
23053
|
// Normal round 2 (nextjs, django): "looks like the surrounding text" is a measurement Checkbox cannot finish (borders,
|
|
22824
|
-
// symbols and italics are not captured), so it
|
|
23054
|
+
// symbols and italics are not captured), so it was a question to confirm by eye. Round 33 (pilot 32 item 18,
|
|
23055
|
+
// core-api-only / core-ci-pr / core-discovery: the owner was asked for "a decision" an agent could make in code, and the
|
|
23056
|
+
// recommended "make it semibold" could never pass next to an already bold price line): a fix with the measured values
|
|
23057
|
+
// and the ways Checkbox can measure (larger type, semibold where the text around is not bold, a contrasting colour, a
|
|
23058
|
+
// border). Never compliant.
|
|
23059
|
+
// A disclosure text check (kind "disclosure") only knows the styling the caller describes (placement.bold / boxed):
|
|
23060
|
+
// there it stays the question it was, with the same measurable options.
|
|
22825
23061
|
{
|
|
22826
23062
|
code: "not_set_off",
|
|
23063
|
+
category: "content",
|
|
23064
|
+
when: (c) => c.kind !== "disclosure" && /look like the surrounding text/.test(c.text),
|
|
23065
|
+
build: (c) => ({ status: "needs_fix", fix: setOffFix(c.text) })
|
|
23066
|
+
},
|
|
23067
|
+
{
|
|
23068
|
+
code: "not_set_off_described",
|
|
22827
23069
|
category: "legal_choice",
|
|
22828
|
-
when:
|
|
22829
|
-
build: () => ({
|
|
23070
|
+
when: (c) => c.kind === "disclosure" && /look like the surrounding text/.test(c.text),
|
|
23071
|
+
build: (c) => ({
|
|
22830
23072
|
status: "your_call",
|
|
23073
|
+
fix: setOffFix(c.text),
|
|
22831
23074
|
question: ask(
|
|
22832
|
-
"Confirm by eye: does the renewal line stand out from the text around it (semibold, a
|
|
22833
|
-
"
|
|
22834
|
-
"
|
|
23075
|
+
"Confirm by eye: does the renewal line stand out from the text around it (semibold where the text around it is not bold, larger type, a contrasting colour, or a border)?",
|
|
23076
|
+
"If it does, check again with placement.bold (or placement.boxed) set; if not, apply one of the ways in the fix.",
|
|
23077
|
+
"This check only knows the styling you describe; it cannot see your page."
|
|
22835
23078
|
)
|
|
22836
23079
|
})
|
|
22837
23080
|
},
|
|
@@ -23179,7 +23422,7 @@ var init_status = __esm({
|
|
|
23179
23422
|
{ code: "stripe_checkout_unchecked", category: "missing_evidence", when: has(/checkout session whose parameters checkbox has not checked|register the checkout session parameters/), build: (c) => ({ status: "pending_live_check", pending: pendingFor("", `${c.text} createstripecheckoutconfig`, c.data) }) }
|
|
23180
23423
|
];
|
|
23181
23424
|
STATUS_RULES = RULES.map((r) => ({ code: r.code, category: r.category }));
|
|
23182
|
-
NY_EMAIL_ONLY_LABEL = "Your decision: email only";
|
|
23425
|
+
NY_EMAIL_ONLY_LABEL = "Your decision: email only (not verified)";
|
|
23183
23426
|
NY_EMAIL_ONLY_NOTE = 'You recorded your decision to send renewal and reminder notices by email only. New York asks for notices "in the manner the consumer selected"; whether email that nobody picked meets that is not settled, and Checkbox does not decide it. You can add an Email choice at sign-up at any time.';
|
|
23184
23427
|
STRIPE_HOSTED_UNITS = /* @__PURE__ */ new Set(["checkout_config", "payment_link", "stripe_hosted"]);
|
|
23185
23428
|
PLATFORM_NAMES2 = { paddle: "Paddle", shopify: "Shopify", recharge: "Recharge", chargebee: "Chargebee", recurly: "Recurly" };
|
|
@@ -23981,12 +24224,12 @@ function disclosureParts(text) {
|
|
|
23981
24224
|
function disclosureHtml(text) {
|
|
23982
24225
|
return disclosureParts(text).map((p) => "url" in p ? `<a href="${esc(p.url)}">${esc(p.label)}</a>` : esc(p.text)).join("");
|
|
23983
24226
|
}
|
|
23984
|
-
function htmlDisclosureComponent(texts, sha2562) {
|
|
24227
|
+
function htmlDisclosureComponent(texts, sha2562, opts = {}) {
|
|
23985
24228
|
return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
|
|
23986
24229
|
Paste INSIDE your sign-up <form>, directly ABOVE the subscribe / purchase button.
|
|
23987
|
-
One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
|
|
23988
|
-
If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead
|
|
23989
|
-
<div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5
|
|
24230
|
+
${opts.boxed ? BOXED_NOTE : ` One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
|
|
24231
|
+
If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead.`} Keep it at body size and readable contrast; do not make it smaller or lighter than the text around it. -->
|
|
24232
|
+
<div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;${opts.boxed ? BOXED_STYLE : ""}">
|
|
23990
24233
|
<p style="margin:0 0 6px;font-weight:600;">${disclosureHtml(texts.disclosure)}</p>
|
|
23991
24234
|
<label style="display:flex;gap:8px;align-items:flex-start;">
|
|
23992
24235
|
<!-- Never add "checked": consent must be an affirmative act. -->
|
|
@@ -23996,23 +24239,26 @@ function htmlDisclosureComponent(texts, sha2562) {
|
|
|
23996
24239
|
</div>
|
|
23997
24240
|
<button type="submit">${esc(texts.cta)}</button>`;
|
|
23998
24241
|
}
|
|
23999
|
-
function htmlTermsOnly(disclosure, sha2562, onStripe) {
|
|
24242
|
+
function htmlTermsOnly(disclosure, sha2562, onStripe, opts = {}) {
|
|
24000
24243
|
const where2 = onStripe === "payment_link" ? "Payment Link" : "Stripe Checkout";
|
|
24001
24244
|
return `<!-- Checkbox renewal terms (generated from your plan's price, interval and cancellation URL; regenerate instead of editing the text).
|
|
24002
24245
|
Paste directly ABOVE your own subscribe / Join link or button. Keep that link or button as it is.
|
|
24003
24246
|
No consent checkbox here: the ${where2} collects consent (consent_collection.terms_of_service = "required").
|
|
24004
|
-
One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
|
|
24005
|
-
If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead
|
|
24006
|
-
<div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5
|
|
24247
|
+
${opts.boxed ? BOXED_NOTE : ` One line in your page's own font, size and colours: semibold sets it apart from normal-weight text (clear and conspicuous).
|
|
24248
|
+
If the text around it is already bold (a bold price line), put it in a bordered or shaded box instead.`} -->
|
|
24249
|
+
<div class="checkbox-renewal-terms" data-checkbox-terms="${esc(sha2562)}" style="margin:12px 0;line-height:1.5;${opts.boxed ? BOXED_STYLE : ""}">
|
|
24007
24250
|
<p style="margin:0;font-weight:600;">${disclosureHtml(disclosure)}</p>
|
|
24008
24251
|
</div>`;
|
|
24009
24252
|
}
|
|
24010
|
-
var esc, URL_RE2;
|
|
24253
|
+
var esc, URL_RE2, BOXED_STYLE, BOXED_NOTE;
|
|
24011
24254
|
var init_html = __esm({
|
|
24012
24255
|
"../rules/src/fixes/html.ts"() {
|
|
24013
24256
|
"use strict";
|
|
24014
24257
|
esc = (s2) => s2.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
24015
24258
|
URL_RE2 = /https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)]/g;
|
|
24259
|
+
BOXED_STYLE = "border:1px solid currentColor;border-radius:6px;padding:8px 12px;";
|
|
24260
|
+
BOXED_NOTE = ` The text next to your button is already bold, so a semibold line cannot stand out: this version is set off by a border
|
|
24261
|
+
(${BOXED_STYLE.replace(/;$/, "")}), which Checkbox measures. Keep the border, or use 15% larger type instead.`;
|
|
24016
24262
|
}
|
|
24017
24263
|
});
|
|
24018
24264
|
|
|
@@ -24095,7 +24341,7 @@ function modelAfterCapture(before, target, texts, opts) {
|
|
|
24095
24341
|
const ctaY = cta ? cta.rect.y : 700;
|
|
24096
24342
|
const ctaH = cta ? cta.rect.height : 48;
|
|
24097
24343
|
const stripe = target === "stripe_checkout" || target === "stripe_payment_link";
|
|
24098
|
-
const style2 = stripe ? STRIPE_CUSTOM_TEXT_STYLE : COMPONENT_STYLE;
|
|
24344
|
+
const style2 = stripe ? STRIPE_CUSTOM_TEXT_STYLE : opts.boxed ? { ...COMPONENT_STYLE, boxed: true } : COMPONENT_STYLE;
|
|
24099
24345
|
const disclosureH = textHeight(texts.disclosure, width, style2.fontSizePx);
|
|
24100
24346
|
const labelH = textHeight(texts.consentLabel, width - 28, style2.fontSizePx);
|
|
24101
24347
|
const disclosure = {
|
|
@@ -24227,7 +24473,7 @@ async function verifyFix(input) {
|
|
|
24227
24473
|
const rest = captures.filter((c) => !allCheckout(c));
|
|
24228
24474
|
const unchangedCheckout = latestPages(captures.filter((c) => allCheckout(c) && !matching.some((m) => m.id === c.id))).filter((c) => (c.ctas?.length ?? 0) > 0);
|
|
24229
24475
|
const otherCheckoutPages = new Set(unchangedCheckout.map((c) => c.url.split(/[?#]/)[0])).size;
|
|
24230
|
-
const after = base.map((c) => modelAfterCapture(c, input.target, input.texts, { consentCheckbox: consent }));
|
|
24476
|
+
const after = base.map((c) => modelAfterCapture(c, input.target, input.texts, { consentCheckbox: consent, ...input.boxed ? { boxed: true } : {} }));
|
|
24231
24477
|
const changedIds = new Set(objects.map((o) => o.id));
|
|
24232
24478
|
const afterConfigs = input.ctx.checkoutConfigs?.map(
|
|
24233
24479
|
(c) => changedIds.has(c.id) ? { ...c, capture: modelAfterCapture(c.capture, input.target, input.texts, { consentCheckbox: consent }), consentRequired: consent || c.consentRequired } : c
|
|
@@ -24384,6 +24630,9 @@ var init_model = __esm({
|
|
|
24384
24630
|
});
|
|
24385
24631
|
|
|
24386
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
|
+
}
|
|
24387
24636
|
function resolvePlan(input, prefix = "") {
|
|
24388
24637
|
const currency = parseCurrency(input.currency, `${prefix}currency`);
|
|
24389
24638
|
const amount = minorAmount({ price: input.price, amount: input.amount, currency }, `${prefix}price`);
|
|
@@ -24402,6 +24651,20 @@ function resolvePlan(input, prefix = "") {
|
|
|
24402
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`);
|
|
24403
24652
|
}
|
|
24404
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
|
+
}
|
|
24405
24668
|
if (off !== void 0 && off !== null && !(typeof off === "number" && Number.isInteger(off) && off > 0 && off < amount)) {
|
|
24406
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`);
|
|
24407
24670
|
}
|
|
@@ -24492,13 +24755,18 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
|
|
|
24492
24755
|
const verifyTexts = lockedTexts(verifyTerms);
|
|
24493
24756
|
const page = opts.onStripe && opts.page && (opts.page.ctas?.length ?? 0) > 0 ? [{ ...opts.page, pageKind: "checkout", source: "scanner" }] : [];
|
|
24494
24757
|
const ctx = modelContext(jurisdictions, asOf, planFacts(plan2), { config: planConfig(plan2, asOf), ...page.length ? { captures: page } : {}, ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
|
|
24495
|
-
const
|
|
24758
|
+
const v0 = await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, ...opts.boxed ? { boxed: true } : {}, ...opts.onStripe ? { consentCheckbox: false } : {} });
|
|
24759
|
+
const tryBoxed = !opts.boxed && page.length > 0 && !v0.passes && v0.remaining.includes("signup.disclosure.conspicuous");
|
|
24760
|
+
const vb = tryBoxed ? await verifyFix({ ctx, target: "html", texts: verifyTexts, terms: verifyTerms, now, boxed: true, ...opts.onStripe ? { consentCheckbox: false } : {} }) : void 0;
|
|
24761
|
+
const boxedOnPage = !!vb && !vb.remaining.includes("signup.disclosure.conspicuous") && vb.remaining.length < v0.remaining.length;
|
|
24762
|
+
const boxed = opts.boxed === true || boxedOnPage;
|
|
24763
|
+
const v = boxedOnPage ? vb : v0;
|
|
24496
24764
|
const sha = textsSha256(shown);
|
|
24497
24765
|
const placeholders = plan2.cancelUrl ? [] : [CANCEL_URL_PLACEHOLDER];
|
|
24498
24766
|
let short;
|
|
24499
24767
|
if (plan2.cancelUrl) {
|
|
24500
24768
|
const shortText = shortDisclosureText(verifyTerms);
|
|
24501
|
-
const sv = await verifyFix({ ctx, target: "html", texts: { ...verifyTexts, disclosure: shortText }, terms: verifyTerms, now, ...opts.onStripe ? { consentCheckbox: false } : {} });
|
|
24769
|
+
const sv = await verifyFix({ ctx, target: "html", texts: { ...verifyTexts, disclosure: shortText }, terms: verifyTerms, now, ...boxed ? { boxed: true } : {}, ...opts.onStripe ? { consentCheckbox: false } : {} });
|
|
24502
24770
|
if (sv.passes && v.passes && shortText !== shown.disclosure) short = shortText;
|
|
24503
24771
|
}
|
|
24504
24772
|
if (opts.onStripe) {
|
|
@@ -24509,9 +24777,9 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
|
|
|
24509
24777
|
...short ? { short_disclosure_text: short } : {},
|
|
24510
24778
|
consent_label: shown.consentLabel,
|
|
24511
24779
|
button_text: shown.cta,
|
|
24512
|
-
html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe),
|
|
24780
|
+
html: htmlTermsOnly(shown.disclosure, sha, opts.onStripe, boxed ? { boxed: true } : {}),
|
|
24513
24781
|
consent_on: opts.onStripe,
|
|
24514
|
-
note: `html is the renewal terms only (no checkbox, no button): paste it directly above your own purchase link and keep that link. Consent is collected on ${where2}: it needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text (check it with ${check2}). consent_label and button_text are for a page that collects consent itself
|
|
24782
|
+
note: `html is the renewal terms only (no checkbox, no button): paste it directly above your own purchase link and keep that link. Consent is collected on ${where2}: it needs consent_collection.terms_of_service = "required" and the renewal terms in custom_text (check it with ${check2}). consent_label and button_text are for a page that collects consent itself.${boxed ? " The text next to your button is already bold, so a semibold renewal line cannot stand out: this html sets it off with a border, which Checkbox measures (or use type 15% larger than the text around it)." : ""}`,
|
|
24515
24783
|
placeholders,
|
|
24516
24784
|
verified: v.passes,
|
|
24517
24785
|
remaining_checks: [.../* @__PURE__ */ new Set([...v.remaining, "signup.consent.affirmative"])],
|
|
@@ -24523,7 +24791,7 @@ async function buildReplacement(plan2, jurisdictions, asOf, now, opts = {}) {
|
|
|
24523
24791
|
...short ? { short_disclosure_text: short } : {},
|
|
24524
24792
|
consent_label: shown.consentLabel,
|
|
24525
24793
|
button_text: shown.cta,
|
|
24526
|
-
html: htmlDisclosureComponent(shown, sha),
|
|
24794
|
+
html: htmlDisclosureComponent(shown, sha, boxed ? { boxed: true } : {}),
|
|
24527
24795
|
placeholders,
|
|
24528
24796
|
verified: v.passes,
|
|
24529
24797
|
remaining_checks: v.remaining,
|
|
@@ -24615,15 +24883,20 @@ function modelDisclosureCheckout(input) {
|
|
|
24615
24883
|
return { captures, viewport: VIEWPORT };
|
|
24616
24884
|
}
|
|
24617
24885
|
function nextActionFor(checks, replacement, input) {
|
|
24618
|
-
const
|
|
24619
|
-
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");
|
|
24620
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)}` : "";
|
|
24621
24891
|
if (failing.length) {
|
|
24622
24892
|
return {
|
|
24623
24893
|
type: "replace_text",
|
|
24624
|
-
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}`
|
|
24625
24895
|
};
|
|
24626
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
|
+
}
|
|
24627
24900
|
if (review2.length && review2.every((c) => c.check_id === "signup.disclosure.conspicuous")) {
|
|
24628
24901
|
const same = norm(input.text) === norm(replacement.disclosure_text);
|
|
24629
24902
|
if (!input.bold && !input.boxed) {
|
|
@@ -24672,20 +24945,24 @@ async function checkDisclosure(input, opts = {}) {
|
|
|
24672
24945
|
label: str2(c.label, 1e3, "consent.label") ?? DEFAULT_CONSENT_LABEL,
|
|
24673
24946
|
required: bool2(c.required, true, "consent.required")
|
|
24674
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 } : {} };
|
|
24675
24952
|
const warnings = [];
|
|
24676
|
-
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" });
|
|
24677
24954
|
if (placement.contrast_ratio !== void 0 && placement.contrast_ratio < CONSPICUOUS_MIN_CONTRAST) {
|
|
24678
24955
|
warnings.push({ code: "low_contrast", message: `contrast_ratio ${placement.contrast_ratio} is below ${CONSPICUOUS_MIN_CONTRAST}:1.`, param: "placement.contrast_ratio" });
|
|
24679
24956
|
}
|
|
24680
24957
|
const buttonText = str2(input.button_text, 200, "button_text") ?? (plan2.trialDays ? "Start free trial" : "Subscribe");
|
|
24681
24958
|
const orderSummary = str2(input.order_summary, 1e3, "order_summary");
|
|
24682
24959
|
const { captures } = modelDisclosureCheckout({ text, buttonText, ...orderSummary ? { orderSummary } : {}, placement, ...consent ? { consent } : {}, asOf });
|
|
24683
|
-
const ctx = modelContext(jurisdictions, asOf,
|
|
24960
|
+
const ctx = modelContext(jurisdictions, asOf, facts, { captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
|
|
24684
24961
|
const run2 = await runChecks(ctx, SIGNUP_FIX_CHECKS, now);
|
|
24685
24962
|
let checks = shapeChecks(run2.findings);
|
|
24686
24963
|
if (!placement.same_page_as_payment) {
|
|
24687
24964
|
const onPage = modelDisclosureCheckout({ text, buttonText, ...orderSummary ? { orderSummary } : {}, placement: { ...placement, same_page_as_payment: true }, ...consent ? { consent } : {}, asOf });
|
|
24688
|
-
const textCtx = modelContext(jurisdictions, asOf,
|
|
24965
|
+
const textCtx = modelContext(jurisdictions, asOf, facts, { captures: onPage.captures, config: planConfig(plan2, asOf), ...plan2.merchantName ? { merchantNames: [plan2.merchantName] } : {} });
|
|
24689
24966
|
const byText = new Map(shapeChecks((await runChecks(textCtx, SIGNUP_FIX_CHECKS, now)).findings).map((v) => [v.check_id, v]));
|
|
24690
24967
|
checks = checks.map((v) => {
|
|
24691
24968
|
const t = byText.get(v.check_id);
|
|
@@ -24701,13 +24978,17 @@ async function checkDisclosure(input, opts = {}) {
|
|
|
24701
24978
|
};
|
|
24702
24979
|
});
|
|
24703
24980
|
}
|
|
24704
|
-
|
|
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 } : {});
|
|
24705
24986
|
const ownButton = str2(input.button_text, 200, "button_text");
|
|
24706
|
-
if (ownButton && buttonFitsPlan(ownButton, plan2)) {
|
|
24987
|
+
if (ownButton && buttonFitsPlan(ownButton, plan2) && !onStripe) {
|
|
24707
24988
|
replacement.button_text = ownButton;
|
|
24708
|
-
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 } : {});
|
|
24709
24990
|
}
|
|
24710
|
-
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}
|
|
24711
24992
|
${input.consent?.label ?? ""}`, plan2), kind: "disclosure", replacement };
|
|
24712
24993
|
}
|
|
24713
24994
|
var MAX_DISCLOSURE_CHARS, PREFLIGHT_PLAN_ID, CANCEL_URL_PLACEHOLDER, SAMPLE_CANCEL_URL, DEFAULT_CONSENT_LABEL, norm, TEXT_CONTENT_CHECKS;
|
|
@@ -24814,6 +25095,17 @@ var init_price_hint = __esm({
|
|
|
24814
25095
|
});
|
|
24815
25096
|
|
|
24816
25097
|
// ../rules/src/preflight/stripe.ts
|
|
25098
|
+
function trialPromise(texts) {
|
|
25099
|
+
for (const [field, text] of texts) {
|
|
25100
|
+
if (!text) continue;
|
|
25101
|
+
const a = analyzeTerms(text);
|
|
25102
|
+
if (!a.trialMentions.length && !a.trialLengths.length) continue;
|
|
25103
|
+
const days = a.trialLengths.map((t) => "days" in t ? t.days : null).filter((d) => typeof d === "number" && d > 0);
|
|
25104
|
+
const span = a.trialLengths[0] ?? a.trialMentions[0];
|
|
25105
|
+
return { field, quote: span.text.trim(), days };
|
|
25106
|
+
}
|
|
25107
|
+
return void 0;
|
|
25108
|
+
}
|
|
24817
25109
|
function clone(v) {
|
|
24818
25110
|
return JSON.parse(JSON.stringify(v));
|
|
24819
25111
|
}
|
|
@@ -24905,6 +25197,25 @@ function readParams(params, merchant, kind, asOf) {
|
|
|
24905
25197
|
if (trial !== void 0 && trial > 0 && custom && /[$€£]\s?\d|\b(?:charged?|billed)\b/i.test(custom) && !/\b(?:trial|free|days?)\b/i.test(custom)) {
|
|
24906
25198
|
caveats.push({ checks: ["signup.disclosure.trial_terms"], status: "fail", reason: `there is a ${trial}-day free trial, but your custom_text.submit states the charge without mentioning it, so a shopper reads it as charged today (Stripe's summary alone shows the trial) \u2014 state the ${trial}-day free trial and what is charged when it ends`, param: "params.custom_text.submit" });
|
|
24907
25199
|
}
|
|
25200
|
+
const promisedTrial = trialPromise([["custom_text.submit", msg(ctAll.submit)], ["custom_text.terms_of_service_acceptance", isObj(params.consent_collection) && params.consent_collection.terms_of_service === "required" ? msg(ctAll.terms_of_service_acceptance) : void 0], ["product description", description]]);
|
|
25201
|
+
if (promisedTrial && !(trial !== void 0 && trial > 0)) {
|
|
25202
|
+
caveats.push({
|
|
25203
|
+
checks: ["signup.disclosure.trial_terms"],
|
|
25204
|
+
status: "fail",
|
|
25205
|
+
reason: `the ${promisedTrial.field} promises a free trial (\u201C${promisedTrial.quote}\u201D), but the params run none (no subscription_data.trial_period_days or trial_end), so Stripe charges the full price today \u2014 add the trial to subscription_data, or remove the promise from the text`,
|
|
25206
|
+
param: promisedTrial.field === "product description" ? "params.line_items" : `params.${promisedTrial.field}.message`
|
|
25207
|
+
});
|
|
25208
|
+
} else if (promisedTrial && trial !== void 0 && trial > 0) {
|
|
25209
|
+
const wrongDays = promisedTrial.days.find((d) => Math.abs(d - trial) > TRIAL_DAYS_SLACK);
|
|
25210
|
+
if (wrongDays !== void 0) {
|
|
25211
|
+
caveats.push({
|
|
25212
|
+
checks: ["signup.disclosure.trial_terms"],
|
|
25213
|
+
status: "fail",
|
|
25214
|
+
reason: `the ${promisedTrial.field} promises a ${wrongDays}-day free trial (\u201C${promisedTrial.quote}\u201D), but the params run a ${trial}-day trial \u2014 state the ${trial}-day free trial subscribers get, or change subscription_data.trial_period_days`,
|
|
25215
|
+
param: promisedTrial.field === "product description" ? "params.line_items" : `params.${promisedTrial.field}.message`
|
|
25216
|
+
});
|
|
25217
|
+
}
|
|
25218
|
+
}
|
|
24908
25219
|
const uiMode = typeof params.ui_mode === "string" ? params.ui_mode : void 0;
|
|
24909
25220
|
if (uiMode === "custom" || uiMode === "elements") {
|
|
24910
25221
|
caveats.push({
|
|
@@ -25155,7 +25466,7 @@ function planAsMerchant(shape, merchant) {
|
|
|
25155
25466
|
}
|
|
25156
25467
|
function correct(params, shape, checks, forLink, style2 = "full") {
|
|
25157
25468
|
const byId = new Map(checks.map((c) => [c.check_id, c]));
|
|
25158
|
-
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"));
|
|
25159
25470
|
const needConsent = CONSENT_CHECKS2.some((id) => open(byId.get(id))) || !shape.consentRequired && needText;
|
|
25160
25471
|
const terms2 = merchantTerms(shape.plan, false);
|
|
25161
25472
|
const full = lockedTexts(terms2);
|
|
@@ -25182,6 +25493,7 @@ function correct(params, shape, checks, forLink, style2 = "full") {
|
|
|
25182
25493
|
out.custom_text = set;
|
|
25183
25494
|
} else {
|
|
25184
25495
|
for (const [k, v] of Object.entries(set)) {
|
|
25496
|
+
if (JSON.stringify(ct[k]) === JSON.stringify(v)) continue;
|
|
25185
25497
|
patch.push({ op: k in ct && ct[k] !== null ? "replace" : "add", path: `/custom_text/${k}`, value: v });
|
|
25186
25498
|
ct[k] = v;
|
|
25187
25499
|
}
|
|
@@ -25275,6 +25587,11 @@ function withResolvedPrices(input, knownPrices, amountSource = "merchant.price")
|
|
|
25275
25587
|
const m = input.merchant ?? {};
|
|
25276
25588
|
const stated = typeof m.amount === "number" ? m.amount : typeof m.price === "number" ? Math.round(m.price * 10 ** currencyDigitsOf(r.resolved.currency)) : void 0;
|
|
25277
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" });
|
|
25278
25595
|
if (stated !== void 0 && stated !== r.resolved.amount) {
|
|
25279
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" });
|
|
25280
25597
|
}
|
|
@@ -25449,7 +25766,10 @@ function modelCheckoutConfig(params, opts) {
|
|
|
25449
25766
|
// travels with its plan, so the site's checks and copy know the promo without a separate declaration.
|
|
25450
25767
|
...plan2.introAmount !== void 0 && plan2.introAmount >= 0 && plan2.introAmount < plan2.amount ? { introAmount: plan2.introAmount, ...plan2.introPeriods && plan2.introPeriods > 1 ? { introPeriods: plan2.introPeriods } : {} } : {}
|
|
25451
25768
|
},
|
|
25452
|
-
consentRequired: shape.consentRequired
|
|
25769
|
+
consentRequired: shape.consentRequired,
|
|
25770
|
+
// Round 33: the trial the params themselves run (subscription_data), 0 when none; a trial taken only from a Price
|
|
25771
|
+
// or merchant terms is not one Stripe runs for these params.
|
|
25772
|
+
trialDays: plan2.trialDays ?? 0
|
|
25453
25773
|
};
|
|
25454
25774
|
}
|
|
25455
25775
|
function noticeChannelField(params) {
|
|
@@ -25476,6 +25796,7 @@ var init_stripe2 = __esm({
|
|
|
25476
25796
|
init_compact();
|
|
25477
25797
|
init_plan_terms();
|
|
25478
25798
|
init_text();
|
|
25799
|
+
init_signup();
|
|
25479
25800
|
init_verify();
|
|
25480
25801
|
init_stripe();
|
|
25481
25802
|
init_common();
|
|
@@ -25991,9 +26312,6 @@ function stripeButtonsKind(capture) {
|
|
|
25991
26312
|
const [only] = [...kinds];
|
|
25992
26313
|
return kinds.size === 1 && only ? only : void 0;
|
|
25993
26314
|
}
|
|
25994
|
-
function stripeConsentText(kind) {
|
|
25995
|
-
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.";
|
|
25996
|
-
}
|
|
25997
26315
|
function hasPrice(t) {
|
|
25998
26316
|
return !!t && (t.price !== void 0 && t.price !== null && t.price !== "" || t.amount !== void 0 && t.amount !== null);
|
|
25999
26317
|
}
|
|
@@ -26183,7 +26501,8 @@ async function checkPageHtml(input, opts = {}) {
|
|
|
26183
26501
|
});
|
|
26184
26502
|
}
|
|
26185
26503
|
const consentOnStripe = onStripe ?? (checkoutType === "payment_link" || checkoutType === "stripe_checkout" ? checkoutType : checkoutType ? void 0 : stripeButtonsKind(capture));
|
|
26186
|
-
const
|
|
26504
|
+
const boldAround = checks.some((c) => c.check_id === "signup.disclosure.conspicuous" && /already bold, so a bolder weight cannot set them apart/.test(c.explanation ?? ""));
|
|
26505
|
+
const repOpts = { ...consentOnStripe ? { onStripe: consentOnStripe } : {}, ...boldAround ? { boxed: true } : {} };
|
|
26187
26506
|
const built = plan2 ? await buildReplacement(plan2, jurisdictions, asOf, now, { ...repOpts, page: capture }) : void 0;
|
|
26188
26507
|
const differs = plan2 && !multi ? pagePriceDiffers(run2.findings) : [];
|
|
26189
26508
|
if (plan2 && differs.length) {
|
|
@@ -26266,7 +26585,8 @@ function signupStripeObjects(input) {
|
|
|
26266
26585
|
capture: { ...m.capture, siteId: input.siteId },
|
|
26267
26586
|
consentRequired: m.consentRequired,
|
|
26268
26587
|
kind: "checkout_config",
|
|
26269
|
-
...m.plan ? { planId: m.plan.id } : {}
|
|
26588
|
+
...m.plan ? { planId: m.plan.id } : {},
|
|
26589
|
+
...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
|
|
26270
26590
|
});
|
|
26271
26591
|
}
|
|
26272
26592
|
if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", ...row.name ? { label: row.name } : {} });
|
|
@@ -26299,7 +26619,8 @@ function signupStripeObjects(input) {
|
|
|
26299
26619
|
consentRequired: m.consentRequired,
|
|
26300
26620
|
kind: "payment_link",
|
|
26301
26621
|
...link.url ? { url: link.url } : {},
|
|
26302
|
-
...m.plan ? { planId: m.plan.id } : {}
|
|
26622
|
+
...m.plan ? { planId: m.plan.id } : {},
|
|
26623
|
+
...typeof m.trialDays === "number" ? { trialDays: m.trialDays } : {}
|
|
26303
26624
|
});
|
|
26304
26625
|
}
|
|
26305
26626
|
if (m.plan) referencePlans2.push({ ...m.plan, source: "checkout_config", label: `Payment Link \u201C${name}\u201D` });
|
|
@@ -26358,6 +26679,7 @@ __export(preflight_exports, {
|
|
|
26358
26679
|
stripeParamsVerdict: () => stripeParamsVerdict,
|
|
26359
26680
|
supportContactLine: () => supportContactLine,
|
|
26360
26681
|
testRunSessionIds: () => testRunSessionIds,
|
|
26682
|
+
trialPromise: () => trialPromise,
|
|
26361
26683
|
withIntroOffers: () => withIntroOffers,
|
|
26362
26684
|
withTestRunSessions: () => withTestRunSessions
|
|
26363
26685
|
});
|
|
@@ -27005,7 +27327,7 @@ function parseObjectLiteral(source, env2 = {}, consts = {}) {
|
|
|
27005
27327
|
const p = new Parser(source, env2, consts);
|
|
27006
27328
|
if (p.peek() !== "{") return { value: null, unresolved: ["<not an object literal>"] };
|
|
27007
27329
|
const v = p.object("");
|
|
27008
|
-
return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {} };
|
|
27330
|
+
return { value: v === UNKNOWN ? null : v, unresolved: [...new Set(p.unresolved)], ...Object.keys(p.fromEnv).length ? { fromEnv: p.fromEnv } : {}, ...p.spreads.length ? { spreads: p.spreads } : {} };
|
|
27009
27331
|
}
|
|
27010
27332
|
var UNKNOWN, Parser;
|
|
27011
27333
|
var init_literal = __esm({
|
|
@@ -27021,6 +27343,7 @@ var init_literal = __esm({
|
|
|
27021
27343
|
i = 0;
|
|
27022
27344
|
unresolved = [];
|
|
27023
27345
|
fromEnv = {};
|
|
27346
|
+
spreads = [];
|
|
27024
27347
|
ws() {
|
|
27025
27348
|
for (; ; ) {
|
|
27026
27349
|
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
|
|
@@ -27157,7 +27480,9 @@ var init_literal = __esm({
|
|
|
27157
27480
|
if (!ch) return UNKNOWN;
|
|
27158
27481
|
if (this.s.startsWith("...", this.i)) {
|
|
27159
27482
|
this.i += 3;
|
|
27483
|
+
const from = this.i;
|
|
27160
27484
|
this.skipExpr();
|
|
27485
|
+
if (!path) this.spreads.push(this.s.slice(from, this.i).trim());
|
|
27161
27486
|
this.unresolved.push(`${path ? `${path}.` : ""}...`);
|
|
27162
27487
|
} else {
|
|
27163
27488
|
let key = null;
|
|
@@ -27585,13 +27910,19 @@ function detectProject(root, processEnv = {}) {
|
|
|
27585
27910
|
const payButtons = [];
|
|
27586
27911
|
const checkoutSessions = [];
|
|
27587
27912
|
const policyPaths = {};
|
|
27913
|
+
const linkedPolicies = {};
|
|
27588
27914
|
walk2(root, (abs, rel2) => {
|
|
27589
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}`;
|
|
27590
27917
|
if (route) {
|
|
27591
|
-
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;
|
|
27592
27919
|
}
|
|
27593
27920
|
const text = readText(abs);
|
|
27594
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
|
+
}
|
|
27595
27926
|
const lines = text.split("\n");
|
|
27596
27927
|
lines.forEach((line2, i) => {
|
|
27597
27928
|
for (const p of CHECKOUT_PATTERNS) {
|
|
@@ -27611,6 +27942,7 @@ function detectProject(root, processEnv = {}) {
|
|
|
27611
27942
|
}
|
|
27612
27943
|
});
|
|
27613
27944
|
if (billingPlatform === "custom" && checkoutCode.some((c) => c.kind.startsWith("stripe_"))) billingPlatform = "stripe";
|
|
27945
|
+
Object.assign(policyPaths, linkedPolicies);
|
|
27614
27946
|
const site = detectSiteUrl(root, env2, pkg);
|
|
27615
27947
|
const stripeKey = findStripeKey(env2, processEnv, envFile);
|
|
27616
27948
|
const brand = detectBrand(root, layoutFile, typeof pkg?.name === "string" ? pkg.name : void 0);
|
|
@@ -27756,8 +28088,8 @@ function plansFromLiterals(text, file) {
|
|
|
27756
28088
|
if (!interval) continue;
|
|
27757
28089
|
const price3 = /\bprice\s*:\s*(\d+(?:\.\d{1,2})?)\b/.exec(body)?.[1];
|
|
27758
28090
|
const minor = /\b(?:amount|unit_amount|price_cents|cents)\s*:\s*(\d+)\b/.exec(body)?.[1];
|
|
27759
|
-
const
|
|
27760
|
-
if (
|
|
28091
|
+
const major2 = price3 !== void 0 ? Number(price3) : minor !== void 0 ? Number(minor) / 100 : void 0;
|
|
28092
|
+
if (major2 === void 0 || !(major2 > 0)) continue;
|
|
27761
28093
|
const name = /\b(?:name|title|label)\s*:\s*['"]([^'"]{1,80})['"]/.exec(body)?.[1] ?? m[1]?.trim();
|
|
27762
28094
|
if (!name) continue;
|
|
27763
28095
|
const currency = /\bcurrency\s*:\s*['"]([a-z]{3})['"]/i.exec(body)?.[1]?.toLowerCase() ?? "usd";
|
|
@@ -27766,7 +28098,7 @@ function plansFromLiterals(text, file) {
|
|
|
27766
28098
|
if (out.some((p) => p.name === name)) continue;
|
|
27767
28099
|
const key = m[1]?.trim() && /^[A-Za-z_$][\w$]*$/.test(m[1].trim()) ? m[1].trim() : void 0;
|
|
27768
28100
|
const ids = [...new Set([...body.matchAll(/['"`](price_[A-Za-z0-9_]+)['"`]/g)].map((x) => x[1]))];
|
|
27769
|
-
out.push({ name, ...key ? { key } : {}, price:
|
|
28101
|
+
out.push({ name, ...key ? { key } : {}, price: major2, currency, interval, ...count && Number(count) > 1 ? { interval_count: Number(count) } : {}, ...trial && Number(trial) > 0 ? { trial_days: Number(trial) } : {}, ...ids.length ? { price_ids: ids } : {}, source: file });
|
|
27770
28102
|
}
|
|
27771
28103
|
return out;
|
|
27772
28104
|
}
|
|
@@ -27890,6 +28222,10 @@ function pairCall(root, s2, plans, priceIds, calls) {
|
|
|
27890
28222
|
}
|
|
27891
28223
|
return { ref: literalIds[0] ?? exprs[0] };
|
|
27892
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
|
+
}
|
|
27893
28229
|
function scaffoldConfig(root, env2 = {}, facts = {}) {
|
|
27894
28230
|
const det = detectProject(root, env2);
|
|
27895
28231
|
const todos = [];
|
|
@@ -28026,6 +28362,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
|
|
|
28026
28362
|
} else stripe.push(entry);
|
|
28027
28363
|
}
|
|
28028
28364
|
const emails = [];
|
|
28365
|
+
const supportSeen = /* @__PURE__ */ new Map();
|
|
28029
28366
|
const pages = [];
|
|
28030
28367
|
const disclosures = [];
|
|
28031
28368
|
walk3(root, (rel2) => {
|
|
@@ -28042,6 +28379,8 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
|
|
|
28042
28379
|
const hint = intervalHintOf(basename(rel2).replace(/\.[^.]+$/, ""));
|
|
28043
28380
|
const byName = hint && plans.length > 1 ? plans.filter((p) => p.interval === hint && (Number(p.interval_count ?? 1) || 1) === 1) : [];
|
|
28044
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);
|
|
28045
28384
|
emails.push({ name: unique(names, `Email: ${basename(rel2)}`), kind, file: rel2, ...plan2, ...Object.keys(tags).length ? { merge_tags: tags } : {} });
|
|
28046
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")` : ""}`);
|
|
28047
28386
|
return;
|
|
@@ -28088,6 +28427,11 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
|
|
|
28088
28427
|
if (!disclosures.length && det.payButtons.length && !det.payButtons.some((b) => inPages(b.file))) {
|
|
28089
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`);
|
|
28090
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
|
+
}
|
|
28091
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)');
|
|
28092
28436
|
const checks = {};
|
|
28093
28437
|
if (stripe.length) checks.stripe_checkout = stripe;
|
|
@@ -28106,7 +28450,7 @@ function scaffoldConfig(root, env2 = {}, facts = {}) {
|
|
|
28106
28450
|
todoValues: countTodoValues({ merchant, plans, checks })
|
|
28107
28451
|
};
|
|
28108
28452
|
}
|
|
28109
|
-
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;
|
|
28110
28454
|
var init_scaffold = __esm({
|
|
28111
28455
|
"src/ci/scaffold.ts"() {
|
|
28112
28456
|
"use strict";
|
|
@@ -28135,6 +28479,7 @@ var init_scaffold = __esm({
|
|
|
28135
28479
|
return n;
|
|
28136
28480
|
};
|
|
28137
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;
|
|
28138
28483
|
}
|
|
28139
28484
|
});
|
|
28140
28485
|
|
|
@@ -28304,8 +28649,8 @@ function loaderFlags(file, dir, nodeVersion = process.versions.node, features =
|
|
|
28304
28649
|
if (hasTsx(dir)) return ["--import", "tsx"];
|
|
28305
28650
|
if (/\.[jt]sx$/.test(file)) return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: JSX modules need tsx (npm install -D tsx), or export the rendered { subject, html } from a .js / .ts file`;
|
|
28306
28651
|
if (features.typescript) return [];
|
|
28307
|
-
const [
|
|
28308
|
-
if (
|
|
28652
|
+
const [major2, minor] = nodeVersion.split(".").map(Number);
|
|
28653
|
+
if (major2 > 22 || major2 === 22 && minor >= 6) return ["--experimental-strip-types"];
|
|
28309
28654
|
return declaresTsx(dir) ? `${file}: ${NOT_INSTALLED}` : `${file}: TypeScript modules need Node 22.6+ (type stripping) or tsx in the project (npm install -D tsx); or point "file" at a JSON export`;
|
|
28310
28655
|
}
|
|
28311
28656
|
function runModule(dir, ref, args = [], env2 = process.env, timeoutMs = 3e4) {
|
|
@@ -28375,7 +28720,23 @@ function paramsFromSource(text, file, call = 1, env2 = {}, consts = {}) {
|
|
|
28375
28720
|
if (!lit) throw new InputError(`${file}:${line2}: the params are not an object literal; use "module" (a function returning the params) or "file" instead`);
|
|
28376
28721
|
const parsed = parseObjectLiteral(lit, env2, consts);
|
|
28377
28722
|
if (!parsed.value) throw new InputError(`${file}:${line2}: could not read the params object`);
|
|
28378
|
-
|
|
28723
|
+
const unresolved = [...parsed.unresolved, ...spreadUnread(parsed.value, parsed.spreads ?? [], consts)];
|
|
28724
|
+
return { params: parsed.value, line: line2, unresolved, ...parsed.fromEnv ? { fromEnv: parsed.fromEnv } : {} };
|
|
28725
|
+
}
|
|
28726
|
+
function spreadUnread(params, spreads, consts = {}) {
|
|
28727
|
+
const out = [];
|
|
28728
|
+
for (const expr of spreads) {
|
|
28729
|
+
const name = expr.replace(/\s+/g, "").replace(/\?\./g, ".");
|
|
28730
|
+
const seen = Object.prototype.hasOwnProperty.call(consts, name) ? consts[name] : expr;
|
|
28731
|
+
const visible = /[{]/.test(seen);
|
|
28732
|
+
for (const k of SPREAD_VERDICT_KEYS) {
|
|
28733
|
+
if (params[k] !== void 0) continue;
|
|
28734
|
+
if (visible && !new RegExp(`\\b${k}\\b`).test(seen)) continue;
|
|
28735
|
+
const entry = `${k} (may come from ...${expr.length > 40 ? `${expr.slice(0, 37)}\u2026` : expr})`;
|
|
28736
|
+
if (!out.includes(entry)) out.push(entry);
|
|
28737
|
+
}
|
|
28738
|
+
}
|
|
28739
|
+
return out;
|
|
28379
28740
|
}
|
|
28380
28741
|
function lineOfAny(text, needles) {
|
|
28381
28742
|
const lower2 = text.toLowerCase();
|
|
@@ -28486,7 +28847,8 @@ async function stripeJob(loaded, e, o, root) {
|
|
|
28486
28847
|
if (r.unresolved.length) {
|
|
28487
28848
|
job.partial = true;
|
|
28488
28849
|
job.unresolved = r.unresolved;
|
|
28489
|
-
|
|
28850
|
+
const listed = r.unresolved.some((p) => / \(may come from \.\.\./.test(p)) ? r.unresolved.filter((p) => p !== "...") : r.unresolved;
|
|
28851
|
+
job.warnings.push(`Read statically; ${listed.length} value(s) are expressions Checkbox cannot evaluate (${listed.slice(0, 4).join(", ")}${listed.length > 4 ? ", \u2026" : ""}). Give the price in "plans" (and "plan" here), or use "module" to return the exact params.`);
|
|
28490
28852
|
}
|
|
28491
28853
|
}
|
|
28492
28854
|
if (!params || typeof params !== "object" || Array.isArray(params)) throw new InputError("the Checkout Session params must be a JSON object");
|
|
@@ -28584,7 +28946,8 @@ function pageJob(loaded, e, o, root) {
|
|
|
28584
28946
|
const { merchant_name: _m, cancel_url: _c, ...own } = planTerms(config, pickPlan(config, n));
|
|
28585
28947
|
return own;
|
|
28586
28948
|
});
|
|
28587
|
-
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) };
|
|
28588
28951
|
if (e.source) {
|
|
28589
28952
|
job.target = { file: rel(loaded, root, e.source), line: 1 };
|
|
28590
28953
|
job.inputs.push(e.source);
|
|
@@ -28622,12 +28985,12 @@ function pageJob(loaded, e, o, root) {
|
|
|
28622
28985
|
function planMoney(plan2) {
|
|
28623
28986
|
if (!plan2) return void 0;
|
|
28624
28987
|
const cur = (plan2.currency ?? "usd").toUpperCase();
|
|
28625
|
-
const
|
|
28626
|
-
if (
|
|
28988
|
+
const major2 = typeof plan2.amount === "number" ? plan2.amount / 100 : typeof plan2.price === "number" ? plan2.price : typeof plan2.price === "string" && /^[\d.]+$/.test(plan2.price) ? Number(plan2.price) : void 0;
|
|
28989
|
+
if (major2 === void 0) return void 0;
|
|
28627
28990
|
try {
|
|
28628
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(
|
|
28991
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: cur }).format(major2);
|
|
28629
28992
|
} catch {
|
|
28630
|
-
return `${
|
|
28993
|
+
return `${major2.toFixed(2)} ${cur}`;
|
|
28631
28994
|
}
|
|
28632
28995
|
}
|
|
28633
28996
|
function fillMergeTags(msg2, tags, config, plan2) {
|
|
@@ -28753,7 +29116,7 @@ async function planJobs(loaded, o, root) {
|
|
|
28753
29116
|
}
|
|
28754
29117
|
return jobs;
|
|
28755
29118
|
}
|
|
28756
|
-
var InputError, NOT_INSTALLED, CHILD, jurisdictionsOf;
|
|
29119
|
+
var InputError, NOT_INSTALLED, CHILD, SPREAD_VERDICT_KEYS, jurisdictionsOf;
|
|
28757
29120
|
var init_inputs = __esm({
|
|
28758
29121
|
"src/ci/inputs.ts"() {
|
|
28759
29122
|
"use strict";
|
|
@@ -28781,6 +29144,7 @@ if (typeof v === 'function') v = await v(...JSON.parse(argsJson));
|
|
|
28781
29144
|
process.stdout.write('\\n__CHECKBOX_JSON__' + JSON.stringify(v === undefined ? null : v) + '\\n');
|
|
28782
29145
|
process.exit(0);
|
|
28783
29146
|
`;
|
|
29147
|
+
SPREAD_VERDICT_KEYS = ["mode", "consent_collection", "custom_text", "subscription_data", "discounts"];
|
|
28784
29148
|
jurisdictionsOf = (config) => config.jurisdictions ? { jurisdictions: config.jurisdictions } : {};
|
|
28785
29149
|
}
|
|
28786
29150
|
});
|
|
@@ -28899,6 +29263,7 @@ var init_src3 = __esm({
|
|
|
28899
29263
|
init_checkout_plans();
|
|
28900
29264
|
init_notices_on();
|
|
28901
29265
|
init_stripe_account();
|
|
29266
|
+
init_intro_terms();
|
|
28902
29267
|
CATALOG2 = [...CATALOG];
|
|
28903
29268
|
}
|
|
28904
29269
|
});
|
|
@@ -29312,6 +29677,17 @@ function operationFor2(kind) {
|
|
|
29312
29677
|
function onlineEngine(client) {
|
|
29313
29678
|
return { mode: "online", run: (kind, body) => client.sdk.call(operationFor2(kind), body) };
|
|
29314
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
|
+
}
|
|
29315
29691
|
function resultOf(status, exit, strict) {
|
|
29316
29692
|
if (exit === 0) return status === "needs_review" ? "passed with items to review (they do not fail this run)" : "passed";
|
|
29317
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})`;
|
|
@@ -29322,7 +29698,31 @@ function resultOf(status, exit, strict) {
|
|
|
29322
29698
|
function ciVocabulary(text) {
|
|
29323
29699
|
return text.replace(/Pass every plan the page sells \(plans: \[\.\.\.\]\)/g, 'Set "plan": "all" on this page entry in checkbox.json (every plan the page sells)').replace(/Pass plans with every plan the page sells/g, 'Set "plan": "all" on this page entry in checkbox.json').replace(/\(page_kind "checkout"\)/g, '(a pages entry with "kind": "checkout")').replace(/page_kind "checkout"/g, '"kind": "checkout"').replace(/check the renewal (text|text itself) with check_disclosure/g, 'check the renewal $1 with a "disclosures" entry').replace(/\bcheck_disclosure\b/g, 'a "disclosures" entry').replace(/pass merchant\.price \(e\.g\. 19\.99\), merchant\.currency and merchant\.interval/g, 'add the plan to "plans" in checkbox.json (price, currency, interval) and name it in "plan" on this entry').replace(/merchant\.price \+ merchant\.interval/g, 'a plan in "plans" of checkbox.json (and "plan" on this entry)').replace(/pass merchant\.price and merchant\.interval/gi, 'add the plan to "plans" in checkbox.json and name it in "plan" on this entry').replace(/merchant\.intro_percent_off \(or intro_amount_off\) and merchant\.intro_periods/g, "intro_percent_off (or intro_amount_off) and intro_periods on the plan in checkbox.json").replace(/merchant\.intro_\*/g, "the plan's intro_* in checkbox.json").replace(/merchant\.trial_days \((\d+)\)/g, "trial_days ($1) of the plan in checkbox.json").replace(/merchant\.price was ignored/g, "the plan's price in checkbox.json was ignored").replace(/comes from merchant\.price and is not verified against the Price\. Connect Stripe on the site, or register the params with createStripeCheckoutConfig on a site whose Stripe account has this Price\./g, "comes from the plan's price in checkbox.json and is not verified against the Price. Connect Stripe on the site in Checkbox, then run checkbox ci --online (CHECKBOX_SECRET_KEY) so Checkbox reads the Price.").replace(/comes from merchant\.price\b/g, "comes from the plan's price in checkbox.json").replace(/\bmerchant\.price says\b/g, "The plan's price in checkbox.json says").replace(/\(or pass merchant\.cancel_url\)/g, "(or set merchant.cancel_url in checkbox.json)").replace(/to confirm, check the email again with sample values in their place \(keep the merge tags in your code\)\./g, `give each tag its value under "merge_tags" in checkbox.json (on this entry, or top level for every email), e.g. "merge_tags": { "price": "plan.price", "interval": "plan.interval" }: Checkbox fills them from the entry's plan before checking, and your template keeps its tags.`).replace(/In test mode, pass the page source as html\./g, 'With a test key, list the built page under "file" in checkbox.json instead of "url" (or run --online with a live key, ck_live_\u2026).');
|
|
29324
29700
|
}
|
|
29325
|
-
function pricingPageFixes(fixes) {
|
|
29701
|
+
function pricingPageFixes(fixes, replacements = []) {
|
|
29702
|
+
return concreteCardFixes(pricingPageFixesRaw(fixes), replacements);
|
|
29703
|
+
}
|
|
29704
|
+
function concreteCardFixes(fixes, replacements) {
|
|
29705
|
+
if (!replacements.length) return fixes;
|
|
29706
|
+
const lineFor = (card) => {
|
|
29707
|
+
const name = card.split(", ")[0].trim().toLowerCase();
|
|
29708
|
+
const r = replacements.find((x) => x.plan.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase() === name);
|
|
29709
|
+
return r?.short_disclosure_text || r?.disclosure_text || void 0;
|
|
29710
|
+
};
|
|
29711
|
+
const covered = /* @__PURE__ */ new Set();
|
|
29712
|
+
const out = fixes.map((f) => {
|
|
29713
|
+
const m = /^Plan card “([^”]+)”: /.exec(f);
|
|
29714
|
+
if (!m || !/"<price> every <interval>[^"]*"/.test(f)) return f;
|
|
29715
|
+
const line2 = lineFor(m[1]);
|
|
29716
|
+
if (!line2) return f;
|
|
29717
|
+
if (/cancel anytime online/i.test(line2)) covered.add(m[1]);
|
|
29718
|
+
return f.replace(/"<price> every <interval>[^"]*"/, `"${line2}"`);
|
|
29719
|
+
});
|
|
29720
|
+
return out.filter((f) => {
|
|
29721
|
+
const m = /^Plan card “([^”]+)”: Add "Cancel anytime online" to the renewal line/.exec(f);
|
|
29722
|
+
return !(m && covered.has(m[1]));
|
|
29723
|
+
});
|
|
29724
|
+
}
|
|
29725
|
+
function pricingPageFixesRaw(fixes) {
|
|
29326
29726
|
const reworded = fixes.map(
|
|
29327
29727
|
(f) => f.replace(/,? before the card and email fields and the (?:pay|purchase) button/g, ", next to the plan's button").replace(/,? before the card and email fields/g, ", next to the plan's button")
|
|
29328
29728
|
);
|
|
@@ -29371,8 +29771,10 @@ function fixesOf(r, onlyNeedsFix = false) {
|
|
|
29371
29771
|
const plain = cards.length > 1 ? cardClauses(c.explanation).filter((x) => /look like the surrounding text/.test(x.clause)).map((x) => x.card) : [];
|
|
29372
29772
|
if (plain.length && plain.length < cards.length && /renewal line/i.test(base)) {
|
|
29373
29773
|
cards = cards.filter((x) => !plain.includes(x));
|
|
29374
|
-
const
|
|
29375
|
-
|
|
29774
|
+
for (const x of cardClauses(c.explanation).filter((y) => plain.includes(y.card))) {
|
|
29775
|
+
const standOut = `Plan card \u201C${x.card}\u201D: ${setOffFix(x.clause)}`;
|
|
29776
|
+
if (!out.includes(standOut)) out.push(standOut);
|
|
29777
|
+
}
|
|
29376
29778
|
}
|
|
29377
29779
|
const text = cards.length && base ? `Plan card ${cards.map((x) => `\u201C${x}\u201D`).join(", ")}: ${base}` : base;
|
|
29378
29780
|
if (text && !out.includes(text)) out.push(text);
|
|
@@ -29407,17 +29809,22 @@ function correctedOf(type, r, partial = false, placeholders, pricingPage = false
|
|
|
29407
29809
|
if (type === "stripe_checkout" && r.correction?.patch?.length) {
|
|
29408
29810
|
return { format: "json", title: `Corrected params${r.correction.verification?.passes ? " (verified by the rules engine)" : ""}${placeholders ? "; keep your own values where it says {{\u2026}}" : ""}`, body: JSON.stringify(withPlaceholders(r.correction.corrected_params, placeholders), null, 2) };
|
|
29409
29811
|
}
|
|
29410
|
-
const
|
|
29411
|
-
if (type === "page" &&
|
|
29812
|
+
const allPlans = (r.plan_replacements ?? []).filter((p) => p.disclosure_text);
|
|
29813
|
+
if (type === "page" && allPlans.length >= 2 && ciStatusOf(r) !== "pass") {
|
|
29814
|
+
const named = (r.checks ?? []).filter((c) => (c.status === "fail" || c.status === "needs_review") && !(c.compliance_status && OK_STATUSES.has(c.compliance_status))).flatMap((c) => failingCards(c.explanation)).map((x) => x.split(", ")[0].trim().toLowerCase());
|
|
29815
|
+
const failing = allPlans.filter((p) => named.includes(p.plan.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase()));
|
|
29816
|
+
const perPlan = failing.length ? failing : allPlans;
|
|
29412
29817
|
const all = perPlan.every((p) => p.verified);
|
|
29413
29818
|
return {
|
|
29414
29819
|
format: "text",
|
|
29415
|
-
title: `Suggested replacement per plan, each directly above its own plan's button${all ? " (each passes the checks, verified by the rules engine)" : ""}`,
|
|
29820
|
+
title: perPlan.length === 1 ? `Suggested replacement for ${perPlan[0].plan.replace(/\s*\([^)]*\)\s*$/, "")}, directly above its button${all ? " (passes the checks, verified by the rules engine)" : ""}` : `Suggested replacement per plan, each directly above its own plan's button${all ? " (each passes the checks, verified by the rules engine)" : ""}`,
|
|
29416
29821
|
// Round 32 (pilot 31, ci-pr-agent): a pricing page links to the checkout, where consent is asked (Stripe Checkout
|
|
29417
29822
|
// shows its own checkbox), so no consent checkbox label is suggested for it.
|
|
29418
|
-
body: perPlan.map((p) => `${p.plan}:
|
|
29823
|
+
body: `${perPlan.map((p) => `${p.plan}:
|
|
29419
29824
|
${p.disclosure_text}${p.consent_label && !pricingPage ? `
|
|
29420
|
-
Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
|
|
29825
|
+
Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")}${perPlan.some((p) => p.html && BOXED_HTML.test(p.html)) ? `
|
|
29826
|
+
|
|
29827
|
+
${BOXED_LINE}` : ""}`
|
|
29421
29828
|
};
|
|
29422
29829
|
}
|
|
29423
29830
|
if ((type === "disclosure" || type === "page") && r.replacement?.disclosure_text && ciStatusOf(r) !== "pass") {
|
|
@@ -29429,7 +29836,9 @@ Consent checkbox label: ${p.consent_label}` : ""}`).join("\n\n")
|
|
|
29429
29836
|
|
|
29430
29837
|
Fuller version: ${r.replacement.disclosure_text}` : r.replacement.disclosure_text}${r.replacement.consent_label ? `
|
|
29431
29838
|
|
|
29432
|
-
Consent checkbox label: ${r.replacement.consent_label}` : ""}`
|
|
29839
|
+
Consent checkbox label: ${r.replacement.consent_label}` : ""}${r.replacement.html && BOXED_HTML.test(r.replacement.html) ? `
|
|
29840
|
+
|
|
29841
|
+
${BOXED_LINE}` : ""}`
|
|
29433
29842
|
};
|
|
29434
29843
|
}
|
|
29435
29844
|
if (type === "email" && r.template && ciStatusOf(r) !== "pass" && r.verdict !== "not_applicable") {
|
|
@@ -29505,6 +29914,33 @@ function unverifiedOffer(r) {
|
|
|
29505
29914
|
if (coupon && open2.length) return coupon.message;
|
|
29506
29915
|
return void 0;
|
|
29507
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
|
+
}
|
|
29939
|
+
function sitePlansDifferInCi(message) {
|
|
29940
|
+
const m = /\(you sent (.+?); (site_\w+) has (.+?)\)(?:, then check again|\. This result is judged)/.exec(message);
|
|
29941
|
+
if (!m) return message;
|
|
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.`;
|
|
29943
|
+
}
|
|
29508
29944
|
async function runJob(job, engine, config, opts = {}) {
|
|
29509
29945
|
const base = { name: job.name, type: job.type, ...job.plan ? { plan: job.plan } : {}, target: { ...job.target }, ...job.fromSource ? { fromSource: true } : {}, warnings: [...job.warnings] };
|
|
29510
29946
|
if (job.error) return { ...base, status: "error", label: "Could not run", message: job.error, fixes: [] };
|
|
@@ -29525,11 +29961,21 @@ async function runJob(job, engine, config, opts = {}) {
|
|
|
29525
29961
|
}
|
|
29526
29962
|
const alerts = job.warnings.filter((w) => /^The params and checkbox\.json disagree/.test(w));
|
|
29527
29963
|
for (const w of report.warnings ?? []) {
|
|
29528
|
-
|
|
29964
|
+
if (w.code === "price_read") {
|
|
29965
|
+
Object.assign(base, { stripe: [...base.stripe ?? [], w.message] });
|
|
29966
|
+
continue;
|
|
29967
|
+
}
|
|
29968
|
+
const text = w.code === "site_plans_differ" ? sitePlansDifferInCi(w.message) : ciVocabulary(w.message);
|
|
29529
29969
|
base.warnings.push(text);
|
|
29530
29970
|
if (ALERT_CODES.has(w.code)) alerts.push(text);
|
|
29531
29971
|
}
|
|
29532
29972
|
if (alerts.length) Object.assign(base, { alerts });
|
|
29973
|
+
const onlyPlansDiffer = (report.warnings ?? []).some((w) => w.code === "site_plans_differ") && report.verdict !== "fail" && report.compliance_status !== "needs_fix" && (report.checks ?? []).length > 0 && (report.checks ?? []).every((c) => c.status === "pass" || c.status === "not_applicable" || !!c.compliance_status && OK_STATUSES.has(c.compliance_status));
|
|
29974
|
+
if (onlyPlansDiffer) {
|
|
29975
|
+
const pending = Number(report.compliance?.counts?.pending_live_check) || 0;
|
|
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" } };
|
|
29977
|
+
}
|
|
29978
|
+
if (job.type === "page" && job.body?.checkout_type === "stripe_checkout") report = consentOnStripeCovered(report);
|
|
29533
29979
|
const status = ciStatusOf(report);
|
|
29534
29980
|
const params = job.type === "stripe_checkout" ? job.body?.params ?? void 0 : void 0;
|
|
29535
29981
|
let fixes = (status === "pass" || status === "not_applicable" ? [] : fixesOf(report)).map((f) => ciVocabulary(job.type === "stripe_checkout" ? stripeConsentFix(f, params) : f));
|
|
@@ -29540,11 +29986,14 @@ async function runJob(job, engine, config, opts = {}) {
|
|
|
29540
29986
|
fixes.unshift(`Checkbox cannot read the terms behind ${tags.join(", ")}. Add "merge_tags": { ${sample.join(", ")} } to this entry in checkbox.json (plan.price, plan.interval, \u2026 take the entry's plan), then run checkbox ci again; no render module is needed.`);
|
|
29541
29987
|
}
|
|
29542
29988
|
const pricingPage = job.type === "page" && job.body?.page_kind === "pricing";
|
|
29543
|
-
|
|
29544
|
-
|
|
29545
|
-
const
|
|
29989
|
+
const cardLines = (report.plan_replacements ?? []).filter((p) => p.disclosure_text);
|
|
29990
|
+
if (pricingPage) fixes = pricingPageFixes(fixes, cardLines);
|
|
29991
|
+
const unreadText = (job.unresolved ?? []).filter((p) => /^(?:custom_text|consent_collection|discounts|subscription_data|allow_promotion_codes|payment_method_collection)\b|^mode\b/.test(p));
|
|
29992
|
+
const unread = job.type === "stripe_checkout" && unreadText.length > 0 && (status === "fail" || status === "needs_review" || status === "not_applicable" && unreadText.some((p) => /^mode\b/.test(p)) || status === "pass" && unreadText.some((p) => /^(?:custom_text|consent_collection|discounts|subscription_data(?:\.trial| \(may come from))/.test(p)));
|
|
29993
|
+
const spreadOnly = unreadText.length > 0 && unreadText.every((p) => / \(may come from \.\.\./.test(p));
|
|
29546
29994
|
if (unread) {
|
|
29547
|
-
|
|
29995
|
+
const what = spreadOnly ? `${unreadText.slice(0, 3).map((p) => p.replace(/ \((may come from [^)]*)\)$/, " $1")).join(", ")}: Checkbox does not follow a spread statically` : `${unreadText.slice(0, 3).join(", ")} ${unreadText.length === 1 ? "is an expression" : "are expressions"} Checkbox cannot read statically (e.g. a ternary or PLANS.monthly.*)`;
|
|
29996
|
+
fixes.splice(0, fixes.length, `${what}, so the checks that depend on ${unreadText.length === 1 ? "it were" : "them were"} not checked (no fix is given for a value Checkbox did not read). Use "module" (a function returning the exact params for each plan) or "file" instead of "source"${spreadOnly ? ", or write these keys in the params literal" : ""}.`);
|
|
29548
29997
|
}
|
|
29549
29998
|
const literal = [];
|
|
29550
29999
|
if (unread) {
|
|
@@ -29595,7 +30044,7 @@ async function runJob(job, engine, config, opts = {}) {
|
|
|
29595
30044
|
...base,
|
|
29596
30045
|
status: finalStatus,
|
|
29597
30046
|
...blocking ? { blocking: true } : {},
|
|
29598
|
-
label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${unreadText[0]} is an expression` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : unreadCoupon ? "Needs review \xB7 coupon not read" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary)) : void 0),
|
|
30047
|
+
label: unread ? `${literal.length ? `${literal.length} fix${literal.length === 1 ? "" : "es"} needed \xB7 ` : ""}Not checked: ${spreadOnly ? unreadText[0].replace(/ \((may come from [^)]*)\)$/, " $1") : `${unreadText[0]} is an expression`}` : declaredCoupon ? "Needs review \xB7 coupon declared, not verified" : unreadCoupon ? "Needs review \xB7 coupon not read" : countedLabel(report.compliance?.label || DEFAULT_LABEL[status], report, pricingPage ? (f) => pricingPageFixes(f.map(ciVocabulary), cardLines) : void 0),
|
|
29599
30048
|
...fixes[0] ? { fix: fixes[0] } : {},
|
|
29600
30049
|
fixes,
|
|
29601
30050
|
...corrected ? { corrected } : {},
|
|
@@ -29667,7 +30116,7 @@ async function runCi(jobs, engine, opts) {
|
|
|
29667
30116
|
result: resultOf(status, exit, opts.strict)
|
|
29668
30117
|
};
|
|
29669
30118
|
}
|
|
29670
|
-
var ENGINE_KIND, STRIPE_OBJECTS_OFFLINE_NOTICE, CI_DISCLAIMER, RANK2, OK_STATUSES,
|
|
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;
|
|
29671
30120
|
var init_runner = __esm({
|
|
29672
30121
|
"src/ci/runner.ts"() {
|
|
29673
30122
|
"use strict";
|
|
@@ -29676,6 +30125,7 @@ var init_runner = __esm({
|
|
|
29676
30125
|
init_src();
|
|
29677
30126
|
init_types2();
|
|
29678
30127
|
init_stripe_verdict();
|
|
30128
|
+
init_set_off_fix();
|
|
29679
30129
|
init_config3();
|
|
29680
30130
|
ENGINE_KIND = {
|
|
29681
30131
|
stripe_checkout: "stripe_checkout_session",
|
|
@@ -29684,11 +30134,24 @@ var init_runner = __esm({
|
|
|
29684
30134
|
email: "email",
|
|
29685
30135
|
notice_schedule: "notice_schedule"
|
|
29686
30136
|
};
|
|
29687
|
-
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");
|
|
29688
30150
|
CI_DISCLAIMER = "Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.";
|
|
29689
30151
|
RANK2 = { fail: 0, needs_review: 1 };
|
|
29690
30152
|
OK_STATUSES = /* @__PURE__ */ new Set(["compliant", "pending_live_check", "verified_live", "not_applicable"]);
|
|
29691
|
-
|
|
30153
|
+
BOXED_HTML = /border:1px solid currentColor/;
|
|
30154
|
+
BOXED_LINE = 'Style: the text next to the buttons is already bold, so a semibold line cannot stand out. Put each line in a border (style="border:1px solid currentColor;border-radius:6px;padding:8px 12px"), or make it 15% larger than the text around it: Checkbox measures both.';
|
|
29692
30155
|
NOTICE_TO_EMAIL = {
|
|
29693
30156
|
acknowledgment: "acknowledgment",
|
|
29694
30157
|
trial_ending: "trial_reminder",
|
|
@@ -29707,6 +30170,8 @@ var init_runner = __esm({
|
|
|
29707
30170
|
cancellation_confirmation: "cancellation confirmation"
|
|
29708
30171
|
};
|
|
29709
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"];
|
|
29710
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"]);
|
|
29711
30176
|
}
|
|
29712
30177
|
});
|
|
@@ -29740,9 +30205,13 @@ function renderHuman(run2, c, opts = {}) {
|
|
|
29740
30205
|
lines.push(` ${icon(x.status, c)} ${x.name.padEnd(width)} \u2192 ${label}`);
|
|
29741
30206
|
if (x.message) lines.push(c.dim(` ${wrap(x.message, 110, " ")}`));
|
|
29742
30207
|
if (x.fix) lines.push(` fix: ${wrap(x.fix, 110, " ")}${c.dim(` (${where(x)})`)}`);
|
|
29743
|
-
|
|
30208
|
+
const restore = restoreStylingLine(x);
|
|
30209
|
+
if (restore) lines.push(` fix: ${wrap(restore, 110, " ")}`);
|
|
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, " ")}`);
|
|
30211
|
+
if (!opts.verbose && x.fixes.length > 4) lines.push(c.dim(` (+${x.fixes.length - 4} more with --verbose)`));
|
|
29744
30212
|
const alerts = x.alerts ?? [];
|
|
29745
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, " ")}`);
|
|
29746
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, " ")}`));
|
|
29747
30216
|
for (const w of x.warnings) {
|
|
29748
30217
|
const id = PRICE_UNREAD.exec(w)?.[1];
|
|
@@ -29753,12 +30222,35 @@ function renderHuman(run2, c, opts = {}) {
|
|
|
29753
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, " ")));
|
|
29754
30223
|
const tone = run2.status === "pass" ? c.green : run2.status === "needs_review" && run2.exit_code === 0 ? c.yellow : c.red;
|
|
29755
30224
|
if (run2.status === "skipped") lines.push("", c.red(wrap(nothingCheckedMessage(run2), 110, "")));
|
|
29756
|
-
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, " ")));
|
|
29757
30229
|
lines.push("", `${tone(overallLine(run2))} \xB7 exit ${run2.exit_code}${run2.result ? ` (${run2.result.replace(/ \(exit \d+\)$/, "")})` : ""}`);
|
|
29758
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."));
|
|
29759
30231
|
lines.push(c.dim(run2.disclaimer));
|
|
29760
30232
|
return lines;
|
|
29761
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
|
+
}
|
|
29762
30254
|
function annotations(run2) {
|
|
29763
30255
|
const out = [];
|
|
29764
30256
|
for (const x of run2.checks) {
|
|
@@ -29766,8 +30258,11 @@ function annotations(run2) {
|
|
|
29766
30258
|
const level = x.status === "fail" || x.status === "error" ? "error" : x.status === "needs_review" ? run2.strict || x.blocking ? "error" : "warning" : alerts.length ? "warning" : void 0;
|
|
29767
30259
|
if (!level) continue;
|
|
29768
30260
|
const props = [`file=${escProp(x.target.file)}`, ...x.target.line ? [`line=${x.target.line}`] : [], `title=${escProp(`Checkbox: ${x.name} \u2014 ${x.label}`)}`];
|
|
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.`;
|
|
29769
30264
|
const body = x.status === "error" ? x.fix ? `${x.message ?? ""}
|
|
29770
|
-
${x.fix}` : `Could not run: ${x.message ?? ""}` : [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");
|
|
29771
30266
|
out.push(`::${level} ${props.join(",")}::${escData(body)}`);
|
|
29772
30267
|
}
|
|
29773
30268
|
if (run2.status === "skipped") out.push(`::error file=${escProp(run2.config)},title=${escProp("Checkbox: nothing was checked")}::${escData(nothingCheckedMessage(run2))}`);
|
|
@@ -29780,16 +30275,19 @@ function stepSummary(run2) {
|
|
|
29780
30275
|
lines.push(`**${overallLine(run2)}** \u2014 \`${run2.config}\` \xB7 ${run2.mode}${versions}`, "");
|
|
29781
30276
|
if (run2.status === "skipped") lines.push(`\u274C ${nothingCheckedMessage(run2)}`, "");
|
|
29782
30277
|
if (run2.result) lines.push(`Result: ${run2.result}.`, "");
|
|
29783
|
-
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}`, "");
|
|
29784
30282
|
lines.push("| Check | Status | Fix | File |", "|---|---|---|---|");
|
|
29785
30283
|
for (const x of run2.checks) {
|
|
29786
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 : "";
|
|
29787
|
-
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");
|
|
29788
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))}\` |`);
|
|
29789
30287
|
}
|
|
29790
30288
|
const detailed = run2.checks.filter((x) => x.corrected || x.fixes.length > 1);
|
|
29791
30289
|
for (const x of detailed) {
|
|
29792
|
-
lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.toLowerCase() : `${x.fixes.length} fixes`)}</summary>`, "");
|
|
30290
|
+
lines.push("", `<details><summary><b>${escapeHtml(x.name)}</b>: ${escapeHtml(x.corrected ? x.corrected.title.charAt(0).toLowerCase() + x.corrected.title.slice(1) : `${x.fixes.length} fixes`)}</summary>`, "");
|
|
29793
30291
|
if (x.fixes.length) lines.push(...x.fixes.map((f) => `- ${f}`), "");
|
|
29794
30292
|
if (x.corrected) {
|
|
29795
30293
|
const fence = x.corrected.body.includes("```") ? "````" : "```";
|
|
@@ -29862,6 +30360,7 @@ function renderPlan(p, c, opts = {}) {
|
|
|
29862
30360
|
else if (t.status === "manual" && t.question) lines.push(` ${c.yellow("ask the human:")} ${t.question.text} ${c.dim(`\u2192 checkbox answer ${t.id} <${t.question.options?.join("|") ?? "yes|no"}>`)}`);
|
|
29863
30361
|
else if ((t.status === "done" || t.status === "wont_fix") && t.closed_reason) lines.push(c.dim(` closed: ${t.closed_reason.replace(/_/g, " ")}`));
|
|
29864
30362
|
else if (t.blocked_by.length) lines.push(c.dim(` after ${t.blocked_by.join(", ")}`));
|
|
30363
|
+
else if ((t.status === "open" || t.status === "in_progress") && opts.stale?.(t)) lines.push(` ${c.yellow("stale, re-verify after the site's plans are updated:")} ${opts.stale(t)}`);
|
|
29865
30364
|
else if (t.status === "open" || t.status === "in_progress") lines.push(c.dim(` ${t.where.file_hint ?? t.where.url ?? t.where.stripe_object ?? t.where.kind} \xB7 ${t.change.type} \xB7 checkbox fix ${t.id}`));
|
|
29866
30365
|
}
|
|
29867
30366
|
const hidden = p.tasks.length - tasks.length;
|
|
@@ -29873,7 +30372,7 @@ function statusColor(s2, c) {
|
|
|
29873
30372
|
return s2 === "compliant" ? c.green("all observed checks passing") : s2 === "action_required" ? c.red(s2) : c.yellow(s2);
|
|
29874
30373
|
}
|
|
29875
30374
|
function renderVerification(v, c) {
|
|
29876
|
-
const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : c.yellow("pending");
|
|
30375
|
+
const mark = v.status === "passed" ? c.green("passed") : v.status === "failed" ? c.red("failed") : v.status === "passed_with_gaps" ? c.yellow("passed with gaps (not verified)") : v.status === "passed_with_notes" ? c.yellow("passed with notes (a check outside your control needs review)") : v.status === "not_checked" ? c.yellow("not checked yet (missing evidence, nothing to change)") : v.status === "pending_live_check" ? c.yellow("live check pending (content passes; waiting for a real send)") : v.status === "needs_review" ? c.yellow("needs review (nothing failed; a check is still undecided: see next step)") : v.status === "superseded" ? c.yellow("superseded (newer evidence settled the task)") : c.yellow("pending");
|
|
29877
30376
|
const firstCheck = !v.task && v.next_action?.type === "first_check_complete";
|
|
29878
30377
|
const lines = [`${c.bold(`Verification ${v.id}`)}${v.task ? ` for ${v.task}` : ""}: ${firstCheck ? c.cyan("first check complete") : mark} (${v.method})`];
|
|
29879
30378
|
if (v.precheck) lines.push(` pre-check (${v.precheck.source}): ${v.precheck.verdict} \u2014 ${v.precheck.note}`);
|
|
@@ -29901,7 +30400,7 @@ function workflowYaml(needs = {}) {
|
|
|
29901
30400
|
const node = !!(needs.install || needs.build);
|
|
29902
30401
|
const steps = [" - uses: actions/checkout@v4"];
|
|
29903
30402
|
if (node) {
|
|
29904
|
-
steps.push(" - uses: actions/setup-node@v4", " with:", " node-version:
|
|
30403
|
+
steps.push(" - uses: actions/setup-node@v4", " with:", " node-version: 22");
|
|
29905
30404
|
if (needs.install) steps.push(` # ${needs.why?.install ?? 'checkbox.json "module" entries import your code and its dependencies.'}`, ` - run: ${needs.install}`);
|
|
29906
30405
|
if (needs.build) steps.push(` # ${needs.why?.build ?? 'checkbox.json "pages" read built HTML, which a fresh clone does not have.'}`, ` - run: ${needs.build}`);
|
|
29907
30406
|
} else {
|
|
@@ -29997,9 +30496,11 @@ var init_workflow = __esm({
|
|
|
29997
30496
|
// src/commands/ci/index.ts
|
|
29998
30497
|
var ci_exports = {};
|
|
29999
30498
|
__export(ci_exports, {
|
|
30499
|
+
anchorToChanges: () => anchorToChanges,
|
|
30000
30500
|
attachSiteForCoupons: () => attachSiteForCoupons,
|
|
30001
30501
|
couponIdsIn: () => couponIdsIn,
|
|
30002
30502
|
default: () => ci_default,
|
|
30503
|
+
diffHunks: () => diffHunks,
|
|
30003
30504
|
siteFactsFor: () => siteFactsFor,
|
|
30004
30505
|
termsChanged: () => termsChanged,
|
|
30005
30506
|
touchesConfig: () => touchesConfig,
|
|
@@ -30019,6 +30520,38 @@ function termsChanged(cwd, base, jobs) {
|
|
|
30019
30520
|
return false;
|
|
30020
30521
|
}
|
|
30021
30522
|
}
|
|
30523
|
+
function diffHunks(cwd, base, file) {
|
|
30524
|
+
let out;
|
|
30525
|
+
try {
|
|
30526
|
+
out = execFileSync2("git", ["diff", "-U0", "--no-color", "--no-ext-diff", `${base}...HEAD`, "--", file], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
|
|
30527
|
+
} catch {
|
|
30528
|
+
return [];
|
|
30529
|
+
}
|
|
30530
|
+
const hunks = [];
|
|
30531
|
+
for (const l of out.split("\n")) {
|
|
30532
|
+
const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(l);
|
|
30533
|
+
if (h) hunks.push({ start: Number(h[1]), count: h[2] === void 0 ? 1 : Number(h[2]), removed: [] });
|
|
30534
|
+
else if (hunks.length && l.startsWith("-") && !l.startsWith("---")) hunks[hunks.length - 1].removed.push(l.slice(1));
|
|
30535
|
+
}
|
|
30536
|
+
return hunks;
|
|
30537
|
+
}
|
|
30538
|
+
function anchorToChanges(checks, cwd, workspace, base) {
|
|
30539
|
+
const cache2 = /* @__PURE__ */ new Map();
|
|
30540
|
+
for (const x of checks) {
|
|
30541
|
+
if (!(x.status === "fail" || x.status === "needs_review" || x.status === "error") || !x.target.line || x.failure) continue;
|
|
30542
|
+
const file = resolve7(workspace, x.target.file);
|
|
30543
|
+
if (!cache2.has(file)) cache2.set(file, diffHunks(cwd, base, file));
|
|
30544
|
+
const hunks = cache2.get(file);
|
|
30545
|
+
if (!hunks.length) continue;
|
|
30546
|
+
const line2 = x.target.line;
|
|
30547
|
+
if (hunks.some((h) => h.count > 0 && line2 >= h.start && line2 < h.start + h.count)) continue;
|
|
30548
|
+
const dist = (h) => h.count > 0 ? Math.min(Math.abs(line2 - h.start), Math.abs(line2 - (h.start + h.count - 1))) : Math.abs(line2 - h.start);
|
|
30549
|
+
const near2 = [...hunks].sort((a, b) => dist(a) - dist(b))[0];
|
|
30550
|
+
if (dist(near2) > 40) continue;
|
|
30551
|
+
x.anchored = { from: line2, ...near2.removed.length && near2.count === 0 ? { removed: near2.removed } : {} };
|
|
30552
|
+
x.target = { ...x.target, line: Math.max(1, near2.count > 0 ? near2.start : near2.start + 1) };
|
|
30553
|
+
}
|
|
30554
|
+
}
|
|
30022
30555
|
function gitChanged(cwd, base) {
|
|
30023
30556
|
try {
|
|
30024
30557
|
const root = execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
@@ -30360,16 +30893,18 @@ ${message}
|
|
|
30360
30893
|
const noticesLine = Math.max(1, loaded.raw.split("\n").findIndex((l) => l.includes('"sent_by_checkbox"')) + 1);
|
|
30361
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 } } : {} });
|
|
30362
30895
|
const baseRef = str(p.flags, "base") ?? (ctx.env.GITHUB_BASE_REF ? `origin/${ctx.env.GITHUB_BASE_REF}` : void 0);
|
|
30363
|
-
|
|
30896
|
+
if (baseRef) anchorToChanges(run0.checks, ctx.cwd, workspace, baseRef);
|
|
30897
|
+
const run2 = !client && termsChanged(ctx.cwd, baseRef, jobs) ? withStripeNotChecked(run0, strict) : run0;
|
|
30364
30898
|
ctx.out.result(run2, () => renderHuman(run2, ctx.out.c, { verbose: bool(p.flags, "verbose") === true }));
|
|
30365
30899
|
if (gha) {
|
|
30366
30900
|
for (const a of annotations(run2)) emit(a);
|
|
30367
30901
|
writeGithubFile(ctx.env.GITHUB_STEP_SUMMARY, stepSummary(run2));
|
|
30368
|
-
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")}`);
|
|
30369
30904
|
writeGithubFile(ctx.env.GITHUB_OUTPUT, `status=${run2.status}
|
|
30370
30905
|
exit_code=${run2.exit_code}
|
|
30371
30906
|
result=${run2.result}
|
|
30372
|
-
`);
|
|
30907
|
+
${run2.stripe_not_checked ? "stripe_checked=false\n" : ""}`);
|
|
30373
30908
|
}
|
|
30374
30909
|
return run2.exit_code;
|
|
30375
30910
|
}
|
|
@@ -30558,12 +31093,20 @@ function diffLines(a, b) {
|
|
|
30558
31093
|
ops.push(...a.slice(a.length - suf).map((line2) => ({ t: " ", line: line2 })));
|
|
30559
31094
|
return ops;
|
|
30560
31095
|
}
|
|
31096
|
+
function gitLines(text) {
|
|
31097
|
+
if (text === "") return [];
|
|
31098
|
+
const lines = text.split("\n");
|
|
31099
|
+
if (text.endsWith("\n")) lines.pop();
|
|
31100
|
+
else lines[lines.length - 1] += NO_EOL;
|
|
31101
|
+
return lines;
|
|
31102
|
+
}
|
|
30561
31103
|
function unifiedDiff(before, after, path, context = 3) {
|
|
30562
31104
|
if (before === after) return "";
|
|
30563
|
-
const a = before
|
|
30564
|
-
const b = after
|
|
31105
|
+
const a = gitLines(before);
|
|
31106
|
+
const b = gitLines(after);
|
|
30565
31107
|
const ops = diffLines(a, b);
|
|
30566
31108
|
const out = [`--- a/${path}`, `+++ b/${path}`];
|
|
31109
|
+
const show2 = (o) => o.line.endsWith(NO_EOL) ? [`${o.t}${o.line.slice(0, -NO_EOL.length)}`, NO_EOL_MARKER] : [`${o.t}${o.line}`];
|
|
30567
31110
|
let i = 0;
|
|
30568
31111
|
while (i < ops.length) {
|
|
30569
31112
|
if (ops[i].t === " ") {
|
|
@@ -30585,26 +31128,50 @@ function unifiedDiff(before, after, path, context = 3) {
|
|
|
30585
31128
|
const bStart = ops.slice(0, start).filter((o) => o.t !== "-").length + 1;
|
|
30586
31129
|
const aLen = slice.filter((o) => o.t !== "+").length;
|
|
30587
31130
|
const bLen = slice.filter((o) => o.t !== "-").length;
|
|
30588
|
-
out.push(`@@ -${aLen ? aStart : aStart - 1},${aLen} +${bLen ? bStart : bStart - 1},${bLen} @@`, ...slice.
|
|
31131
|
+
out.push(`@@ -${aLen ? aStart : aStart - 1},${aLen} +${bLen ? bStart : bStart - 1},${bLen} @@`, ...slice.flatMap(show2));
|
|
30589
31132
|
i = stop;
|
|
30590
31133
|
start = stop;
|
|
30591
31134
|
}
|
|
30592
31135
|
return `${out.join("\n")}
|
|
30593
31136
|
`;
|
|
30594
31137
|
}
|
|
30595
|
-
var escapeRe2, VOID_TAGS;
|
|
31138
|
+
var escapeRe2, VOID_TAGS, NO_EOL, NO_EOL_MARKER;
|
|
30596
31139
|
var init_patch = __esm({
|
|
30597
31140
|
"src/commands/plan/patch.ts"() {
|
|
30598
31141
|
"use strict";
|
|
30599
31142
|
escapeRe2 = (s2) => s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30600
31143
|
VOID_TAGS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"];
|
|
31144
|
+
NO_EOL = "\0no-eol";
|
|
31145
|
+
NO_EOL_MARKER = "\";
|
|
30601
31146
|
}
|
|
30602
31147
|
});
|
|
30603
31148
|
|
|
30604
31149
|
// src/commands/plan/fix.ts
|
|
30605
31150
|
import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
|
|
30606
31151
|
import { relative as relative6, resolve as resolve8 } from "node:path";
|
|
30607
|
-
function
|
|
31152
|
+
function declaredPlans(cwd) {
|
|
31153
|
+
const path = findConfig(cwd);
|
|
31154
|
+
if (!path) return [];
|
|
31155
|
+
try {
|
|
31156
|
+
return loadConfig(path).config.plans ?? [];
|
|
31157
|
+
} catch {
|
|
31158
|
+
return [];
|
|
31159
|
+
}
|
|
31160
|
+
}
|
|
31161
|
+
function staleAgainstDeclared(content, plans) {
|
|
31162
|
+
if (!plans.length || !content) return void 0;
|
|
31163
|
+
const text = content.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
|
31164
|
+
const said = [];
|
|
31165
|
+
for (const m of text.matchAll(/(\d{1,3})[- ]days? free,? then \$([\d,]+(?:\.\d{1,2})?) (?:every|per|a|\/) ?(month|year|week|day)/gi)) {
|
|
31166
|
+
const trial = Number(m[1]);
|
|
31167
|
+
const price3 = Number(m[2].replace(/,/g, ""));
|
|
31168
|
+
const interval = m[3].toLowerCase();
|
|
31169
|
+
const same = plans.filter((p) => p.interval === interval && major(p) !== void 0 && Math.abs(major(p) - price3) < 5e-3);
|
|
31170
|
+
if (same.length && !same.some((p) => (p.trial_days ?? 0) === trial)) said.push(`it says \u201C${m[0]}\u201D, but checkbox.json declares ${same.map((p) => `"${p.name}" with ${p.trial_days ? `a ${p.trial_days}-day trial` : "no trial"}`).join(", ")}`);
|
|
31171
|
+
}
|
|
31172
|
+
return said.length ? said.join("; ") : void 0;
|
|
31173
|
+
}
|
|
31174
|
+
function fixForTask(task, cwd, file, plans = declaredPlans(cwd)) {
|
|
30608
31175
|
const base = {
|
|
30609
31176
|
object: "task_fix",
|
|
30610
31177
|
task: task.id,
|
|
@@ -30615,6 +31182,22 @@ function fixForTask(task, cwd, file) {
|
|
|
30615
31182
|
verify: task.verify
|
|
30616
31183
|
};
|
|
30617
31184
|
const verifyCmd = `checkbox verify ${task.id} --wait`;
|
|
31185
|
+
const stale = staleAgainstDeclared(`${task.change.content}
|
|
31186
|
+
${task.change.edits.map((e) => e.content ?? "").join("\n")}`, plans);
|
|
31187
|
+
if (stale) {
|
|
31188
|
+
return {
|
|
31189
|
+
...base,
|
|
31190
|
+
file: null,
|
|
31191
|
+
diff: null,
|
|
31192
|
+
applied: [],
|
|
31193
|
+
unmatched: [],
|
|
31194
|
+
next_action: {
|
|
31195
|
+
type: "refresh_plan",
|
|
31196
|
+
description: `Not applied: this task was built from the site's plans in Checkbox, which are older than checkbox.json (${stale}). Applying it would put the old terms back. Leave this file as it is; once this change is deployed, update the site's plans (updateSetupSession plans[], or \`checkbox init --plan \u2026\`), then run \`checkbox plan\` again for a fix built from the new plans.`,
|
|
31197
|
+
command: "checkbox plan"
|
|
31198
|
+
}
|
|
31199
|
+
};
|
|
31200
|
+
}
|
|
30618
31201
|
const candidate = file ?? (task.where.file_hint && existsSync9(resolve8(cwd, task.where.file_hint)) ? task.where.file_hint : void 0);
|
|
30619
31202
|
if (task.change.type === "patch" && task.change.edits.length && candidate) {
|
|
30620
31203
|
const path = resolve8(cwd, candidate);
|
|
@@ -30652,14 +31235,16 @@ function fixForTask(task, cwd, file) {
|
|
|
30652
31235
|
const type = task.change.type === "manual" ? "answer_question" : task.change.type === "needs_facts" ? "provide_facts" : "apply_change";
|
|
30653
31236
|
return { ...base, file: null, diff: null, applied: [], unmatched: [], next_action: { type, description: how[task.change.type] ?? "Apply the change, then verify.", command: task.change.type === "manual" ? `checkbox answer ${task.id}` : task.change.type === "needs_facts" ? `checkbox fix ${task.id}` : verifyCmd } };
|
|
30654
31237
|
}
|
|
30655
|
-
var TASK_RE2, fix_default;
|
|
31238
|
+
var TASK_RE2, major, fix_default;
|
|
30656
31239
|
var init_fix = __esm({
|
|
30657
31240
|
"src/commands/plan/fix.ts"() {
|
|
30658
31241
|
"use strict";
|
|
30659
31242
|
init_args();
|
|
30660
31243
|
init_types2();
|
|
31244
|
+
init_config3();
|
|
30661
31245
|
init_patch();
|
|
30662
31246
|
TASK_RE2 = /^task_[0-9a-f]{32}$/;
|
|
31247
|
+
major = (p) => p.price !== void 0 ? Number(p.price) : p.amount !== void 0 ? p.amount / 100 : void 0;
|
|
30663
31248
|
fix_default = defineCommand({
|
|
30664
31249
|
name: "fix",
|
|
30665
31250
|
summary: "Show the change for a plan task (exact diff of your file for code tasks)",
|
|
@@ -30950,7 +31535,8 @@ function planEnv(d, v) {
|
|
|
30950
31535
|
[names.secret, v.secretKey],
|
|
30951
31536
|
[names.publishable, v.publishableKey],
|
|
30952
31537
|
...v.liveSecretKey ? [[names.liveSecret, v.liveSecretKey]] : [],
|
|
30953
|
-
...v.livePublishableKey ? [[names.livePublishable, v.livePublishableKey]] : []
|
|
31538
|
+
...v.livePublishableKey ? [[names.livePublishable, v.livePublishableKey]] : [],
|
|
31539
|
+
...v.apiBase ? [["CHECKBOX_API_BASE", v.apiBase]] : []
|
|
30954
31540
|
];
|
|
30955
31541
|
const existing = read(d.root, d.envFile);
|
|
30956
31542
|
let text = existing ?? "";
|
|
@@ -30961,7 +31547,7 @@ function planEnv(d, v) {
|
|
|
30961
31547
|
if (re.test(text)) {
|
|
30962
31548
|
if (!text.match(re)[0].endsWith(`=${val}`)) {
|
|
30963
31549
|
text = text.replace(re, `${k}=${val}`);
|
|
30964
|
-
added.push(`${k}=${mask(val)}`);
|
|
31550
|
+
added.push(`${k}=${k === "CHECKBOX_API_BASE" ? val : mask(val)}`);
|
|
30965
31551
|
}
|
|
30966
31552
|
} else missing.push([k, val]);
|
|
30967
31553
|
}
|
|
@@ -30969,7 +31555,7 @@ function planEnv(d, v) {
|
|
|
30969
31555
|
const block = ["# Checkbox (subscription compliance) \u2014 written by `checkbox init`. Secret: never commit this file.", ...missing.map(([k, val]) => `${k}=${val}`)];
|
|
30970
31556
|
text = `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${block.join("\n")}
|
|
30971
31557
|
`;
|
|
30972
|
-
added.push(...missing.map(([k, val]) => `${k}=${mask(val)}`));
|
|
31558
|
+
added.push(...missing.map(([k, val]) => `${k}=${k === "CHECKBOX_API_BASE" ? val : mask(val)}`));
|
|
30973
31559
|
}
|
|
30974
31560
|
if (existing === text) return { file: d.envFile, action: "unchanged", summary: `${d.envFile} already has the Checkbox keys`, added: [] };
|
|
30975
31561
|
return { file: d.envFile, action: existing === null ? "create" : "modify", summary: `Keys written to ${d.envFile} (${entries.map(([k]) => k).join(", ")})`, contents: text, added };
|
|
@@ -31095,7 +31681,8 @@ function saveState(ctx, root, patch) {
|
|
|
31095
31681
|
}
|
|
31096
31682
|
function withEvaluatedAt(c) {
|
|
31097
31683
|
const t = c.evaluated;
|
|
31098
|
-
|
|
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 };
|
|
31099
31686
|
}
|
|
31100
31687
|
async function ask2(question) {
|
|
31101
31688
|
const rl = createInterface2({ input: process.stdin, output: process.stderr, terminal: true });
|
|
@@ -31270,8 +31857,8 @@ function resultOf2(status, d, session, changes, extra, apiBase) {
|
|
|
31270
31857
|
install: session.install,
|
|
31271
31858
|
stripe: { ...session.stripe, ...extra.stripe ?? {} },
|
|
31272
31859
|
...extra.live && Object.keys(extra.live).length ? { live: extra.live } : {},
|
|
31273
|
-
// Round 32 (pilot 31, discovery-agent: "compliance.evaluated" read like a count but is a Unix timestamp)
|
|
31274
|
-
//
|
|
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.
|
|
31275
31862
|
compliance: session.compliance ? withEvaluatedAt(session.compliance) : session.compliance,
|
|
31276
31863
|
...session.go_live ? { go_live: session.go_live } : {},
|
|
31277
31864
|
steps: session.steps.map((s2) => ({ key: s2.key, status: s2.status, summary: s2.summary })),
|
|
@@ -31371,7 +31958,7 @@ var init_init = __esm({
|
|
|
31371
31958
|
" --no-stripe Do not offer the Stripe test key / test subscription",
|
|
31372
31959
|
" --live Also request live-mode keys and write the domain-verification file",
|
|
31373
31960
|
" --no-wait Start the login, print the link and exit (run `checkbox init` again to continue)",
|
|
31374
|
-
" --wait-seconds <n> How long to wait for the approval (default 900 on a terminal, 100 otherwise)",
|
|
31961
|
+
" --wait-seconds <n> How long to wait for the approval (default 900 on a terminal, 40 with --json, 100 otherwise)",
|
|
31375
31962
|
" --no-open Do not open the browser",
|
|
31376
31963
|
" --verify Re-check the site now (after deploying) and print the updated checklist",
|
|
31377
31964
|
" --complete Finish the setup and email the human the summary (sent automatically the first time every",
|
|
@@ -31391,7 +31978,7 @@ var init_init = __esm({
|
|
|
31391
31978
|
const dryRun = bool(flags, "dry-run") === true;
|
|
31392
31979
|
const yes = bool(flags, "yes") === true || ctx.flags.json;
|
|
31393
31980
|
const out = ctx.out;
|
|
31394
|
-
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(/\/+$/, "");
|
|
31395
31982
|
const d = detectProject(root, ctx.env);
|
|
31396
31983
|
const state = loadState(ctx, root);
|
|
31397
31984
|
const remembered = state.facts ?? {};
|
|
@@ -31500,7 +32087,7 @@ var init_init = __esm({
|
|
|
31500
32087
|
if (bool(flags, "open") !== false) openBrowser(pending.verification_uri_complete, ctx.env);
|
|
31501
32088
|
const tty = !!process.stdin.isTTY && !!process.stderr.isTTY;
|
|
31502
32089
|
const waitArg = str(flags, "wait-seconds");
|
|
31503
|
-
const waitSeconds = bool(flags, "wait") === false ? 0 : waitArg !== void 0 ? Number(waitArg) : tty ? 900 : 100;
|
|
32090
|
+
const waitSeconds = bool(flags, "wait") === false ? 0 : waitArg !== void 0 ? Number(waitArg) : tty ? 900 : out.json ? 40 : 100;
|
|
31504
32091
|
if (!Number.isFinite(waitSeconds) || waitSeconds < 0) throw new UsageError("--wait-seconds must be a number of seconds");
|
|
31505
32092
|
const polled = await pollDeviceLogin(anon, pending, waitSeconds * 1e3, ctx);
|
|
31506
32093
|
if (polled.status === "timeout") {
|
|
@@ -31589,7 +32176,7 @@ var init_init = __esm({
|
|
|
31589
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).`);
|
|
31590
32177
|
} else {
|
|
31591
32178
|
if (token && envTracked) warnings.push(trackedEnvWarning(d.envFile));
|
|
31592
|
-
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 } : {} }));
|
|
31593
32180
|
const gi = planGitignore(d);
|
|
31594
32181
|
if (gi) changes.push(gi);
|
|
31595
32182
|
}
|
|
@@ -31873,6 +32460,7 @@ var init_plan = __esm({
|
|
|
31873
32460
|
init_args();
|
|
31874
32461
|
init_types2();
|
|
31875
32462
|
init_lib();
|
|
32463
|
+
init_fix();
|
|
31876
32464
|
plan_default = defineCommand({
|
|
31877
32465
|
name: "plan",
|
|
31878
32466
|
summary: "Show the compliance plan: ordered tasks to apply, then verify",
|
|
@@ -31893,7 +32481,15 @@ var init_plan = __esm({
|
|
|
31893
32481
|
const client = ctx.client();
|
|
31894
32482
|
const site = await resolveSite(ctx, str(flags, "site"), client);
|
|
31895
32483
|
const plan2 = await client.sdk.plans.retrieve(site);
|
|
31896
|
-
|
|
32484
|
+
const declared = declaredPlans(ctx.cwd);
|
|
32485
|
+
const staleOf = (t) => staleAgainstDeclared(`${t.change.content}
|
|
32486
|
+
${t.change.edits.map((e) => e.content ?? "").join("\n")}`, declared);
|
|
32487
|
+
const stale = plan2.tasks.filter((t) => (t.status === "open" || t.status === "in_progress") && staleOf(t)).map((t) => t.id);
|
|
32488
|
+
ctx.out.result(stale.length ? { ...plan2, stale_tasks: stale } : plan2, () => {
|
|
32489
|
+
const lines = renderPlan(plan2, ctx.out.c, { all: !!bool(flags, "all"), stale: staleOf });
|
|
32490
|
+
if (stale.length) lines.push("", ctx.out.c.yellow(`${stale.length} task(s) were built from the site's plans in Checkbox, which are older than checkbox.json: do not apply them. Once this change is deployed, update the site's plans (updateSetupSession plans[], or \`checkbox init --plan \u2026\`), then run \`checkbox plan\` again.`));
|
|
32491
|
+
return lines;
|
|
32492
|
+
});
|
|
31897
32493
|
return bool(flags, "exit-code") && plan2.status !== "compliant" ? EXIT.fail : EXIT.ok;
|
|
31898
32494
|
}
|
|
31899
32495
|
});
|