feat(payments): fix Polar webhook + pre-fill checkout email
Some checks failed
CI / Backend (pull_request) Successful in 26s
CI / UI (pull_request) Failing after 24s
CI / Backend (push) Successful in 26s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 31s
Release / Test backend (push) Successful in 41s
Release / Docker / caddy (push) Successful in 33s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / backend (push) Successful in 3m43s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Backend (pull_request) Successful in 26s
CI / UI (pull_request) Failing after 24s
CI / Backend (push) Successful in 26s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 31s
Release / Test backend (push) Successful in 41s
Release / Docker / caddy (push) Successful in 33s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / backend (push) Successful in 3m43s
Release / Gitea Release (push) Has been skipped
- Fix customer email path: was data.customer_email, is actually data.customer.email per Polar v1 API schema - Add resolveUser() helper: tries polar_customer_id → email → external_id - Add subscription.active and subscription.canceled event handling - Handle order.created for fast-path pro upgrade on purchase - Profile page: fetch user email + polarCustomerId from PocketBase - Profile page: pre-fill ?customer_email= on checkout links - Profile page: link to polar.sh/purchases for existing customers
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent, handleOrderCreated } from '$lib/server/polar';
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/polar
|
||||
*
|
||||
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
|
||||
* Signature is verified via HMAC-SHA256 before any processing.
|
||||
*
|
||||
* Handled events:
|
||||
* subscription.created — new subscription (status may be "active" or "trialing")
|
||||
* subscription.active — subscription became active (e.g. after payment)
|
||||
* subscription.updated — catch-all: cancellations, renewals, plan changes
|
||||
* subscription.canceled — cancel_at_period_end=true, still active until period end
|
||||
* subscription.revoked — access ended, downgrade to free
|
||||
* order.created — purchase / subscription_create: fast-path upgrade
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const rawBody = await request.text();
|
||||
@@ -30,14 +38,15 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'subscription.created':
|
||||
case 'subscription.active':
|
||||
case 'subscription.updated':
|
||||
case 'subscription.canceled':
|
||||
case 'subscription.revoked':
|
||||
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
await handleSubscriptionEvent(type, data as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
break;
|
||||
|
||||
case 'order.created':
|
||||
// One-time purchases — no role change needed for now
|
||||
log.info('polar', 'order.created (no action)', { orderId: data.id });
|
||||
await handleOrderCreated(data as Parameters<typeof handleOrderCreated>[0]);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -10,24 +10,31 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
}
|
||||
|
||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||
try {
|
||||
sessions = await listUserSessions(locals.user.id);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
let email: string | null = null;
|
||||
let polarCustomerId: string | null = null;
|
||||
|
||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
||||
email = record?.email ?? null;
|
||||
polarCustomerId = record?.polar_customer_id ?? null;
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
sessions = await listUserSessions(locals.user.id);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
email,
|
||||
polarCustomerId,
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
user_agent: s.user_agent,
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
|
||||
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/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9${emailParam}`;
|
||||
const checkoutAnnual = `https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21${emailParam}`;
|
||||
// Customer portal: if user already has a Polar customer ID, link to their portal;
|
||||
// otherwise fall back to the org page
|
||||
const manageUrl = data.polarCustomerId
|
||||
? `https://polar.sh/purchases`
|
||||
: `https://polar.sh/libnovel`;
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
|
||||
let avatarUploading = $state(false);
|
||||
@@ -288,12 +298,12 @@
|
||||
<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>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
|
||||
<a href={checkoutMonthly} target="_blank" rel="noopener noreferrer"
|
||||
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">
|
||||
<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>
|
||||
{m.profile_upgrade_monthly()}
|
||||
</a>
|
||||
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
|
||||
<a href={checkoutAnnual} target="_blank" rel="noopener noreferrer"
|
||||
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">
|
||||
{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>
|
||||
@@ -307,7 +317,7 @@
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||
</div>
|
||||
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
|
||||
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
|
||||
{m.profile_manage_subscription()}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user