feat(billing): Polar.sh Pro subscription integration
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
- Webhook handler verifies HMAC-SHA256 sig and updates user role on
subscription.created / subscription.updated / subscription.revoked
- Audio endpoint gated: free users limited to 3 chapters/day via Valkey
counter; returns 402 {error:'pro_required'} when limit reached
- Translation proxy endpoint enforces 402 for non-pro users
- AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner
- Chapter page shows lock icon + upgrade prompts for gated translation langs
- Profile page: subscription section shows Pro badge + manage link (active)
or monthly/annual checkout buttons (free); isPro resolved fresh from DB
- i18n: 13 new profile_subscription_* keys across all 5 locales
This commit is contained in:
@@ -71,6 +71,8 @@ export interface User {
|
||||
verification_token_exp?: string;
|
||||
oauth_provider?: string;
|
||||
oauth_id?: string;
|
||||
polar_customer_id?: string;
|
||||
polar_subscription_id?: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -572,6 +574,28 @@ export async function getUserByOAuth(provider: string, oauthId: string): Promise
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by their Polar customer ID. Returns null if not found.
|
||||
*/
|
||||
export async function getUserByPolarCustomerId(polarCustomerId: string): Promise<User | null> {
|
||||
return listOne<User>(
|
||||
'app_users',
|
||||
`polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch arbitrary fields on an app_user record.
|
||||
*/
|
||||
export async function patchUser(userId: string, fields: Partial<User & Record<string, unknown>>): Promise<void> {
|
||||
const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body });
|
||||
throw new Error(`patchUser failed: ${res.status} — ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user via OAuth (no password). email_verified is true since the
|
||||
* provider already verified it. Throws on DB errors.
|
||||
|
||||
107
ui/src/lib/server/polar.ts
Normal file
107
ui/src/lib/server/polar.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Polar.sh integration — server-side only.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Verify webhook signatures (HMAC-SHA256)
|
||||
* - Patch app_users.polar_customer_id / polar_subscription_id / role on subscription events
|
||||
* - Expose isPro(userId) helper for gating
|
||||
*
|
||||
* Product IDs (Polar dashboard):
|
||||
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
|
||||
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
export const POLAR_PRO_PRODUCT_IDS = new Set([
|
||||
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
|
||||
'b6190307-79aa-4905-80c8-9ed941378d21' // annual
|
||||
]);
|
||||
|
||||
// ─── Webhook signature verification ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify the Polar webhook signature.
|
||||
* Polar signs with HMAC-SHA256 over the raw body; header is "webhook-signature".
|
||||
* Header format: "v1=<hex>" (may be comma-separated list of sigs)
|
||||
*/
|
||||
export function verifyPolarWebhook(rawBody: string, signatureHeader: string): boolean {
|
||||
const secret = env.POLAR_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
log.warn('polar', 'POLAR_WEBHOOK_SECRET not set — rejecting webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const expectedBuf = Buffer.from(`v1=${expected}`);
|
||||
|
||||
// Header may contain multiple sigs separated by ", "
|
||||
const sigs = signatureHeader.split(',').map((s) => s.trim());
|
||||
for (const sig of sigs) {
|
||||
try {
|
||||
const sigBuf = Buffer.from(sig);
|
||||
if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// length mismatch etc — try next
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Subscription event handler ───────────────────────────────────────────────
|
||||
|
||||
interface PolarSubscription {
|
||||
id: string;
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
customer_email?: string;
|
||||
user_id?: string; // Polar user id (not our user id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Polar subscription event.
|
||||
* Finds the matching app_user by email and updates role + polar fields.
|
||||
*/
|
||||
export async function handleSubscriptionEvent(
|
||||
eventType: string,
|
||||
subscription: PolarSubscription
|
||||
): Promise<void> {
|
||||
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
|
||||
|
||||
log.info('polar', 'subscription event', { eventType, subId, status, product_id, 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);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const isProProduct = POLAR_PRO_PRODUCT_IDS.has(product_id);
|
||||
const isActive = status === 'active';
|
||||
const newRole = isProProduct && isActive ? 'pro' : (user.role === 'admin' ? 'admin' : 'user');
|
||||
|
||||
await patchUser(user.id, {
|
||||
role: newRole,
|
||||
polar_customer_id: customer_id,
|
||||
polar_subscription_id: isActive ? subId : ''
|
||||
});
|
||||
|
||||
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
|
||||
}
|
||||
Reference in New Issue
Block a user