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

- 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:
Admin
2026-03-29 13:21:23 +05:00
parent 83b3dccc41
commit 98631df47a
19 changed files with 539 additions and 13 deletions

View File

@@ -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
View 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 });
}