carlos / carloku Public

Pricing page: exact overage rates, the book's own country list, an honest handoff, and a draft book that cannot be published

Overage rates are rendered to the exact figure ($0.1272, £0.0207), never
rounded to three decimals; a test parses each displayed rate back to the
export. The country list is the book's country_band, named from
countries.json. A country's default currency is read from its option, not
from a list in the script. The signup links always carry both parameters:
?country= is the country selected or empty, ?currency= the currency only
when the visitor chose it (URL or control), else empty, so a locale guess
is never sent as a choice and an empty value lets the console forget an
earlier one. The figures the approved strings carry (the free plan's, the
team minimum, the band percentages, the currency list) are checked against
the export, and per-member band shares are checked to be exact at one
member and at the minimum. A page built from a draft book is marked on its
first line, the build refuses to write it without PRICEBOOK_ALLOW_DRAFT=1,
and npm run check refuses to pass it (hack/check-pricebook.mjs).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 2fc63886941b09aba6de924b532fbf826fa178ff parent 15317d2
7 files changed, +148 −30
  • copy-review/pricing/build.mjs +57 −8
  • hack/check-pricebook.mjs +12 −0
  • package.json +1 −1
  • src/pricing/index.html +4 −3
  • src/site.js +17 −8
  • test/pricing-currency.test.mjs +30 −6
  • test/pricing-pricebook.test.mjs +27 −4
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs
index 310a37e..400d095 100644
--- a/copy-review/pricing/build.mjs
+++ b/copy-review/pricing/build.mjs
@@ -14,11 +14,24 @@ const S = Object.fromEntries(strings.map((s) => [s.id, s.text]));
const currencyStrings = JSON.parse(fs.readFileSync(path.join(here, "..", "currency", "strings.json"), "utf8"));
for (const c of currencyStrings) S[c.id] = c.text;
-// Every price, rate and default on the page comes from the platform's own
-// price book (pricebook.json, generated by `go run ./cmd/pricebook-export`
+// Every price, rate, band and default on the page comes from the platform's
+// own price book (pricebook.json, generated by `go run ./cmd/pricebook-export`
// in carlos/platform and copied here; see pricebook.source.txt). Nothing
-// commercial is typed into this script or into the strings.
+// commercial is typed into this script. The approved strings do carry a few
+// figures of their own (the free plan's inclusions, the team minimum, the
+// band percentages, the list of currencies); each is checked against the
+// book below and the build stops on a difference.
const book = JSON.parse(fs.readFileSync(path.join(here, "pricebook.json"), "utf8"));
+// A draft book is a draft: the console refuses to sell from it in live
+// mode, and this page must not be published from it. The preview is always
+// written for review; the deployable page is written only for an approved
+// book, or when PRICEBOOK_ALLOW_DRAFT=1 says a draft is wanted (the review
+// preview and the tests), and then it carries a marker that
+// hack/check-pricebook.mjs refuses at `npm run check` unless the same
+// variable is set.
+const DRAFT = book.status !== "approved";
+if (DRAFT && !process.env.PRICEBOOK_ALLOW_DRAFT) throw new Error(`the price book ${book.price_book} is ${book.status}, not approved: src/pricing/index.html is not written from it (set PRICEBOOK_ALLOW_DRAFT=1 for a review build)`);
+const draftMarker = DRAFT ? `<!-- PRICE BOOK ${book.price_book} IS ${book.status.toUpperCase()}: not approved for sale, not for publication -->\n` : "";
const CURRENCIES = book.currencies.map((c) => c.code.toUpperCase());
const symbol = Object.fromEntries(book.currencies.map((c) => [c.code.toUpperCase(), c.symbol]));
const plansByKey = Object.fromEntries(book.plans.map((pl) => [pl.key, pl]));
@@ -28,9 +41,17 @@ const money = (cur, minor) => {
const major = Math.floor(minor / 100), rem = minor % 100;
return symbol[cur] + major + (rem ? "." + String(rem).padStart(2, "0") : "");
};
-// rate renders micro units to three decimals, as the approved EUR rate
-// strings do ("€0.010 per hour").
-const rate = (cur, micro) => symbol[cur] + (micro / 1e6).toFixed(3);
+// rate renders micro units exactly: at least three decimals, as the
+// approved EUR rate strings are written ("€0.010 per hour"), and as many
+// more as the figure needs ("$0.1272", "£0.0207"), never rounded. What the
+// page shows is what the platform charges per unit.
+const rate = (cur, micro) => {
+ if (!Number.isInteger(micro) || micro < 0) throw new Error("not a micro amount: " + micro);
+ const whole = Math.floor(micro / 1e6);
+ let frac = String(micro % 1e6).padStart(6, "0");
+ while (frac.length > 3 && frac.endsWith("0")) frac = frac.slice(0, -1);
+ return symbol[cur] + whole + "." + frac;
+};
// unitMinor is a plan's per-member annual price in a currency at a band.
const unitMinor = (plan, cur, band) => plansByKey[plan].annual_unit_minor[cur.toLowerCase()][band];
// values builds the data attribute the selector reads: the text for each
@@ -105,10 +126,38 @@ const t = (id, tag = "span", cls = "") => {
return `<${tag}${cls ? ` class="${cls}"` : ""} data-copy="${id}">${esc(S[id])}</${tag}>`;
};
const li = (id) => ` <li data-copy="${id}">${esc(S[id])}</li>`;
+// The countries are the book's (country_band: every country the console
+// sells to, with its band); countries.json supplies the names only, and a
+// country the book sells to with no name stops the build.
const countries = JSON.parse(fs.readFileSync(path.join(here, "countries.json"), "utf8"));
-const recs = (countries.countries || countries.records || []).slice().sort((a, b) => a.name.localeCompare(b.name));
+const names = new Map((countries.countries || countries.records || []).map((r) => [r.iso2, r.name]));
+const recs = Object.entries(book.country_band).map(([iso2, band]) => {
+ if (!names.has(iso2)) throw new Error("the book sells to " + iso2 + ", which countries.json does not name");
+ return { iso2, band, name: names.get(iso2) };
+}).sort((a, b) => a.name.localeCompare(b.name));
const byBand = { A: [], B: [], C: [], D: [] };
for (const r of recs) byBand[r.band].push(r.name);
+
+// The figures the approved strings carry that are the book's to decide.
+const must = (id, text, what) => { if (!S[id].includes(text)) throw new Error(`${id} (${JSON.stringify(S[id])}) does not carry ${what} ${JSON.stringify(text)} from the book`); };
+for (const p of ["community", "business"]) must(`plan.${p}.min`, String(plansByKey[p].min_quantity), "the minimum");
+for (const [b, id] of [["B", "bands.b"], ["C", "bands.c"], ["D", "bands.d"]]) must(id, (book.band_factors_bps[b] / 100) + "%", "the band percentage");
+if (book.band_factors_bps.A !== 10000) throw new Error("band A is not the full price");
+const free = plansByKey.free.resources_per_member;
+must("plan.free.i3", String(free.awake_seconds / 3600) + " awake hours", "the free plan's awake hours");
+must("plan.free.i4", String(free.storage_byte_months / 1e6) + " MB", "the free plan's storage");
+must("plan.free.i5", String(free.transfer_bytes / 1e9) + " GB", "the free plan's transfer");
+must("currency.explanation", CURRENCIES.slice(0, -1).join(", ") + " or " + CURRENCIES[CURRENCIES.length - 1], "the currencies sold");
+// The per-member figures shown are exact shares of what the console pools:
+// for every band-scaled plan, figure x factor is whole in base units, at one
+// member and at the plan's minimum. If a future book breaks that, the page
+// must say the pooled figure instead, and this stops the build until it does.
+for (const pl of book.plans) {
+ if (!pl.band_scaled) continue;
+ for (const [resource, f] of Object.entries(pl.resources_per_member)) for (const [b, bps] of Object.entries(book.band_factors_bps)) {
+ if ((f * bps) % 10000 !== 0 || (f * pl.min_quantity * bps) % 10000 !== 0) throw new Error(`${pl.key} ${resource} at band ${b} is not an exact per-member share`);
+ }
+}
const bandList = Object.entries(byBand).map(([b, names]) =>
` <div class="band-col"><h3>Band ${b}</h3><p>${names.map(esc).join(", ")}</p></div>`).join("\n");
@@ -170,7 +219,7 @@ const controls = ` <section class="pricing-controls">
</div>
</section>`;
-const page = `<!doctype html>
+const page = `${draftMarker}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
diff --git a/hack/check-pricebook.mjs b/hack/check-pricebook.mjs
new file mode 100644
index 0000000..676e645
--- /dev/null
+++ b/hack/check-pricebook.mjs
@@ -0,0 +1,12 @@
+// 🤖 Refuses a pricing page built from a draft price book. build.mjs marks
+// such a page with a comment on its first line; publishing it would show
+// prices the console refuses to sell. PRICEBOOK_ALLOW_DRAFT=1 lets a review
+// build through.
+import { readFileSync } from "node:fs";
+const page = readFileSync(new URL("../src/pricing/index.html", import.meta.url), "utf8");
+const m = page.match(/^<!-- PRICE BOOK (\S+) IS (\S+): not approved for sale, not for publication -->/);
+if (m && !process.env.PRICEBOOK_ALLOW_DRAFT) {
+ console.error(`src/pricing/index.html is built from the ${m[2].toLowerCase()} price book ${m[1]}; it must not be published. Rebuild from an approved export, or set PRICEBOOK_ALLOW_DRAFT=1 for a review build.`);
+ process.exit(1);
+}
+console.log(m ? `pricing page: DRAFT book ${m[1]} allowed by PRICEBOOK_ALLOW_DRAFT` : "pricing page: built from an approved price book");
diff --git a/package.json b/package.json
index 36ef92d..b897737 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"scripts": {
"build": "eleventy",
"serve": "eleventy --serve",
- "check": "eleventy && node hack/check-docs.mjs && node --test test/*.test.mjs"
+ "check": "eleventy && node hack/check-docs.mjs && node hack/check-pricebook.mjs && node --test test/*.test.mjs"
},
"devDependencies": {
"@11ty/eleventy": "^3.0.0",
diff --git a/src/pricing/index.html b/src/pricing/index.html
index dd34b68..625f67c 100644
--- a/src/pricing/index.html
+++ b/src/pricing/index.html
@@ -1,3 +1,4 @@
+<!-- PRICE BOOK 2026-10-01-draft1 IS DRAFT: not approved for sale, not for publication -->
<!doctype html>
<html lang="en">
<head>
@@ -165,9 +166,9 @@
<table class="price-table rates">
<tbody>
<tr><th scope="row">Awake time</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.012 per hour&quot;,&quot;EUR&quot;:&quot;€0.010 per hour&quot;,&quot;GBP&quot;:&quot;£0.009 per hour&quot;}">$0.012 per hour</td></tr>
- <tr><th scope="row">Storage</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.127 per GB per month&quot;,&quot;EUR&quot;:&quot;€0.106 per GB per month&quot;,&quot;GBP&quot;:&quot;£0.095 per GB per month&quot;}">$0.127 per GB per month</td></tr>
- <tr><th scope="row">Transfer</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.497 per GB&quot;,&quot;EUR&quot;:&quot;€0.414 per GB&quot;,&quot;GBP&quot;:&quot;£0.373 per GB&quot;}">$0.497 per GB</td></tr>
- <tr><th scope="row">Replication writes</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.028 per 1,000&quot;,&quot;EUR&quot;:&quot;€0.023 per 1,000&quot;,&quot;GBP&quot;:&quot;£0.021 per 1,000&quot;}">$0.028 per 1,000</td></tr>
+ <tr><th scope="row">Storage</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.1272 per GB per month&quot;,&quot;EUR&quot;:&quot;€0.106 per GB per month&quot;,&quot;GBP&quot;:&quot;£0.0954 per GB per month&quot;}">$0.1272 per GB per month</td></tr>
+ <tr><th scope="row">Transfer</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.4968 per GB&quot;,&quot;EUR&quot;:&quot;€0.414 per GB&quot;,&quot;GBP&quot;:&quot;£0.3726 per GB&quot;}">$0.4968 per GB</td></tr>
+ <tr><th scope="row">Replication writes</th><td data-currency-values="{&quot;USD&quot;:&quot;$0.0276 per 1,000&quot;,&quot;EUR&quot;:&quot;€0.023 per 1,000&quot;,&quot;GBP&quot;:&quot;£0.0207 per 1,000&quot;}">$0.0276 per 1,000</td></tr>
</tbody>
</table>
<p data-currency-values="{&quot;USD&quot;:&quot;Minimum $1.20 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.&quot;,&quot;EUR&quot;:&quot;Minimum €1 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.&quot;,&quot;GBP&quot;:&quot;Minimum £0.90 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.&quot;}">Minimum $1.20 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.</p>
diff --git a/src/site.js b/src/site.js
index 29d12ef..6203bdf 100644
--- a/src/site.js
+++ b/src/site.js
@@ -16,17 +16,27 @@
// country picks the band (its option's data-band) for every element
// carrying data-band-values, whose texts are by band and then, where the
// amount is money, by currency. Both are forwarded to the console on
- // the signup links (?currency=, ?country=), where they are suggestions:
- // checkout prices from the country the buyer gives there, in the
- // currency the buyer confirms there. Nothing here is a price.
+ // the signup links: ?country= is the country selected or empty, and
+ // ?currency= is the currency only when the visitor chose it (the URL or
+ // the control), else empty. A guess from the browser's locale or a
+ // country's default is shown here but never sent as a choice, and an
+ // empty value tells the console to forget an earlier one. At the console
+ // they are suggestions: checkout prices from the country the buyer gives
+ // there, in the currency the buyer confirms there. Nothing here is a
+ // price.
var currencySelect = document.querySelector("[data-pricing-currency]");
if (currencySelect) {
var supported = ["USD", "EUR", "GBP"];
- var eu = "AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split(" ");
var countrySelect = document.querySelector("[data-pricing-country]");
var explicit = false;
+ // countryCurrency is a country's default currency as the price book
+ // put it on the country's option (data-currency); a country that is
+ // not an option (a locale's region the book does not sell to, say)
+ // takes the book's default, which is the first currency offered.
function countryCurrency(country) {
- return country === "GB" ? "GBP" : eu.indexOf(country) !== -1 ? "EUR" : "USD";
+ var opt = countrySelect && /^[A-Z]{2}$/.test(country || "") && countrySelect.querySelector('option[value="' + country + '"]');
+ var c = opt && opt.getAttribute("data-currency");
+ return supported.indexOf(c) !== -1 ? c : supported[0];
}
// countryOption is the selected country's option, or null when none
// is selected or the value matches no option.
@@ -60,9 +70,8 @@
var country = countryOption() ? countrySelect.value : "";
document.querySelectorAll("[data-pricing-signup]").forEach(function (el) {
var url = new URL(el.href, window.location.href);
- url.searchParams.set("currency", currency.toLowerCase());
- if (country) url.searchParams.set("country", country);
- else url.searchParams.delete("country");
+ url.searchParams.set("currency", explicit ? currency.toLowerCase() : "");
+ url.searchParams.set("country", country);
el.href = url.href;
});
}
diff --git a/test/pricing-currency.test.mjs b/test/pricing-currency.test.mjs
index 9da7d5c..e8e404f 100644
--- a/test/pricing-currency.test.mjs
+++ b/test/pricing-currency.test.mjs
@@ -41,6 +41,19 @@ test('currency defaults from explicit locale region; language alone is not a cou
assert.equal(page(locale).selector.value, expected, locale);
}
});
+test('a locale or country default is shown but never sent as a choice; an explicit choice is', () => {
+ const guessed = page('en-GB', '', '');
+ assert.equal(guessed.selector.value, 'GBP');
+ assert.equal(guessed.price.textContent, '£22/y');
+ assert.equal(guessed.link().get('currency'), '', 'the locale guess is not a choice');
+ assert.equal(guessed.link().get('country'), '', 'no country was selected');
+ assert.ok(guessed.signup.href.includes('currency=') && guessed.signup.href.includes('country='), 'both are present, empty, so the console forgets earlier ones');
+ const byCountry = page('en-US', '', 'IE');
+ assert.equal(byCountry.selector.value, 'EUR');
+ assert.deepEqual([byCountry.link().get('currency'), byCountry.link().get('country')], ['', 'IE']);
+ byCountry.selector.value = 'USD'; byCountry.listeners.currency();
+ assert.deepEqual([byCountry.link().get('currency'), byCountry.link().get('country')], ['usd', 'IE']);
+});
test('explicit choice overrides region and persists when billing country changes', () => {
const p = page('en-IE', '?currency=gbp', 'IE');
assert.equal(p.price.textContent, '£22/y');
@@ -64,7 +77,7 @@ test('a band D country with an explicit currency keeps the currency and changes
const p = page('en-US', '?currency=gbp', '');
assert.equal(p.price.textContent, '£22/y');
assert.equal(p.hours.textContent, '85 awake hours a month');
- assert.equal(p.link().get('country'), null);
+ assert.equal(p.link().get('country'), '');
p.countrySelector.value = 'IN'; p.listeners.country();
assert.equal(p.selector.value, 'GBP', 'the explicit currency stays');
assert.equal(p.price.textContent, '£7.70/y', 'the price is band D in pounds');
@@ -75,10 +88,12 @@ test('a band D country with an explicit currency keeps the currency and changes
p.countrySelector.value = 'US'; p.listeners.country();
assert.equal(p.price.textContent, '£22/y');
assert.equal(p.hours.textContent, '85 awake hours a month');
- // No country: band A, and the link carries no country.
+ // No country: band A, and the link says so with an empty country, which
+ // clears one the console remembered from an earlier visit.
p.countrySelector.value = ''; p.listeners.country();
assert.equal(p.price.textContent, '£22/y');
- assert.equal(p.link().get('country'), null);
+ assert.equal(p.link().get('country'), '');
+ assert.equal(p.link().get('currency'), 'gbp');
});
test('a band C country without an explicit currency takes its default currency and its band', () => {
const p = page('en-US', '', '');
@@ -86,7 +101,7 @@ test('a band C country without an explicit currency takes its default currency a
assert.equal(p.selector.value, 'EUR');
assert.equal(p.price.textContent, '€12/y');
assert.equal(p.hours.textContent, '42.5 awake hours a month');
- assert.deepEqual([p.link().get('currency'), p.link().get('country')], ['eur', 'PL']);
+ assert.deepEqual([p.link().get('currency'), p.link().get('country')], ['', 'PL'], 'the country default is not a choice');
});
test('the link back from the console preselects the country and currency it carries', () => {
const p = page('en-US', '?country=in&currency=gbp', '');
@@ -95,10 +110,19 @@ test('the link back from the console preselects the country and currency it carr
assert.equal(p.price.textContent, '£7.70/y');
const q = page('en-US', '?country=XX', '');
assert.equal(q.countrySelector.value, '', 'an unknown country is ignored');
- assert.equal(q.link().get('country'), null);
+ assert.equal(q.link().get('country'), '');
});
test('unsupported URL currency is ignored and never forwarded', () => {
const p = page('en-US', '?currency=xyz');
assert.equal(p.selector.value, 'USD');
- assert.equal(p.link().get('currency'), 'usd');
+ assert.equal(p.link().get('currency'), '');
+});
+test('a country\'s default currency comes from its option, not from a list in the script', () => {
+ const p = page('en-US', '', '');
+ p.countrySelector.value = 'GB'; p.listeners.country();
+ assert.equal(p.selector.value, 'GBP');
+ p.countrySelector.value = 'IN'; p.listeners.country();
+ assert.equal(p.selector.value, 'USD');
+ p.countrySelector.value = 'PL'; p.listeners.country();
+ assert.equal(p.selector.value, 'EUR');
});
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs
index 7795de3..5a0b6c1 100644
--- a/test/pricing-pricebook.test.mjs
+++ b/test/pricing-pricebook.test.mjs
@@ -12,7 +12,7 @@ const root = new URL('../', import.meta.url);
const book = JSON.parse(readFileSync(new URL('copy-review/pricing/pricebook.json', root), 'utf8'));
const strings = JSON.parse(readFileSync(new URL('copy-review/pricing/strings.json', root), 'utf8'));
const S = Object.fromEntries(strings.map((s) => [s.id, s.text]));
-execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe' });
+execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '1' } });
const page = readFileSync(new URL('src/pricing/index.html', root), 'utf8');
const preview = readFileSync(new URL('copy-review/pricing/preview/index.html', root), 'utf8');
const unesc = (t) => t.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
@@ -96,12 +96,23 @@ test('a band-scaled inclusion carries each band\'s figure by the platform\'s rul
for (const id of ['plan.personal.i1', 'plan.free.i3', 'plan.business.i5']) assert.ok(!preview.match(new RegExp(`data-copy="${re(id)}" data-band-values`)), id);
});
-test('overage rates and the floor are the book, per currency', () => {
+test('overage rates are the book\'s exact figures, per currency, never rounded', () => {
const rates = valuesOf(page.slice(page.indexOf('class="price-table rates"')));
const o = book.overage;
- assert.equal(rates[0].USD, `$${(o.usd.awake_hour_micro / 1e6).toFixed(3)} per hour`);
- assert.equal(rates[1].GBP, `£${(o.gbp.storage_gb_month_micro / 1e6).toFixed(3)} per GB per month`);
+ assert.equal(rates[0].USD, '$0.012 per hour');
+ assert.equal(rates[1].USD, '$0.1272 per GB per month');
+ assert.equal(rates[1].GBP, '£0.0954 per GB per month');
+ assert.equal(rates[3].GBP, '£0.0207 per 1,000');
assert.equal(rates[2].EUR, S['over.rate.transfer']);
+ // Every displayed rate parses back to the exported micro amount.
+ const meters = ['awake_hour_micro', 'storage_gb_month_micro', 'transfer_gb_micro', 'writes_1000_micro'];
+ meters.forEach((meter, i) => {
+ for (const [cur, s] of [['USD', '$'], ['EUR', '€'], ['GBP', '£']]) {
+ const shown = rates[i][cur].match(new RegExp('^\\' + s + '([0-9]+\\.[0-9]{3,})'));
+ assert.ok(shown, rates[i][cur]);
+ assert.equal(Math.round(Number(shown[1]) * 1e6), o[cur.toLowerCase()][meter], `${meter} ${cur}: ${rates[i][cur]}`);
+ }
+ });
assert.ok(rates[4].USD.startsWith('Minimum ' + money('$', o.usd.invoice_floor_micro / 10000)), rates[4].USD);
assert.ok(rates[4].GBP.startsWith('Minimum ' + money('£', o.gbp.invoice_floor_micro / 10000)), rates[4].GBP);
});
@@ -114,6 +125,7 @@ test('the controls carry the five approved strings, and every country option its
for (const [iso, band] of Object.entries(book.country_band)) {
assert.ok(page.includes(`<option value="${iso}" data-currency="${(book.country_currency[iso] || book.default_currency).toUpperCase()}" data-band="${band}">`), iso);
}
+ assert.equal((page.match(/<option value="[A-Z]{2}" data-currency=/g) || []).length, Object.keys(book.country_band).length, 'the country list is the book\'s');
assert.ok(page.includes('<option value="IE" data-currency="EUR" data-band="A">'));
assert.ok(page.includes('<option value="GB" data-currency="GBP" data-band="A">'));
assert.ok(page.includes('<option value="IN" data-currency="USD" data-band="D">'));
@@ -121,6 +133,17 @@ test('the controls carry the five approved strings, and every country option its
assert.equal((page.match(/data-pricing-signup/g) || []).length, 5);
});
+test('a page built from a draft book says so on its first line, and the check refuses it unless a review build is asked for', () => {
+ const marked = page.startsWith(`<!-- PRICE BOOK ${book.price_book} IS ${book.status.toUpperCase()}: not approved for sale, not for publication -->`);
+ assert.equal(marked, book.status !== 'approved');
+ const check = new URL('hack/check-pricebook.mjs', root).pathname;
+ assert.throws(() => execFileSync('node', [check], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '' } }), /must not be published/);
+ execFileSync('node', [check], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '1' } });
+ // And a build without the variable refuses outright.
+ assert.throws(() => execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '' } }), /not approved/);
+ execFileSync('node', [new URL('copy-review/pricing/build.mjs', root).pathname], { stdio: 'pipe', env: { ...process.env, PRICEBOOK_ALLOW_DRAFT: '1' } });
+});
+
test('no euro amount on the page is typed in the script: each comes from a string or the book', () => {
const stripped = page.replace(/data-(currency|band)-values="[^"]*"/g, '');
for (const m of stripped.matchAll(/€[0-9][0-9.,]*/g)) {