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 });
|
||||
};
|
||||
Reference in New Issue
Block a user