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.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy — Your Pro License</title>
|
||||
<link rel="canonical" href="/billing/success">
|
||||
<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; --warn:#ffd07f; }
|
||||
* { 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(720px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
|
||||
.lede { color: var(--muted); }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
|
||||
.card.ready { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
|
||||
.card.warn { border-color: var(--warn); }
|
||||
.card.error { border-color: var(--danger); }
|
||||
.key { font: 600 1.2rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #06101e; padding: 16px; border-radius: 10px; border: 1px dashed var(--border); word-break: break-all; user-select: all; }
|
||||
.row { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
|
||||
button { cursor: pointer; border: 0; padding: 10px 16px; border-radius: 10px; font: inherit; font-weight: 600; background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
button.primary { background: var(--pro); color: #052016; border-color: var(--pro); }
|
||||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.meta { color: var(--muted); font-size: .9rem; margin-top: 8px; }
|
||||
.next-steps { margin-top: 24px; }
|
||||
.next-steps ol { padding-left: 20px; }
|
||||
.next-steps li { margin: 6px 0; color: var(--muted); }
|
||||
.next-steps li strong { color: var(--text); }
|
||||
a { color: var(--accent); }
|
||||
.copied { color: var(--pro); font-size: .9rem; margin-left: 8px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--muted); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 600px) { .card { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1>Thanks for your purchase!</h1>
|
||||
<p class="lede">Your Pro license code is shown below. We've also sent it to your email as a backup — keep it safe.</p>
|
||||
|
||||
<div id="card" class="card">
|
||||
<div id="loading"><span class="spinner"></span>Generating your license…</div>
|
||||
|
||||
<div id="ready" hidden>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="key" id="key" aria-live="polite"></div>
|
||||
<div class="row">
|
||||
<button id="copy" class="primary" type="button">Copy license key</button>
|
||||
<span id="copied" class="copied" hidden>Copied!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="processing" hidden>
|
||||
Your payment was received. The license is being generated — this page will update automatically. You can also check your email.
|
||||
</div>
|
||||
|
||||
<div id="warn" hidden>
|
||||
<strong>Email delivery didn't complete.</strong> Your license code is below — save it now. We're retrying email delivery on our side.
|
||||
<div class="meta" id="warn-meta" style="margin-top:12px"></div>
|
||||
<div class="key" id="key-warn" aria-live="polite"></div>
|
||||
<div class="row">
|
||||
<button id="copy-warn" type="button">Copy license key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" hidden>
|
||||
<strong>We can't find a record for this session yet.</strong> This page refreshes every 1.5 seconds. If you closed Stripe before being redirected back, your license has been emailed to you.
|
||||
<div class="meta" id="error-meta" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h2 style="font-size:1.25rem;margin-bottom:8px">How to install your key</h2>
|
||||
<ol>
|
||||
<li>Open your DashCaddy host: <strong>https://<your-host></strong></li>
|
||||
<li>Sign in (TOTP or email magic link)</li>
|
||||
<li>Go to <strong>Settings → License</strong> (path: <code>/admin/license</code>)</li>
|
||||
<li>Paste the key and click <strong>Activate license</strong></li>
|
||||
<li>Pro features (unlimited users, public share links, Tailscale-mediated share) unlock immediately</li>
|
||||
</ol>
|
||||
<p class="meta">Need help? Reply to the receipt email or open an issue at <a href="https://github.com/sami7777/dashcaddy/issues" rel="noopener">github.com/sami7777/dashcaddy</a>. 14-day pro-rated refunds per the <a href="/legal/terms">Terms of Service</a>.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var sessionId = new URLSearchParams(window.location.search).get('session_id');
|
||||
if (!sessionId) {
|
||||
// No session_id — Stripe didn't redirect here from a checkout. Show a friendly fallback.
|
||||
document.getElementById('card').classList.add('error');
|
||||
document.getElementById('loading').hidden = true;
|
||||
document.getElementById('error').hidden = false;
|
||||
document.getElementById('error-meta').textContent = 'Missing session_id in URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
var card = document.getElementById('card');
|
||||
var loadingEl = document.getElementById('loading');
|
||||
var readyEl = document.getElementById('ready');
|
||||
var processingEl = document.getElementById('processing');
|
||||
var warnEl = document.getElementById('warn');
|
||||
var errorEl = document.getElementById('error');
|
||||
var metaEl = document.getElementById('meta');
|
||||
var warnMetaEl = document.getElementById('warn-meta');
|
||||
var keyEl = document.getElementById('key');
|
||||
var keyWarnEl = document.getElementById('key-warn');
|
||||
var copyBtn = document.getElementById('copy');
|
||||
var copyWarnBtn = document.getElementById('copy-warn');
|
||||
var copiedEl = document.getElementById('copied');
|
||||
|
||||
var POLL_INTERVAL_MS = 1500;
|
||||
var POLL_TIMEOUT_MS = 60 * 1000;
|
||||
var startTime = Date.now();
|
||||
|
||||
function showReady(code, durationDays, productId, deliveredVia) {
|
||||
loadingEl.hidden = true;
|
||||
readyEl.hidden = false;
|
||||
card.classList.add('ready');
|
||||
keyEl.textContent = code;
|
||||
metaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
|
||||
(deliveredVia ? ' · delivered via ' + deliveredVia : '') +
|
||||
' · also emailed to you';
|
||||
}
|
||||
|
||||
function showProcessing() {
|
||||
loadingEl.hidden = true;
|
||||
processingEl.hidden = false;
|
||||
}
|
||||
|
||||
function showWarn(code, durationDays, productId, lastError, deliveredVia) {
|
||||
loadingEl.hidden = true;
|
||||
warnEl.hidden = false;
|
||||
card.classList.add('warn');
|
||||
keyWarnEl.textContent = code;
|
||||
warnMetaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
|
||||
(lastError ? ' · last email error: ' + lastError : '') +
|
||||
(deliveredVia ? ' · attempted via ' + deliveredVia : '');
|
||||
}
|
||||
|
||||
function showError(reason) {
|
||||
loadingEl.hidden = true;
|
||||
errorEl.hidden = false;
|
||||
card.classList.add('error');
|
||||
if (reason) document.getElementById('error-meta').textContent = reason;
|
||||
}
|
||||
|
||||
function copyFromTextarea(text, cb) {
|
||||
// Use the modern Clipboard API; fall back to a hidden textarea + execCommand.
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(cb).catch(function () {
|
||||
fallbackCopy(text, cb);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, cb);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, cb) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'absolute';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); cb(); } catch (_) { /* swallow */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
copyBtn.addEventListener('click', function () {
|
||||
copyFromTextarea(keyEl.textContent, function () {
|
||||
copiedEl.hidden = false;
|
||||
setTimeout(function () { copiedEl.hidden = true; }, 2000);
|
||||
});
|
||||
});
|
||||
copyWarnBtn.addEventListener('click', function () {
|
||||
copyFromTextarea(keyWarnEl.textContent, function () { /* no-op */ });
|
||||
});
|
||||
|
||||
function tick() {
|
||||
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
|
||||
showError('Timed out waiting for the bridge to generate the license. Your license has been emailed to you — please check your inbox.');
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/billing/lookup/' + encodeURIComponent(sessionId), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
|
||||
.then(function (resp) {
|
||||
var data = resp.body && resp.body.data ? resp.body.data : null;
|
||||
if (resp.status === 200 && data) {
|
||||
if (data.status === 'delivered') {
|
||||
showReady(data.code, data.durationDays, data.productId, data.deliveredVia);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_email' && data.code) {
|
||||
showWarn(data.code, data.durationDays, data.productId, data.lastError, data.deliveredVia);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'processing') {
|
||||
showProcessing();
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (resp.status === 404 && resp.body && /expired/i.test(resp.body.error || '')) {
|
||||
showError('License lookup window has expired. Your key was emailed to you.');
|
||||
return;
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
// Payment still processing — keep polling.
|
||||
showProcessing();
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
showError('Unexpected response: ' + (resp.body && resp.body.error ? resp.body.error : 'HTTP ' + resp.status));
|
||||
})
|
||||
.catch(function () {
|
||||
// Transient network error — keep trying.
|
||||
setTimeout(tick, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user