diff --git a/ui/src/lib/server/polar.ts b/ui/src/lib/server/polar.ts index 528dda9..32364ff 100644 --- a/ui/src/lib/server/polar.ts +++ b/ui/src/lib/server/polar.ts @@ -9,12 +9,16 @@ * Product IDs (Polar dashboard): * Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9 * Annual : b6190307-79aa-4905-80c8-9ed941378d21 + * + * Webhook event data shapes (Polar v1 API): + * subscription.* → data.customer_id, data.product_id, data.status, data.customer.email + * order.created → data.customer_id, data.product_id, data.customer.email, data.billing_reason */ import { createHmac, timingSafeEqual } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { log } from '$lib/server/logger'; -import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase'; +import { getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase'; export const POLAR_PRO_PRODUCT_IDS = new Set([ '1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly @@ -55,41 +59,69 @@ export function verifyPolarWebhook(rawBody: string, signatureHeader: string): bo // ─── Subscription event handler ─────────────────────────────────────────────── +interface PolarCustomer { + email?: string; + external_id?: string; // our app_users.id if set on the customer +} + interface PolarSubscription { id: string; - status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ... + status: string; // "active" | "canceled" | "past_due" | "unpaid" | ... product_id: string; customer_id: string; - customer_email?: string; - user_id?: string; // Polar user id (not our user id) + customer?: PolarCustomer; // nested object — email lives here +} + +/** + * Resolve the app_user for a Polar customer. + * Priority: polar_customer_id → email → customer.external_id (our user ID) + */ +async function resolveUser(customer_id: string, customer?: PolarCustomer) { + const { getUserByEmail, getUserById } = await import('$lib/server/pocketbase'); + + // 1. By stored polar_customer_id (fastest on repeat events) + const byCustomerId = await getUserByPolarCustomerId(customer_id).catch(() => null); + if (byCustomerId) return byCustomerId; + + // 2. By email (most common first-time path) + const email = customer?.email; + if (email) { + const byEmail = await getUserByEmail(email).catch(() => null); + if (byEmail) return byEmail; + } + + // 3. By external_id = our user ID (if set via Polar API on customer creation) + const externalId = customer?.external_id; + if (externalId) { + const byId = await getUserById(externalId).catch(() => null); + if (byId) return byId; + } + + return null; } /** * Handle a Polar subscription event. - * Finds the matching app_user by email and updates role + polar fields. + * Finds the matching app_user and updates role + polar fields. */ export async function handleSubscriptionEvent( eventType: string, subscription: PolarSubscription ): Promise { - const { id: subId, status, product_id, customer_id, customer_email } = subscription; + const { id: subId, status, product_id, customer_id, customer } = subscription; - log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email }); + log.info('polar', 'subscription event', { + eventType, subId, status, product_id, + customer_email: customer?.email + }); - if (!customer_email) { - log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId }); - return; - } - - // Find user by their polar_customer_id first (faster on repeat events), then by email - let user = await getUserByPolarCustomerId(customer_id).catch(() => null); - if (!user) { - const { getUserByEmail } = await import('$lib/server/pocketbase'); - user = await getUserByEmail(customer_email).catch(() => null); - } + const user = await resolveUser(customer_id, customer); if (!user) { - log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id }); + log.warn('polar', 'no app_user found for polar customer', { + customer_email: customer?.email, + customer_id + }); return; } @@ -103,5 +135,60 @@ export async function handleSubscriptionEvent( polar_subscription_id: isActive ? subId : '' }); - log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status }); + log.info('polar', 'user role updated', { + userId: user.id, username: user.username, newRole, status + }); +} + +// ─── Order event handler ────────────────────────────────────────────────────── + +interface PolarOrder { + id: string; + status: string; + billing_reason: string; // "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update" + product_id: string | null; + customer_id: string; + subscription_id: string | null; + customer?: PolarCustomer; +} + +/** + * Handle order.created — used for initial subscription purchases. + * We only act on subscription_create billing_reason to avoid double-processing + * (subscription.active will also fire, but this ensures we catch edge cases). + */ +export async function handleOrderCreated(order: PolarOrder): Promise { + const { id: orderId, billing_reason, product_id, customer_id, customer } = order; + + log.info('polar', 'order.created', { orderId, billing_reason, product_id, customer_email: customer?.email }); + + // Only handle new subscription purchases here; renewals are handled by subscription.updated + if (billing_reason !== 'purchase' && billing_reason !== 'subscription_create') { + log.debug('polar', 'order.created — skipping non-purchase billing_reason', { billing_reason }); + return; + } + + if (!product_id || !POLAR_PRO_PRODUCT_IDS.has(product_id)) { + log.debug('polar', 'order.created — product not a pro product', { product_id }); + return; + } + + const user = await resolveUser(customer_id, customer); + if (!user) { + log.warn('polar', 'order.created — no app_user found', { + customer_email: customer?.email, customer_id + }); + return; + } + + // Only upgrade if not already pro/admin — subscription.active will do a full sync too + if (user.role !== 'pro' && user.role !== 'admin') { + await patchUser(user.id, { + role: 'pro', + polar_customer_id: customer_id + }); + log.info('polar', 'order.created — user upgraded to pro', { + userId: user.id, username: user.username + }); + } } diff --git a/ui/src/routes/api/webhooks/polar/+server.ts b/ui/src/routes/api/webhooks/polar/+server.ts index 1dfc649..6b9d19a 100644 --- a/ui/src/routes/api/webhooks/polar/+server.ts +++ b/ui/src/routes/api/webhooks/polar/+server.ts @@ -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[1]); + await handleSubscriptionEvent(type, data as Parameters[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[0]); break; default: diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index a144113..c4ccea7 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -10,24 +10,31 @@ export const load: PageServerLoad = async ({ locals }) => { } let sessions: Awaited> = []; - 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, diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 4e20230..98ff016 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -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(untrack(() => data.avatarUrl ?? null)); let avatarUploading = $state(false); @@ -288,12 +298,12 @@

{m.profile_upgrade_heading()}

{m.profile_upgrade_desc()}

- {m.profile_manage_subscription()}