feat(payments): lock checkout email via Polar server-side checkout sessions
Some checks failed
CI / UI (pull_request) Failing after 23s
CI / Backend (push) Successful in 27s
CI / Backend (pull_request) Successful in 53s
CI / UI (push) Failing after 25s
Release / Test backend (push) Successful in 28s
Release / Check ui (push) Failing after 30s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 1m50s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / UI (pull_request) Failing after 23s
CI / Backend (push) Successful in 27s
CI / Backend (pull_request) Successful in 53s
CI / UI (push) Failing after 25s
Release / Test backend (push) Successful in 28s
Release / Check ui (push) Failing after 30s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 1m50s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Has been skipped
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.
This commit is contained in:
106
ui/src/routes/api/checkout/+server.ts
Normal file
106
ui/src/routes/api/checkout/+server.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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 });
|
||||||
|
};
|
||||||
@@ -4,19 +4,46 @@
|
|||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { page } from '$app/state';
|
||||||
import type { Voice } from '$lib/types';
|
import type { Voice } from '$lib/types';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
// ── Polar checkout URLs (pre-fill email when available) ──────────────────────
|
// ── Polar checkout ───────────────────────────────────────────────────────────
|
||||||
const emailParam = data.email ? `?customer_email=${encodeURIComponent(data.email)}` : '';
|
// Customer portal: always link to the org portal
|
||||||
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
|
|
||||||
const manageUrl = `https://polar.sh/libnovel/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 ───────────────────────────────────────────────────────────────────
|
// ── 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<string | null>(untrack(() => data.avatarUrl ?? null));
|
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
|
||||||
let avatarUploading = $state(false);
|
let avatarUploading = $state(false);
|
||||||
let avatarError = $state('');
|
let avatarError = $state('');
|
||||||
@@ -225,6 +252,17 @@
|
|||||||
|
|
||||||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||||||
|
|
||||||
|
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
||||||
|
{#if justSubscribed}
|
||||||
|
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||||
<div class="flex items-center gap-5 pt-2">
|
<div class="flex items-center gap-5 pt-2">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
@@ -294,17 +332,34 @@
|
|||||||
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
||||||
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
||||||
|
{#if checkoutError}
|
||||||
|
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
|
||||||
|
{/if}
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a href={checkoutMonthly} target="_blank" rel="noopener noreferrer"
|
<button
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
type="button"
|
||||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
onclick={() => startCheckout('monthly')}
|
||||||
|
disabled={checkoutLoading !== null}
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||||
|
{#if checkoutLoading === 'monthly'}
|
||||||
|
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||||
|
{/if}
|
||||||
{m.profile_upgrade_monthly()}
|
{m.profile_upgrade_monthly()}
|
||||||
</a>
|
</button>
|
||||||
<a href={checkoutAnnual} target="_blank" rel="noopener noreferrer"
|
<button
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
|
type="button"
|
||||||
{m.profile_upgrade_annual()}
|
onclick={() => startCheckout('annual')}
|
||||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
disabled={checkoutLoading !== null}
|
||||||
</a>
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||||
|
{#if checkoutLoading === 'annual'}
|
||||||
|
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||||
|
{:else}
|
||||||
|
{m.profile_upgrade_annual()}
|
||||||
|
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user