Files
dashcaddy/status/pricing/index.html
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
Canonical product catalog at src/billing/catalog.js shared by Stripe
Checkout client (src/billing/stripe-client.js), webhook bridge
(scripts/stripe-license-bridge.js), and pricing page
(status/pricing/index.html). One-time payment keyed by productId at
$20/$50/$70/$99 — no more monthly/annual subscription drift.

Bridge resolves duration via metadata.productId (single contract),
requires payment_status === 'paid' before fulfillment (rejects
unpaid/no_payment_required/missing with ack 200), handles
async_payment_succeeded for ACH/SEPA delayed-payment flow. License
persisted to fulfillment-store BEFORE email — SMTP failure path serves
the persisted code via the new /api/v1/billing/lookup/:sessionId
endpoint (the documented customer recovery path).

Layer-1 (event-id) + layer-2 (session-id) idempotency prevent
duplicate issuance. Checkout return URLs derived from
STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) —
closes host-header-poisoning + session-ID-leak attack class.

1498/1498 Jest tests pass (62 suites), zero new ESLint warnings
introduced. Test files:
  - stripe-license-bridge.test.js (24 tests)
  - billing-lookup.test.js (8 tests, HTTP-level)
  - bridge-lookup-http.test.js (5 tests, uses exported createServer)
  - pricing-page-catalog.test.js (9 tests, per-tier consistency)
  - checkout-origin.test.js (6 tests, host injection rejection)
  - stripe-client.test.js (rewrite for productId + mode:payment)

Bridge code refactored: handleWebhook decomposed into verifySignature +
parseEventBody + checkEventIdempotency + fulfillCheckout +
ensureLicensePersisted (under ESLint complexity=20 cap). New
createServer()/createRequestHandler() factories guarded by
require.main === module.

Removed 3 stale test files from the rolled-back DC-055 attempt.
2026-08-04 14:18:49 -07:00

166 lines
7.2 KiB
HTML

<!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>