Skip to content

Commit cc1a1c0

Browse files
ooplesfranklinic
andauthored
feat(website): live Stripe Payment Links + post-checkout spinner + webhook provisioning (#1178)
* feat(website): wire live Stripe Payment Links + post-checkout UX + webhook provisioning End-to-end Stripe checkout now works against the live account: **Pricing page (src/pages/pricing.astro)** When the user is signed in at click time, the Subscribe buttons append ?client_reference_id=<user.id>&prefilled_email=<user.email> to the Stripe Payment Link URL. The stripe-webhook edge function reads client_reference_id first and falls back to customer_email so the webhook can bind the Stripe customer to the right Supabase profile regardless of whether the customer signed up before or after paying. **Post-checkout provisioning UX (src/pages/account/licenses/index.astro)** Stripe's success_url redirects to /account/licenses/?new=true. The page now detects that query param and renders a spinner + 'Issuing your license...' banner instead of the default loading state. A 1-second polling loop runs for up to 30 seconds waiting for the stripe-webhook edge function's license_keys INSERT to land; the moment the row appears the banner flips to a green checkmark and the new license card renders in the list. If the webhook is still pending at the 30s deadline, the banner shows an amber warning with a support email. The license-list rendering was factored out of loadLicenses() into a shared renderLicenses() helper so the polling path and the normal path use identical markup + setup-instructions logic. The history.replaceState call clears ?new=true from the URL so a browser refresh doesn't retrigger the spinner after the license is already visible. **Webhook handler rewrite (supabase/functions/stripe-webhook/index.ts)** - Inserts now include product='aidotnet' so they pass the NOT NULL constraint added by migration 20260419000000. - user_id resolution falls back through client_reference_id → customer_details.email; failures log loudly so admins can reconcile. - Idempotent: if the user already holds an active license at the same (product, tier), a retried checkout.session.completed event no-ops. - Secrets read lazily inside the request handler so deploy doesn't throw when STRIPE_SECRET_KEY isn't yet set on a fresh project. - Uses the async constructEventAsync signature variant for Deno. - Tier max_activations centralized in TIER_MAX_ACTIVATIONS map. The function is already deployed to prod; secrets STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET need to be set on the Supabase project's Edge Functions → Secrets dashboard before real payments will process. Verify: - Local `npx astro build`: 78 pages, 0 errors. - DNS probe: MX + SPF propagated (sales@aidotnet.dev → cheatcountry@gmail.com). - 4 live Payment Links created in Stripe (Pro monthly/yearly, Ent monthly/yearly). - Stripe webhook endpoint created (events: checkout.session.completed, invoice.paid, customer.subscription.{created,updated,deleted}, invoice.payment_failed). - 4 PUBLIC_STRIPE_*_LINK + STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET GitHub secrets set on ooples/AiDotNet. * fix(website): CodeRabbit review on PR #1178 — 7 review comments addressed account/licenses/index.astro (3 comments): - Document p99 webhook latency + rationale for the 1s/30s poll cadence so future maintainers know when to revisit these values. - Replace innerHTML assignment for the support-email subtitle with textContent + document.createElement to close the 'hardcoded today, interpolated tomorrow' XSS vector. The mailto link is built as a DOM node instead of a string. - Flip the polling loop so it CHECKS first and sleeps between retries. If the Stripe webhook landed while the initial loadLicenses() fetch was in flight, the first poll wins immediately and we skip the gratuitous 1-second spinner pause on the happy path. stripe-webhook/index.ts (4 comments): - Derive the allowed-tier set from TIER_MAX_ACTIVATIONS instead of hardcoding a parallel [professional, enterprise] list. Adding a tier becomes a one-line change on the map. - handleCheckoutCompleted: profile update no longer silently ignores errors. On failure, log a warning and continue (license insert is the customer-critical path; profile is recoverable via admin). - handleSubscriptionUpdated: same pattern — warn-and-continue on profile update failures so RLS/permission regressions surface in logs instead of hiding behind silent success. - handleSubscriptionDeleted: same pattern on the profile downgrade. License row is the source of truth for access; a stale profile only affects the billing page's tier display. Verify: local astro build clean (78 pages, 0 errors). * fix(website): CodeRabbit round 2 on PR #1178 — 3 review comments 1. (Nit) Spinner icon still used innerHTML for its state swap, inconsistent with the XSS-safe subtitle builder. Replaced the three-state swap with three pre-rendered SVGs in the markup (loading, success, warning) that toggle via hidden class. No innerHTML sink remains on the status-display code path; future state additions are just another SVG + classList toggle. 2. (Nit) Polling loop selected id rows and then counted .length, fetching every license row on every iteration. Switched to .select('*', { count: 'exact', head: true }) which asks PostgREST for only the Content-Range header (no row data). Poll bandwidth is now O(1) regardless of how many licenses the user owns. 3. (Nit) handleSubscriptionUpdated assumed single-item subscriptions when extracting tier. Stripe permits multi-item subs (plan + add-on, etc.). Replaced subscription.items.data[0] lookup with a TIER_RANK-ordered walk: collect every known tier advertised in price metadata, pick the highest-ranked one. Enterprise wins over professional; unknown tiers are ignored rather than trusted. Adding a new tier requires appending it to TIER_RANK so precedence stays explicit. Verify: local astro build clean (78 pages, 0 errors). --------- Co-authored-by: franklinic <franklin@ivorycloud.com>
1 parent 075b15b commit cc1a1c0

3 files changed

Lines changed: 440 additions & 119 deletions

File tree

website/src/pages/account/licenses/index.astro

Lines changed: 196 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,42 @@ const base = import.meta.env.BASE_URL;
4242
<p class="text-sm text-slate-500 dark:text-slate-400">Loading your licenses...</p>
4343
</div>
4444

45+
<!--
46+
Post-checkout provisioning banner. Shown only when the URL carries
47+
?new=true (set by Stripe Payment Links' success redirect). The page
48+
polls license_keys until the Stripe webhook creates the new row, so
49+
the customer doesn't see an empty "No licenses yet" state in the
50+
~1-3 second window between Stripe redirect and webhook fan-out.
51+
-->
52+
<div id="new-license-banner" class="hidden bg-gradient-to-r from-brand-blue/10 to-brand-purple/10 border border-brand-blue/30 rounded-2xl p-6">
53+
<div class="flex items-center gap-4">
54+
<!--
55+
Three pre-rendered SVG icons for the banner's three states:
56+
loading (spinner), success (checkmark), and timeout (warning).
57+
The client-side script toggles the `hidden` class on each —
58+
it never calls innerHTML. Keeping the SVGs in the markup means
59+
no future change to the status-display code can accidentally
60+
introduce an HTML-injection surface.
61+
-->
62+
<div class="shrink-0">
63+
<svg id="new-license-icon-loading" class="w-6 h-6 text-brand-blue animate-spin" fill="none" viewBox="0 0 24 24">
64+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
65+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
66+
</svg>
67+
<svg id="new-license-icon-success" class="hidden w-6 h-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
68+
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
69+
</svg>
70+
<svg id="new-license-icon-warning" class="hidden w-6 h-6 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
71+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
72+
</svg>
73+
</div>
74+
<div class="flex-1">
75+
<p id="new-license-title" class="text-sm font-semibold text-slate-900 dark:text-white">Issuing your license...</p>
76+
<p id="new-license-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">This usually takes a couple of seconds.</p>
77+
</div>
78+
</div>
79+
</div>
80+
4581
<!-- License cards -->
4682
<div id="licenses-container" class="hidden space-y-4">
4783
</div>
@@ -93,25 +129,125 @@ var builder = new AiModelBuilder&lt;double, double[], double&gt;(license);</code
93129
is_active: boolean;
94130
}
95131

132+
// Checkout success redirect arrives with ?new=true. When set, we
133+
// render the spinner banner instead of the "No licenses yet" empty
134+
// state and poll license_keys until the stripe-webhook edge function's
135+
// INSERT lands.
136+
//
137+
// Timing assumptions:
138+
// - p50 webhook latency: ~1.5s (Stripe redirect → webhook event
139+
// delivered → INSERT visible).
140+
// - p99 webhook latency: ~5-8s (Stripe retries + edge function
141+
// cold-start worst case).
142+
// - POLL_INTERVAL_MS = 1s keeps us under the read-rate limit with
143+
// a wide margin and matches the p50 so first-paint of the new
144+
// license happens within ~2s of arrival.
145+
// - POLL_TIMEOUT_MS = 30s is 3-5x the p99 so any legitimate slow
146+
// webhook still wins, while a truly stuck webhook surfaces to
147+
// the user as the amber "taking longer than expected" banner
148+
// instead of an infinite spinner.
149+
// - baseline count: how many licenses exist BEFORE the new one
150+
// arrives. Polling waits for count to INCREASE
151+
// so a customer who already held other-tier
152+
// licenses sees the new row specifically, not
153+
// their old ones.
154+
//
155+
// Revisit these numbers if the webhook pipeline changes (e.g., if
156+
// stripe-webhook starts calling an external API during provisioning).
157+
const POLL_INTERVAL_MS = 1000;
158+
const POLL_TIMEOUT_MS = 30_000;
159+
const urlParams = new URLSearchParams(window.location.search);
160+
const isPostCheckout = urlParams.get('new') === 'true';
161+
96162
async function loadLicenses() {
97163
const loadingEl = document.getElementById('licenses-loading');
98164
const noLicensesEl = document.getElementById('no-licenses');
99165
const containerEl = document.getElementById('licenses-container');
100166
const setupEl = document.getElementById('setup-section');
167+
const bannerEl = document.getElementById('new-license-banner');
101168

102169
const { data: { session } } = await supabase.auth.getSession();
103170
if (!session) {
104-
window.location.href = `${import.meta.env.BASE_URL}login/?redirect=${encodeURIComponent(window.location.pathname)}`;
171+
window.location.href = `${import.meta.env.BASE_URL}login/?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
105172
return;
106173
}
107174

175+
// Post-checkout flow: reveal the provisioning banner before the
176+
// first fetch so the user sees the spinner instantly rather than
177+
// any flash of the default loading state.
178+
if (isPostCheckout && bannerEl) {
179+
bannerEl.classList.remove('hidden');
180+
if (loadingEl) loadingEl.classList.add('hidden');
181+
}
182+
108183
const { data: licenses, error } = await supabase
109184
.from('license_keys')
110185
.select('id, license_key, tier, status, max_activations, organization_name, issued_at, expires_at')
111186
.eq('user_id', session.user.id)
112187
.order('created_at', { ascending: false });
113188

114-
if (loadingEl) loadingEl.classList.add('hidden');
189+
if (loadingEl && !isPostCheckout) loadingEl.classList.add('hidden');
190+
191+
// Post-checkout: if the license list hasn't grown since we arrived,
192+
// the Stripe webhook hasn't landed yet. Keep polling until it does
193+
// (or we hit the deadline). First render sets the baseline to the
194+
// current count; subsequent renders short-circuit the poll when the
195+
// count goes up.
196+
if (isPostCheckout && !error) {
197+
const initialCount = licenses?.length ?? 0;
198+
const ok = await pollUntilNewLicenseAppears(session.user.id, initialCount);
199+
// Toggle one of three pre-rendered SVG icons instead of rewriting
200+
// innerHTML. Static markup in the template makes future status
201+
// additions a matter of adding another element + toggle, not a
202+
// new innerHTML sink.
203+
const iconLoading = document.getElementById('new-license-icon-loading');
204+
const iconSuccess = document.getElementById('new-license-icon-success');
205+
const iconWarning = document.getElementById('new-license-icon-warning');
206+
if (ok && bannerEl) {
207+
// Success: swap banner copy + icon, then hide the banner after
208+
// a brief celebratory pause so the new license row can render.
209+
const title = document.getElementById('new-license-title');
210+
const subtitle = document.getElementById('new-license-subtitle');
211+
if (title) title.textContent = 'License issued!';
212+
if (subtitle) subtitle.textContent = 'Your new key is ready below.';
213+
iconLoading?.classList.add('hidden');
214+
iconSuccess?.classList.remove('hidden');
215+
setTimeout(() => bannerEl.classList.add('hidden'), 2500);
216+
} else if (bannerEl) {
217+
const title = document.getElementById('new-license-title');
218+
const subtitle = document.getElementById('new-license-subtitle');
219+
if (title) title.textContent = 'License issuance is taking longer than expected.';
220+
if (subtitle) {
221+
// Build the subtitle via DOM APIs instead of innerHTML. The
222+
// content is static today, but swapping it out with a builder
223+
// means future maintainers can't accidentally introduce an
224+
// HTML-injection vector by interpolating a user-supplied
225+
// value (error message, email address, etc.) into the string.
226+
subtitle.textContent = 'Please refresh in a minute. If the license still isn\'t here, email ';
227+
const mailLink = document.createElement('a');
228+
mailLink.href = 'mailto:support@aidotnet.dev';
229+
mailLink.className = 'underline';
230+
mailLink.textContent = 'support@aidotnet.dev';
231+
subtitle.appendChild(mailLink);
232+
subtitle.append('.');
233+
}
234+
iconLoading?.classList.add('hidden');
235+
iconWarning?.classList.remove('hidden');
236+
}
237+
// Replace the URL so the banner doesn't reappear if the user
238+
// clicks browser-back or refreshes after success.
239+
const cleanUrl = window.location.pathname + window.location.hash;
240+
history.replaceState(null, '', cleanUrl);
241+
// Re-fetch once more to render the final list with the new row.
242+
const { data: refreshed } = await supabase
243+
.from('license_keys')
244+
.select('id, license_key, tier, status, max_activations, organization_name, issued_at, expires_at')
245+
.eq('user_id', session.user.id)
246+
.order('created_at', { ascending: false });
247+
if (refreshed) await renderLicenses(refreshed);
248+
if (loadingEl) loadingEl.classList.add('hidden');
249+
return;
250+
}
115251

116252
if (error) {
117253
console.error('Failed to load licenses:', error);
@@ -122,19 +258,70 @@ var builder = new AiModelBuilder&lt;double, double[], double&gt;(license);</code
122258
return;
123259
}
124260

261+
await renderLicenses(licenses);
262+
}
263+
264+
/**
265+
* Polls license_keys until the user's license count exceeds
266+
* `initialCount`, or POLL_TIMEOUT_MS elapses. Resolves `true` when a
267+
* new row appears, `false` on timeout.
268+
*
269+
* Used exclusively after a Stripe Payment Link success redirect
270+
* (`?new=true`) to cover the async gap between Stripe's redirect and
271+
* the stripe-webhook edge function's INSERT.
272+
*/
273+
async function pollUntilNewLicenseAppears(userId: string, initialCount: number): Promise<boolean> {
274+
const deadline = Date.now() + POLL_TIMEOUT_MS;
275+
// Check first, sleep between retries. If the Stripe webhook already
276+
// landed while the initial loadLicenses() fetch was in flight, the
277+
// first poll wins immediately and we save a full POLL_INTERVAL_MS
278+
// of spinner time on the happy path.
279+
while (true) {
280+
// head=true + count=exact asks PostgREST for just the Content-Range
281+
// row count, not the rows themselves. Saves fetching every license
282+
// ID on every poll iteration — negligible for a handful of licenses
283+
// but a real win for enterprise users with many.
284+
const { count, error: pollError } = await supabase
285+
.from('license_keys')
286+
.select('*', { count: 'exact', head: true })
287+
.eq('user_id', userId);
288+
if (pollError) {
289+
console.error('License poll failed:', pollError);
290+
} else if ((count ?? 0) > initialCount) {
291+
return true;
292+
}
293+
if (Date.now() >= deadline) return false;
294+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
295+
}
296+
}
297+
298+
/**
299+
* Renders the license list + setup instructions. Extracted so both the
300+
* normal load path and the post-checkout polling path can share the
301+
* exact same rendering behavior.
302+
*/
303+
async function renderLicenses(licenses: LicenseKey[]) {
304+
const noLicensesEl = document.getElementById('no-licenses');
305+
const containerEl = document.getElementById('licenses-container');
306+
const setupEl = document.getElementById('setup-section');
307+
125308
if (!licenses || licenses.length === 0) {
126309
if (noLicensesEl) noLicensesEl.classList.remove('hidden');
310+
if (containerEl) containerEl.classList.add('hidden');
311+
if (setupEl) setupEl.classList.add('hidden');
127312
return;
128313
}
129314

130-
// Store licenses for secure key lookup (copy button handler)
131-
loadedLicenses = licenses.map(l => ({ id: l.id, license_key: l.license_key }));
315+
// Store for secure Copy-Key handler lookup (keeps the raw key out of
316+
// the rendered DOM — we only show a masked preview).
317+
loadedLicenses = licenses.map((l) => ({ id: l.id, license_key: l.license_key }));
132318

319+
if (noLicensesEl) noLicensesEl.classList.add('hidden');
133320
if (containerEl) containerEl.classList.remove('hidden');
134321
if (setupEl) setupEl.classList.remove('hidden');
135322

136323
// Load activations for all licenses
137-
const licenseIds = licenses.map(l => l.id);
324+
const licenseIds = licenses.map((l) => l.id);
138325
const { data: activations, error: activationsError } = await supabase
139326
.from('license_activations')
140327
.select('id, license_key_id, machine_id_hash, hostname, os_description, first_seen_at, last_seen_at, is_active')
@@ -155,10 +342,11 @@ var builder = new AiModelBuilder&lt;double, double[], double&gt;(license);</code
155342
}
156343

157344
if (containerEl) {
158-
containerEl.innerHTML = licenses.map(l => renderLicenseCard(l, activationsByLicense.get(l.id) || [])).join('');
345+
containerEl.innerHTML = licenses
346+
.map((l) => renderLicenseCard(l, activationsByLicense.get(l.id) || []))
347+
.join('');
159348

160-
// Update setup instructions with first active key
161-
const activeKey = licenses.find(l => l.status === 'active');
349+
const activeKey = licenses.find((l) => l.status === 'active');
162350
if (activeKey) {
163351
const envCode = document.getElementById('env-var-code');
164352
if (envCode) envCode.textContent = `AIDOTNET_LICENSE_KEY=${activeKey.license_key}`;

website/src/pages/pricing.astro

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,15 +313,37 @@ const base = import.meta.env.BASE_URL;
313313
return;
314314
}
315315

316-
// Paid tiers — redirect to Stripe Payment Link
316+
// Paid tiers — redirect to Stripe Payment Link. Before redirect,
317+
// annotate the URL with the current user's Supabase ID + email so
318+
// the stripe-webhook edge function can bind the resulting Stripe
319+
// customer/subscription to the existing profile (instead of
320+
// having to reconcile by email after the fact, which fails when
321+
// the customer pays with a different email than they signed up
322+
// with). Anonymous checkouts are allowed — they'll be linked on
323+
// first login via the customer_details.email fallback path.
317324
if (PAYMENT_LINKS[tier]) {
318325
const interval = isYearly ? 'year' : 'month';
319326
const link = PAYMENT_LINKS[tier][interval];
320-
if (link) {
321-
window.location.href = link;
322-
} else {
323-
alert('Payment links are being configured. Please try again shortly or contact sales@ooples.com.');
327+
if (!link) {
328+
alert('Payment links are being configured. Please try again shortly or contact sales@aidotnet.dev.');
329+
return;
330+
}
331+
332+
const { data: { session } } = await supabase.auth.getSession();
333+
let url = link;
334+
if (session?.user) {
335+
const params = new URLSearchParams();
336+
// client_reference_id is the documented Stripe Payment Link
337+
// parameter for passing a customer identifier through to the
338+
// Checkout Session. See
339+
// https://stripe.com/docs/payment-links/customize#passing-custom-parameters
340+
params.set('client_reference_id', session.user.id);
341+
if (session.user.email) {
342+
params.set('prefilled_email', session.user.email);
343+
}
344+
url += (link.includes('?') ? '&' : '?') + params.toString();
324345
}
346+
window.location.href = url;
325347
}
326348
});
327349
});

0 commit comments

Comments
 (0)