Apply approved awake allowances and overage examples
8 files changed,
+163
−16
copy-review/overages/result.json+87 −0copy-review/pricing/build.mjs+5 −1copy-review/pricing/pricebook.json+4 −4copy-review/pricing/pricebook.source.txt+1 −1copy-review/pricing/strings.json+2 −2src/pricing/index.html+38 −4src/site.css+4 −0test/pricing-pricebook.test.mjs+22 −4
diff --git a/copy-review/overages/result.json b/copy-review/overages/result.json| new file mode 100644 |
| index 0000000..31eb033 |
| --- /dev/null |
| +++ b/copy-review/overages/result.json |
| @@ -0,0 +1,87 @@ |
| +{ |
| + "action": "approve", |
| + "guidance": null, |
| + "strings": [ |
| + { |
| + "id": "s01", |
| + "text": "<h1>🤖 Review preview: proposed awake-time allowances</h1>\n<p>A <strong>Free</strong> account includes <strong>60 awake hours a month</strong>. This is one monthly pool, not a daily two-hour quota.</p>\n<p><strong>Personal</strong> includes <strong>100 awake hours a month</strong>. That is the base-band allowance; paid allowances vary by billing country.</p>", |
| + "original": "<h1>🤖 Review preview: proposed awake-time allowances</h1>\n<p>A <strong>Free</strong> account includes <strong>60 awake hours a month</strong>. This is one monthly pool, not a daily two-hour quota.</p>\n<p><strong>Personal</strong> includes <strong>100 awake hours a month</strong>. That is the base-band allowance; paid allowances vary by billing country.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s02", |
| + "text": "<h2>Proposed overages behavior</h2>\n<p>Overages are usage beyond your included allowances, billed at the published rates.</p>\n<p>Paid overages would be off by default. You could enable them during signup or later in account settings.</p>", |
| + "original": "<h2>Proposed overages behavior</h2>\n<p>Overages are usage beyond your included allowances, billed at the published rates.</p>\n<p>Paid overages would be off by default. You could enable them during signup or later in account settings.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s03", |
| + "text": "<h2>Proposed always-awake behavior</h2>\n<p>Apps normally sleep after they are idle and wake when someone visits. Sleeping time does not use your awake allowance.</p>\n<p>To keep an app awake 24/7, you would enable overages and choose <strong>Always awake</strong> for that app. An estimate would be shown before you enable it.</p>\n<p>Always-awake controls and overage opt-in are still being built.</p>", |
| + "original": "<h2>Proposed always-awake behavior</h2>\n<p>Apps normally sleep after they are idle and wake when someone visits. Sleeping time does not use your awake allowance.</p>\n<p>To keep an app awake 24/7, you would enable overages and choose <strong>Always awake</strong> for that app. An estimate would be shown before you enable it.</p>\n<p>Always-awake controls and overage opt-in are still being built.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s04", |
| + "text": "<h2>Indicative monthly costs</h2>", |
| + "original": "<h2>Indicative monthly costs</h2>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s05", |
| + "text": "<p>These examples use proposed USD rates for country band A and assume a 730-hour average month, one app, no awake time from other apps, and a team minimum of 10 members. Subscriptions are paid annually. The monthly figures are comparisons only.</p>", |
| + "original": "<p>These examples use proposed USD rates for country band A and assume a 730-hour average month, one app, no awake time from other apps, and a team minimum of 10 members. Subscriptions are paid annually. The monthly figures are comparisons only.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s06", |
| + "text": "<p>Storage, data transfer, replication writes, and taxes are excluded. The actual bill depends on the month length and other usage.</p>", |
| + "original": "<p>Storage, data transfer, replication writes, and taxes are excluded. The actual bill depends on the month length and other usage.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s07", |
| + "text": "<table>\n<thead>\n<tr>\n<th>Plan</th>\n<th>Example</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Personal</td>\n<td>630 excess hours × $0.012 = $7.56/month, plus $28/year. Monthly equivalent: $9.89.</td>\n</tr>\n<tr>\n<td>Community, 10 members</td>\n<td>560 excess hours × $0.012 = $6.72/month, plus $60/year. Monthly equivalent: $11.72.</td>\n</tr>\n<tr>\n<td>Business, 10 members</td>\n<td>1,700 included hours. No compute overage in this example. $600/year, or $50/month equivalent.</td>\n</tr>\n</tbody>\n</table>", |
| + "original": "<table>\n<thead>\n<tr>\n<th>Plan</th>\n<th>Example</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Personal</td>\n<td>630 excess hours × $0.012 = $7.56/month, plus $28/year. Monthly equivalent: $9.89.</td>\n</tr>\n<tr>\n<td>Community, 10 members</td>\n<td>560 excess hours × $0.012 = $6.72/month, plus $60/year. Monthly equivalent: $11.72.</td>\n</tr>\n<tr>\n<td>Business, 10 members</td>\n<td>1,700 included hours. No compute overage in this example. $600/year, or $50/month equivalent.</td>\n</tr>\n</tbody>\n</table>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s08", |
| + "text": "<p>Free cannot keep one app awake continuously because it includes only 60 awake hours a month.</p>", |
| + "original": "<p>Free cannot keep one app awake continuously because it includes only 60 awake hours a month.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + }, |
| + { |
| + "id": "s09", |
| + "text": "<h2>Pricing status</h2>\n<p>The USD and GBP overage rates are proposals, not approved prices. These examples are for private review and are not final pricing.</p>", |
| + "original": "<h2>Pricing status</h2>\n<p>The USD and GBP overage rates are proposals, not approved prices. These examples are for private review and are not final pricing.</p>", |
| + "edited": false, |
| + "rerolled": false, |
| + "rerolls": [], |
| + "done": false |
| + } |
| + ] |
| +} |
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs| index bb02576..d57bd99 100644 |
| --- a/copy-review/pricing/build.mjs |
| +++ b/copy-review/pricing/build.mjs |
| @@ -11,6 +11,9 @@ const here = path.dirname(new URL(import.meta.url).pathname); |
| const root = path.resolve(here, "..", ".."); |
| const strings = JSON.parse(fs.readFileSync(path.join(here, "strings.json"), "utf8")); |
| const S = Object.fromEntries(strings.map((s) => [s.id, s.text])); |
| +const overageReview = JSON.parse(fs.readFileSync(path.join(here, "..", "overages", "result.json"), "utf8")); |
| +if (overageReview.action !== "approve") throw new Error("overage copy is not approved"); |
| +const overageCopy = Object.fromEntries(overageReview.strings.map(s => [s.id, s.text])); |
| // The five currency-control strings have their own review (approved |
| // 2026-10-01, copy-review/currency/result.json). |
| const currencyStrings = JSON.parse(fs.readFileSync(path.join(here, "..", "currency", "strings.json"), "utf8")); |
| @@ -331,7 +334,7 @@ ${bandList} |
| <section class="tint"> |
| <div class="wrap narrow prose"> |
| ${t("over.heading", "h2")} |
| - <p data-copy="over.p1">${esc(S["over.p1"])}</p> |
| + ${overageCopy.s02} |
| <table class="price-table rates"> |
| <tbody> |
| <tr><th scope="row" data-copy="over.table.awake">${esc(S["over.table.awake"])}</th>${priced("over.rate.awake", overageRate("awake_hour_micro"), "td")}</tr> |
| @@ -342,6 +345,7 @@ ${bandList} |
| </table> |
| ${priced("over.p2", (cur) => money(cur, book.overage[cur.toLowerCase()].invoice_floor_micro / 10000), "p")} |
| <p data-copy="over.p3">${esc(S["over.p3"])}</p> |
| + <div class="overage-examples">${["s03", "s04", "s05", "s06", "s07", "s08", "s09"].map(id => overageCopy[id]).join("\n")}</div> |
| </div> |
| </section> |
| <section class="band"> |
diff --git a/copy-review/pricing/pricebook.json b/copy-review/pricing/pricebook.json| index 9e53c66..8d325d7 100644 |
| --- a/copy-review/pricing/pricebook.json |
| +++ b/copy-review/pricing/pricebook.json |
| @@ -1,7 +1,7 @@ |
| { |
| "schema": 1, |
| - "price_book": "2026-10-01-draft1", |
| - "terms_version": "2026-09-07-v1", |
| + "price_book": "2026-10-03-draft1", |
| + "terms_version": "2026-10-03-v1", |
| "status": "draft", |
| "default_currency": "usd", |
| "currencies": [ |
| @@ -285,7 +285,7 @@ |
| } |
| }, |
| "resources_per_member": { |
| - "awake_seconds": 360000, |
| + "awake_seconds": 216000, |
| "storage_byte_months": 250000000, |
| "transfer_bytes": 1000000000, |
| "writes": 0 |
| @@ -317,7 +317,7 @@ |
| } |
| }, |
| "resources_per_member": { |
| - "awake_seconds": 306000, |
| + "awake_seconds": 360000, |
| "storage_byte_months": 2000000000, |
| "transfer_bytes": 2000000000, |
| "writes": 0 |
diff --git a/copy-review/pricing/pricebook.source.txt b/copy-review/pricing/pricebook.source.txt| index 6cdedfc..c4216a6 100644 |
| --- a/copy-review/pricing/pricebook.source.txt |
| +++ b/copy-review/pricing/pricebook.source.txt |
| @@ -1 +1 @@ |
| -copy-review/pricing/pricebook.json is generated by the platform (carlos/platform, go run ./cmd/pricebook-export) and copied here unchanged. Source: branch feat/carloku-multi-currency-20261001, internal/plans/public-pricebook.json at commit 5047494b. Do not edit; replace with a fresh export. |
| +copy-review/pricing/pricebook.json is generated by the platform (carlos/platform, go run ./cmd/pricebook-export) and copied here unchanged. Source: branch feat/carloku-consent-terms-20261003, internal/plans/public-pricebook.json at commit d504567a1bf9ff4fa7da876b68e80d6e3f8b9fb5. Do not edit; replace with a fresh export. |
diff --git a/copy-review/pricing/strings.json b/copy-review/pricing/strings.json| index dcae1dd..5ba846f 100644 |
| --- a/copy-review/pricing/strings.json |
| +++ b/copy-review/pricing/strings.json |
| @@ -87,7 +87,7 @@ |
| "id": "plan.free.i3", |
| "section": "The plans", |
| "label": "Free inclusion 3", |
| - "text": "100 awake hours a month", |
| + "text": "60 awake hours a month", |
| "context": "One line in the plan card's list of what the plan includes." |
| }, |
| { |
| @@ -164,7 +164,7 @@ |
| "id": "plan.personal.i2", |
| "section": "The plans", |
| "label": "Personal inclusion 2", |
| - "text": "85 awake hours a month", |
| + "text": "100 awake hours a month", |
| "context": "One line in the plan card's list of what the plan includes." |
| }, |
| { |
diff --git a/src/pricing/index.html b/src/pricing/index.html| index 4198534..cb30a15 100644 |
| --- a/src/pricing/index.html |
| +++ b/src/pricing/index.html |
| @@ -1,4 +1,4 @@ |
| -<!-- PRICE BOOK 2026-10-01-draft1 IS DRAFT: not approved for sale, not for publication --> |
| +<!-- PRICE BOOK 2026-10-03-draft1 IS DRAFT: not approved for sale, not for publication --> |
| <!doctype html> |
| <html lang="en"> |
| <head> |
| @@ -75,7 +75,7 @@ |
| <p class="allowance-heading" aria-hidden="true"> </p> |
| <ul class="allowances"> <li>One member</li> |
| <li>3 apps</li> |
| - <li>100 awake hours a month</li> |
| + <li>60 awake hours a month</li> |
| <li>250 MB of storage</li> |
| <li>1 GB of transfer a month</li></ul> |
| </div> |
| @@ -98,7 +98,7 @@ |
| <div class="plan-inclusions"> |
| <p class="allowance-heading" aria-hidden="true"> </p> |
| <ul class="allowances"> <li>One member</li> |
| - <li data-band-values="{"A":"85 awake hours a month","B":"63.75 awake hours a month","C":"42.5 awake hours a month","D":"29.75 awake hours a month"}">85 awake hours a month</li> |
| + <li data-band-values="{"A":"100 awake hours a month","B":"75 awake hours a month","C":"50 awake hours a month","D":"35 awake hours a month"}">100 awake hours a month</li> |
| <li data-band-values="{"A":"2 GB of storage","B":"1.5 GB of storage","C":"1 GB of storage","D":"0.7 GB of storage"}">2 GB of storage</li> |
| <li data-band-values="{"A":"2 GB of transfer a month","B":"1.5 GB of transfer a month","C":"1 GB of transfer a month","D":"0.7 GB of transfer a month"}">2 GB of transfer a month</li></ul> |
| </div> |
| @@ -194,7 +194,9 @@ |
| <section class="tint"> |
| <div class="wrap narrow prose"> |
| <h2>Overages</h2> |
| - <p>Extra usage is billed monthly, from our own usage statements.</p> |
| + <h2>Proposed overages behavior</h2> |
| +<p>Overages are usage beyond your included allowances, billed at the published rates.</p> |
| +<p>Paid overages would be off by default. You could enable them during signup or later in account settings.</p> |
| <table class="price-table rates"> |
| <tbody> |
| <tr><th scope="row">Awake time</th><td data-currency-values="{"USD":"$0.012 per hour","EUR":"€0.010 per hour","GBP":"£0.009 per hour"}">$0.012 per hour</td></tr> |
| @@ -205,6 +207,38 @@ |
| </table> |
| <p data-currency-values="{"USD":"Minimum $1.20 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.","EUR":"Minimum €1 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month.","GBP":"Minimum £0.90 bill based on our own measures. If usage doesn't meet our metered amount, it's not billed for that month."}">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> |
| <p>Carloku is hosting (currently) on AWS. For files served directly from S3, we simply charge the AWS transfer fee.</p> |
| + <div class="overage-examples"><h2>Proposed always-awake behavior</h2> |
| +<p>Apps normally sleep after they are idle and wake when someone visits. Sleeping time does not use your awake allowance.</p> |
| +<p>To keep an app awake 24/7, you would enable overages and choose <strong>Always awake</strong> for that app. An estimate would be shown before you enable it.</p> |
| +<p>Always-awake controls and overage opt-in are still being built.</p> |
| +<h2>Indicative monthly costs</h2> |
| +<p>These examples use proposed USD rates for country band A and assume a 730-hour average month, one app, no awake time from other apps, and a team minimum of 10 members. Subscriptions are paid annually. The monthly figures are comparisons only.</p> |
| +<p>Storage, data transfer, replication writes, and taxes are excluded. The actual bill depends on the month length and other usage.</p> |
| +<table> |
| +<thead> |
| +<tr> |
| +<th>Plan</th> |
| +<th>Example</th> |
| +</tr> |
| +</thead> |
| +<tbody> |
| +<tr> |
| +<td>Personal</td> |
| +<td>630 excess hours × $0.012 = $7.56/month, plus $28/year. Monthly equivalent: $9.89.</td> |
| +</tr> |
| +<tr> |
| +<td>Community, 10 members</td> |
| +<td>560 excess hours × $0.012 = $6.72/month, plus $60/year. Monthly equivalent: $11.72.</td> |
| +</tr> |
| +<tr> |
| +<td>Business, 10 members</td> |
| +<td>1,700 included hours. No compute overage in this example. $600/year, or $50/month equivalent.</td> |
| +</tr> |
| +</tbody> |
| +</table> |
| +<p>Free cannot keep one app awake continuously because it includes only 60 awake hours a month.</p> |
| +<h2>Pricing status</h2> |
| +<p>The USD and GBP overage rates are proposals, not approved prices. These examples are for private review and are not final pricing.</p></div> |
| </div> |
| </section> |
| <section class="band"> |
diff --git a/src/site.css b/src/site.css| index fcd9f58..38262d6 100644 |
| --- a/src/site.css |
| +++ b/src/site.css |
| @@ -508,3 +508,7 @@ article.guide .note { |
| .team-plans-heading::after { border-right: 1px solid var(--line-strong); border-top-right-radius: 0.6rem; } |
| .info-popup { width: min(21rem, 74vw); padding: 0.9rem 1rem; line-height: 1.6; } |
| .info-popup::before { content: ""; position: absolute; right: 0.3rem; top: -0.3rem; width: 0.5rem; height: 0.5rem; background: var(--bg); border-top: 1px solid var(--line-strong); border-left: 1px solid var(--line-strong); transform: rotate(45deg); } |
| + |
| +.overage-examples table { width: 100%; border-collapse: collapse; } |
| +.overage-examples td, .overage-examples th { padding: .75rem; text-align: left; border-bottom: 1px solid var(--line, #ddd); vertical-align: top; } |
| +.overage-examples td:first-child { min-width: 7em; } |
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs| index f4a0ac3..5ac2330 100644 |
| --- a/test/pricing-pricebook.test.mjs |
| +++ b/test/pricing-pricebook.test.mjs |
| @@ -13,7 +13,8 @@ import { moneyText } from '../hack/money.mjs'; |
| 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])); |
| +const overageReview = JSON.parse(readFileSync(new URL('copy-review/overages/result.json', root), 'utf8')); |
| +const S = Object.fromEntries([...strings, ...overageReview.strings].map((s) => [s.id, s.text])); |
| 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'); |
| @@ -94,8 +95,8 @@ test('plan prices and the band table are the book, per band and currency', () => |
| test('a band-scaled inclusion carries each band\'s figure by the platform\'s rule, and band A is the approved string', () => { |
| const per = (k, resource, band) => Math.floor(planOf(k).resources_per_member[resource] * book.band_factors_bps[band] / 10000); |
| - assert.deepEqual(attr('plan.personal.i2', 'data-band-values'), { A: S['plan.personal.i2'], B: '63.75 awake hours a month', C: '42.5 awake hours a month', D: '29.75 awake hours a month' }); |
| - assert.equal(per('personal', 'awake_seconds', 'D') / 3600, 29.75); |
| + assert.deepEqual(attr('plan.personal.i2', 'data-band-values'), { A: S['plan.personal.i2'], B: '75 awake hours a month', C: '50 awake hours a month', D: '35 awake hours a month' }); |
| + assert.equal(per('personal', 'awake_seconds', 'D') / 3600, 35); |
| assert.deepEqual(attr('plan.community.i2', 'data-band-values'), { A: S['plan.community.i2'], B: '0.375 GB of storage', C: '0.25 GB of storage', D: '0.175 GB of storage' }); |
| assert.deepEqual(attr('plan.business.i4', 'data-band-values'), { A: S['plan.business.i4'], B: '22,500 replication writes/m', C: '15,000 replication writes/m', D: '10,500 replication writes/m' }); |
| assert.equal(per('business', 'writes', 'D'), 10500); |
| @@ -239,7 +240,7 @@ test('every priced element shows, as static text, exactly its own USD value, and |
| for (const amount of candidates) { |
| const code = bySymbol[amount[0]]; |
| const inBook = known[code].has(amount); |
| - const inStrings = strings.some((s) => new RegExp('(^|[^0-9.])' + amount.replace(/[.$]/g, '\\$&') + '(?![0-9.])').test(s.text)); |
| + const inStrings = [...strings, ...overageReview.strings].some((s) => new RegExp('(^|[^0-9.])' + amount.replace(/[.$]/g, '\\$&') + '(?![0-9.])').test(s.text)); |
| assert.ok(inBook || inStrings, amount + ' (' + code + ') is on the page and in neither the book nor an approved string'); |
| } |
| }); |
| @@ -268,3 +269,20 @@ test('a figure is carried only as a whole number token, grouping commas on eithe |
| ['125%', '25%', false], |
| ]) assert.equal(carriesToken(text, figure), want, `${JSON.stringify(figure)} in ${JSON.stringify(text)}`); |
| }); |
| + |
| + test('reviewed 24/7 examples agree with the canonical base-band USD scenario', () => { |
| + assert.equal(overageReview.action, 'approve'); |
| + assert.equal(planOf('free').resources_per_member.awake_seconds, 60 * 3600); |
| + for (const key of ['personal', 'community', 'business']) { |
| + const p = planOf(key), q = p.min_quantity; |
| + const excess = Math.max(0, 730 - p.resources_per_member.awake_seconds / 3600 * q); |
| + const usage = excess * book.overage.usd.awake_hour_micro / 1e6; |
| + const annual = p.annual_unit_minor.usd.A * q / 100; |
| + const total = usage + annual / 12; |
| + const copy = S.s07; |
| + assert.ok(copy.includes('$' + total.toFixed(2)) || (Number.isInteger(total) && copy.includes('$' + total + '/month'))); |
| + assert.ok(copy.includes('$' + annual + '/year')); |
| + if (usage) assert.ok(copy.includes('$' + usage.toFixed(2) + '/month')); |
| + } |
| + for (const id of ['s02','s03','s04','s05','s06','s07','s08','s09']) assert.ok(page.includes(S[id]), id); |
| +}); |