From 23ae1ed500e24bc2e7c56a6c0b566230ae640e04 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 31 Mar 2026 23:36:53 +0500 Subject: [PATCH] feat(payments): lock checkout email via Polar server-side checkout sessions Replace static Polar checkout links with a server-side POST /api/checkout route that creates a checkout session with customer_external_id = user ID and customer_email locked (not editable). Adds loading/error states and a post-checkout success banner on the profile page. --- ui/src/routes/api/checkout/+server.ts | 106 ++++++++++++++++++++++++++ ui/src/routes/profile/+page.svelte | 83 ++++++++++++++++---- 2 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 ui/src/routes/api/checkout/+server.ts diff --git a/ui/src/routes/api/checkout/+server.ts b/ui/src/routes/api/checkout/+server.ts new file mode 100644 index 0000000..21a629a --- /dev/null +++ b/ui/src/routes/api/checkout/+server.ts @@ -0,0 +1,106 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; +import { getUserByUsername } from '$lib/server/pocketbase'; + +const POLAR_API_BASE = 'https://api.polar.sh'; + +const PRICE_IDS: Record = { + monthly: '9c0eea36-4f4a-4fd6-970b-d176588d4771', + annual: '5a5be04e-f252-4a30-8f8b-858b40ec33e4' +}; + +/** + * POST /api/checkout + * Body: { product: 'monthly' | 'annual' } + * + * Creates a Polar server-side checkout session with: + * - external_customer_id = locals.user.id (so webhooks can match back to us) + * - customer_email locked to the logged-in user's email (email field disabled in UI) + * - allow_discount_codes: true + * - success_url redirects to /profile?subscribed=1 + * + * Returns: { url: string } + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + const apiToken = env.POLAR_API_TOKEN; + if (!apiToken) { + log.error('checkout', 'POLAR_API_TOKEN not set'); + error(500, 'Checkout unavailable'); + } + + let product: string; + try { + const body = await request.json() as { product?: unknown }; + product = String(body?.product ?? ''); + } catch { + error(400, 'Invalid request body'); + } + + const priceId = PRICE_IDS[product]; + if (!priceId) { + error(400, `Unknown product: ${product}. Use 'monthly' or 'annual'.`); + } + + // Fetch the user's email from PocketBase (not in the auth token) + let email: string | null = null; + try { + const record = await getUserByUsername(locals.user.username); + email = record?.email ?? null; + } catch (e) { + log.warn('checkout', 'failed to fetch user email (non-fatal)', { err: String(e) }); + } + + // Create a server-side checkout session on Polar + // https://docs.polar.sh/api-reference/checkouts/create + const payload = { + product_price_id: priceId, + allow_discount_codes: true, + success_url: 'https://libnovel.cc/profile?subscribed=1', + customer_external_id: locals.user.id, + ...(email ? { customer_email: email } : {}) + }; + + log.info('checkout', 'creating polar checkout session', { + userId: locals.user.id, + product, + email: email ?? '(none)' + }); + + const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiToken}` + }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + log.error('checkout', 'polar checkout creation failed', { + status: res.status, + body: text.slice(0, 500) + }); + error(502, 'Failed to create checkout session'); + } + + const data = await res.json() as { url?: string; id?: string }; + const checkoutUrl = data?.url; + + if (!checkoutUrl) { + log.error('checkout', 'polar response missing url', { data: JSON.stringify(data).slice(0, 200) }); + error(502, 'Invalid checkout response from Polar'); + } + + log.info('checkout', 'checkout session created', { + userId: locals.user.id, + checkoutId: data?.id, + product + }); + + return json({ url: checkoutUrl }); +}; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index cdf5132..1cc7f8c 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -4,19 +4,46 @@ import type { PageData, ActionData } from './$types'; import { audioStore } from '$lib/audio.svelte'; import { browser } from '$app/environment'; + import { page } from '$app/state'; import type { Voice } from '$lib/types'; import * as m from '$lib/paraglide/messages.js'; let { data, form }: { data: PageData; form: ActionData } = $props(); - // ── Polar checkout URLs (pre-fill email when available) ────────────────────── - const emailParam = data.email ? `?customer_email=${encodeURIComponent(data.email)}` : ''; - const checkoutMonthly = `https://buy.polar.sh/polar_cl_KtOHiuL2UkiQ4hrojBfINnxlCTORX1A8DeUUO18irVA${emailParam}`; - const checkoutAnnual = `https://buy.polar.sh/polar_cl_ylmUnsorSBCgNMhWVG7iO8zVQBnr5cVeLJlW74fm5kG${emailParam}`; - // Customer portal: always link to the org portal — customer logs in with checkout email + // ── Polar checkout ─────────────────────────────────────────────────────────── + // Customer portal: always link to the org portal const manageUrl = `https://polar.sh/libnovel/portal`; + let checkoutLoading = $state<'monthly' | 'annual' | null>(null); + let checkoutError = $state(''); + + async function startCheckout(product: 'monthly' | 'annual') { + checkoutLoading = product; + checkoutError = ''; + try { + const res = await fetch('/api/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ product }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { message?: string }; + checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`; + return; + } + const { url } = await res.json() as { url: string }; + window.location.href = url; + } catch { + checkoutError = 'Network error. Please try again.'; + } finally { + checkoutLoading = null; + } + } + // ── Avatar ─────────────────────────────────────────────────────────────────── + // Show a welcome banner when Polar redirects back with ?subscribed=1 + const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1'); + let avatarUrl = $state(untrack(() => data.avatarUrl ?? null)); let avatarUploading = $state(false); let avatarError = $state(''); @@ -225,6 +252,17 @@
+ + {#if justSubscribed} +
+ +
+

Welcome to Pro!

+

Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.

+
+
+ {/if} +
@@ -294,17 +332,34 @@

{m.profile_upgrade_heading()}

{m.profile_upgrade_desc()}

+ {#if checkoutError} +

{checkoutError}

+ {/if}
- - + +