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.
166 lines
7.2 KiB
HTML
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 & 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>
|