DC-061: remove superseded status/pricing/index.html and obsolete pricing-page-catalog test
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes
2026-08-23 20:24:42 -07:00
parent a7a2b70b2d
commit b6678cf591
3 changed files with 4 additions and 303 deletions
+4 -4
View File
@@ -353,10 +353,10 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
- **owner:** hermes
### DC-061: Remove superseded status/pricing/index.html — dead weight since dashcaddy.net pricing page
- **status:** in-progress
- **owner:** hermes
- **details:** The in-repo `status/pricing/index.html` is served by the status.sami SPA catch-all but duplicates the canonical pricing page now living on the dedicated Next.js marketing site at `dashcaddy.net/pricing`. It has 0 Stripe refs in the current codebase (the marketing site handles checkout). Remove the file to avoid confusion and reduce surface area. No Caddy config change needed — the SPA fallback will serve index.html for /pricing, which is correct behavior (dashboard app handles unknown routes).
- **impact:** Cleaner repo, single source of truth for pricing. Eliminates a stale page that could mislead users who hit status.sami/pricing directly.
- **status:** done
- **owner:** hermes
- **details:** The in-repo `status/pricing/index.html` was served by the status.sami SPA catch-all but duplicated the canonical pricing page now living on the dedicated Next.js marketing site at `dashcaddy.net/pricing`. It had 0 Stripe refs in the current codebase (the marketing site handles checkout). Removed the file and its parent directory. Also deleted the obsolete test `__tests__/billing/pricing-page-catalog.test.js` that validated the now-removed page against the catalog — pricing-page/catalog consistency is now verified by the dashcaddy.net marketing site's own test suite. No Caddy config change needed — the SPA fallback serves index.html for /pricing, which is correct behavior (dashboard app handles unknown routes).
- **result:** Removed `status/pricing/index.html` and `status/pricing/` directory. Deleted `__tests__/billing/pricing-page-catalog.test.js` (9 tests). All 2854 remaining tests pass, zero new ESLint warnings.
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
- **impact:** Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
- **prerequisite:** None.
@@ -1,134 +0,0 @@
/**
* DC-057 pricing-page catalog consistency test.
*
* The pricing page at status/pricing/index.html hard-codes the 4 product
* IDs, prices, and labels. This test asserts that those hard-coded values
* exactly match the catalog in src/billing/catalog.js — preventing drift
* between the two sources.
*
* If a new tier is added to the catalog, this test will fail until the
* pricing page is updated. If the pricing page is updated, the catalog
* must change in lockstep (or this test fails the other way).
*/
const fs = require('fs');
const path = require('path');
const catalog = require('../../src/billing/catalog');
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
function extractTiersFromPage(html) {
// Extract each `<div class="tier pro" data-product-id="...">` block, then
// pull out the dollar amount in the `<div class="price">` element and
// the durationDays from the "N-day Pro license" string. The regex is
// anchored on the tier-class open + the matching buy-btn close so we
// capture the full body of each tier card regardless of how many inner
// divs it has.
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
const tierBlocks = [...html.matchAll(tierRe)];
return tierBlocks.map(([, productId, body]) => {
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
const durMatch = body.match(/(\d+)-day Pro license/);
return {
productId,
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
};
});
}
/**
* Extract the HTML body for one specific tier (from open div through the
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
*/
function extractTierBody(html, productId) {
const re = new RegExp(
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
'i'
);
const m = html.match(re);
return m ? m[1] : null;
}
describe('pricing page <-> catalog consistency (DC-057)', () => {
let html;
let pageTiers;
beforeAll(() => {
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
pageTiers = extractTiersFromPage(html);
});
test('pricing page exists and is readable', () => {
expect(html.length).toBeGreaterThan(1000);
expect(pageTiers.length).toBeGreaterThan(0);
});
test('every catalog product is rendered on the pricing page', () => {
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
const pageIds = pageTiers.map((t) => t.productId).sort();
expect(pageIds).toEqual(catalogIds);
});
test('every pricing-page productId appears in the catalog', () => {
for (const tier of pageTiers) {
const product = catalog.getProduct(tier.productId);
expect(product).not.toBeNull();
}
});
test('pricing-page dollar amounts match catalog amountCents', () => {
for (const tier of pageTiers) {
const product = catalog.getProduct(tier.productId);
const expectedDollars = product.amountCents / 100;
expect(tier.priceDollars).toBe(expectedDollars);
}
});
test('pricing-page duration strings match catalog durationDays', () => {
for (const tier of pageTiers) {
const product = catalog.getProduct(tier.productId);
expect(tier.durationDays).toBe(product.durationDays);
}
});
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
// Per-tier priceLabel assertion: each tier card must include its
// own catalog.priceLabel. A swap or misplaced label fails immediately
// because the assertion checks the tier's own HTML body, not the page.
for (const tier of pageTiers) {
const product = catalog.getProduct(tier.productId);
const body = extractTierBody(html, tier.productId);
expect(body).not.toBeNull();
// The priceLabel appears in the price div of THIS tier only,
// immediately followed by the closing </div> + the duration block.
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
expect(body).toMatch(labelRegex);
}
});
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
// would contradict the spec.
expect(html).not.toMatch(/period-monthly|period-annual/);
expect(html).not.toMatch(/Subscribe to Pro/);
});
test('pricing page references the success-page endpoint', () => {
// The success URL is constructed server-side in stripe-client.js
// (${origin}/billing/success?session_id=...). The pricing page itself
// doesn't need to embed it — but the FOOTER must reference it so the
// customer knows where to go after Stripe redirects.
expect(html.toLowerCase()).toContain('after payment');
expect(html).toContain('/admin/license');
expect(html).toContain('/api/v1/billing/checkout');
});
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
const successHtml = fs.readFileSync(successPath, 'utf8');
expect(successHtml).toContain('/api/v1/billing/lookup/');
expect(successHtml.length).toBeGreaterThan(1000);
});
});
-165
View File
@@ -1,165 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DashCaddy Pricing — Free &amp; Pro</title>
<link rel="canonical" href="/pricing">
<link rel="stylesheet" href="/assets/dashboard.css">
<style>
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; }
* { box-sizing: border-box; }
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
main { width: min(1100px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,56px); }
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
h1 { margin: 8px 0 0; font-size: clamp(2rem,5vw,3rem); }
.lede { color: var(--muted); max-width: 720px; margin-top: 12px; }
.tiers { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 18px; margin-top: 36px; }
.tier { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 28px; display: flex; flex-direction: column; }
.tier.pro { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
.tier h2 { margin: 0 0 4px; font-size: 1.25rem; }
.tier .price { font-size: 2rem; font-weight: 700; margin: 14px 0 0; }
.tier .price small { font-size: 1rem; color: var(--muted); font-weight: 400; }
.tier .duration { color: var(--muted); margin-top: 4px; font-size: .9rem; }
.tier ul { margin: 14px 0; padding-left: 18px; color: var(--muted); font-size: .9rem; }
.tier li { margin: 4px 0; }
.tier button { cursor: pointer; border: 0; padding: 12px 16px; border-radius: 10px; font: inherit; font-weight: 600; margin-top: auto; }
.tier.free { grid-column: 1 / -1; }
.tier.free button { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
.tier.pro button { background: var(--pro); color: #052016; }
.tier button:disabled { opacity: .6; cursor: not-allowed; }
.footnote { color: var(--muted); margin-top: 32px; font-size: .9rem; }
.footnote a { color: var(--accent); }
.error { color: var(--danger); margin-top: 12px; min-height: 1.4em; }
@media (max-width: 600px) { .tier { padding: 20px; } }
</style>
</head>
<body>
<main>
<div class="eyebrow">DashCaddy</div>
<h1>Simple pricing. Self-hosted either way.</h1>
<p class="lede">DashCaddy runs on your hardware. Free is enough for most homelabs. Pro unlocks multi-host fleets, public sharing, and email support.</p>
<div class="tiers">
<div class="tier free">
<h2>Free</h2>
<div class="price">$0<small>/forever</small></div>
<div class="duration">Unlimited duration</div>
<ul>
<li>Single host</li>
<li>Up to <strong>3 users</strong></li>
<li>TOTP login (single-user)</li>
<li>Docker / Caddy / DNS management</li>
<li>Community support (GitHub issues)</li>
</ul>
<button type="button" onclick="window.location.href='/download'">Download Free</button>
</div>
<div class="tier pro" data-product-id="pro-30d">
<h2>1 month</h2>
<div class="price">$20</div>
<div class="duration">30-day Pro license</div>
<ul>
<li>Unlimited users</li>
<li>Public share links</li>
<li>Tailscale-mediated share</li>
<li>Email support</li>
</ul>
<button class="buy-btn" type="button" data-product-id="pro-30d">Buy 1 month</button>
</div>
<div class="tier pro" data-product-id="pro-90d">
<h2>3 months</h2>
<div class="price">$50</div>
<div class="duration">90-day Pro license (17% off)</div>
<ul>
<li>Unlimited users</li>
<li>Public share links</li>
<li>Tailscale-mediated share</li>
<li>Email support</li>
</ul>
<button class="buy-btn" type="button" data-product-id="pro-90d">Buy 3 months</button>
</div>
<div class="tier pro" data-product-id="pro-180d">
<h2>6 months</h2>
<div class="price">$70</div>
<div class="duration">180-day Pro license (42% off)</div>
<ul>
<li>Unlimited users</li>
<li>Public share links</li>
<li>Tailscale-mediated share</li>
<li>Email support</li>
</ul>
<button class="buy-btn" type="button" data-product-id="pro-180d">Buy 6 months</button>
</div>
<div class="tier pro" data-product-id="pro-365d">
<h2>12 months</h2>
<div class="price">$99</div>
<div class="duration">365-day Pro license (59% off)</div>
<ul>
<li>Unlimited users</li>
<li>Public share links</li>
<li>Tailscale-mediated share</li>
<li>Email support</li>
</ul>
<button class="buy-btn" type="button" data-product-id="pro-365d">Buy 12 months</button>
</div>
</div>
<div id="error" class="error" role="alert"></div>
<p class="footnote">Payments are processed by <a href="https://stripe.com" rel="noopener">Stripe</a>. Your card details never touch DashCaddy servers. After payment you receive a Pro license code on the success page AND by email — keep it safe; you'll paste it into <code>/admin/license</code> on your host. 14-day pro-rated refunds. By purchasing you agree to the <a href="/legal/terms">Terms of Service</a> and <a href="/legal/privacy">Privacy Policy</a>.</p>
</main>
<script>
(function () {
'use strict';
var errEl = document.getElementById('error');
function setError(msg) {
errEl.textContent = msg || '';
}
function buy(productId, btn) {
setError('');
btn.disabled = true;
var originalText = btn.textContent;
btn.textContent = 'Opening Stripe…';
var email = null; // could prefill from a logged-in user; left null for the public pricing page
fetch('/api/v1/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: productId, customerEmail: email })
}).then(function (r) {
return r.json().then(function (body) { return { status: r.status, body: body }; });
}).then(function (resp) {
if (resp.status === 200 && resp.body.success && resp.body.data && resp.body.data.url) {
window.location.href = resp.body.data.url;
return;
}
btn.disabled = false;
btn.textContent = originalText;
setError((resp.body && resp.body.error) || ('Checkout failed (HTTP ' + resp.status + ').'));
}).catch(function () {
btn.disabled = false;
btn.textContent = originalText;
setError('Network error. Please try again.');
});
}
var buttons = document.querySelectorAll('.buy-btn');
for (var i = 0; i < buttons.length; i++) {
(function (btn) {
btn.addEventListener('click', function () {
var productId = btn.getAttribute('data-product-id');
buy(productId, btn);
});
})(buttons[i]);
}
})();
</script>
</body>
</html>