diff --git a/apps/api/package.json b/apps/api/package.json index ffeda0b..9c3a178 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -37,7 +37,8 @@ "file-type": "catalog:", "hono": "catalog:", "resend": "catalog:", - "zod": "catalog:" + "zod": "catalog:", + "@orkide/billing": "workspace:*" }, "devDependencies": { "@cloudflare/vite-plugin": "catalog:", @@ -50,4 +51,4 @@ "vitest": "catalog:", "wrangler": "catalog:" } -} +} \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index cb4f8e0..fc02121 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,3 +1,4 @@ +import { billingRoutes } from "./modules/billing/billing.routes.ts"; import { OpenAPIHono } from "@hono/zod-openapi"; import { auth } from "@orkide/auth"; import { except } from "hono/combine"; @@ -42,6 +43,7 @@ const routes = app .route("/", taxonomyRoutes) .route("/", mediaRoutes) .route("/", contactRoutes) + .route("/", billingRoutes) .route("/", statsRoutes); registerOpenApi(app); diff --git a/apps/api/src/modules/billing/billing.routes.ts b/apps/api/src/modules/billing/billing.routes.ts new file mode 100644 index 0000000..e017915 --- /dev/null +++ b/apps/api/src/modules/billing/billing.routes.ts @@ -0,0 +1,72 @@ +import { Hono } from "hono"; +import { verifyPolarWebhookSignature, parsePolarWebhook } from "@orkide/billing"; +import { BillingModuleService, type BillingEnvironment } from "./billing.service"; + +export const billingRoutes = new Hono<{ Bindings: BillingEnvironment }>(); + +/** + * POST /api/v1/billing/checkout + * Inicjalizuje sesję zakupową w Polar + */ +billingRoutes.post("/checkout", async (c) => { + const body = await c.req.json(); + const service = new BillingModuleService(c.env); + + try { + const session = await service.startCheckout( + body.userId, + body.email, + body.productId, + body.successUrl + ); + return c.json({ ok: true, session }); + } catch (err: any) { + return c.json({ ok: false, error: err.message }, 400); + } +}); + +/** + * GET /api/v1/billing/portal + * Generuje link do portalu klienta (zarządzanie subskrypcją i kartami) + */ +billingRoutes.get("/portal", async (c) => { + const customerId = c.req.query("customerId"); + if (!customerId) { + return c.json({ ok: false, error: "Missing customerId" }, 400); + } + + const service = new BillingModuleService(c.env); + try { + const session = await service.openPortal(customerId); + return c.json({ ok: true, session }); + } catch (err: any) { + return c.json({ ok: false, error: err.message }, 400); + } +}); + +/** + * POST /api/v1/billing/webhook + * Obsługuje i weryfikuje podpisy zdarzeń Polar.sh (Standard Webhooks / Svix) + */ +billingRoutes.post("/webhook", async (c) => { + const payloadText = await c.req.text(); + const id = c.req.header("webhook-id") || ""; + const timestamp = c.req.header("webhook-timestamp") || ""; + const signature = c.req.header("webhook-signature") || ""; + + const isValid = await verifyPolarWebhookSignature( + payloadText, + { id, timestamp, signature }, + c.env.POLAR_WEBHOOK_SECRET + ); + + if (!isValid) { + return c.json({ ok: false, error: "Invalid webhook signature" }, 401); + } + + const event = parsePolarWebhook(payloadText); + const service = new BillingModuleService(c.env); + const result = await service.handleWebhookEvent(event.type, event.data); + + return c.json({ ok: true, result }); +}); diff --git a/apps/api/src/modules/billing/billing.service.ts b/apps/api/src/modules/billing/billing.service.ts new file mode 100644 index 0000000..55385f9 --- /dev/null +++ b/apps/api/src/modules/billing/billing.service.ts @@ -0,0 +1,54 @@ +import { PolarBillingService, type WebhookEventType } from "@orkide/billing"; + +export interface BillingEnvironment { + POLAR_ACCESS_TOKEN: string; + POLAR_SERVER?: "sandbox" | "production"; + POLAR_WEBHOOK_SECRET: string; +} + +export class BillingModuleService { + private billing: PolarBillingService; + + constructor(env: BillingEnvironment) { + this.billing = new PolarBillingService({ + accessToken: env.POLAR_ACCESS_TOKEN, + server: env.POLAR_SERVER || "sandbox", + webhookSecret: env.POLAR_WEBHOOK_SECRET, + }); + } + + async startCheckout(userId: string, email: string, productId: string, successUrl: string) { + return this.billing.createCheckoutSession({ + productId, + customerEmail: email, + successUrl, + metadata: { userId }, + }); + } + + async openPortal(polarCustomerId: string) { + return this.billing.createCustomerPortalSession({ + customerId: polarCustomerId, + }); + } + + async handleWebhookEvent(type: WebhookEventType, data: any) { + switch (type) { + case "subscription.created": + case "subscription.active": + // Aktualizacja uprawnień RBAC użytkownika na 'PRO' + return { action: "grant_entitlement", userId: data?.metadata?.userId, status: "active" }; + + case "subscription.canceled": + case "subscription.revoked": + // Odwołanie uprawnień RBAC + return { action: "revoke_entitlement", userId: data?.metadata?.userId, status: "revoked" }; + + case "order.created": + return { action: "order_processed", orderId: data?.id }; + + default: + return { action: "ignored", type }; + } + } +} diff --git a/packages/billing/package.json b/packages/billing/package.json new file mode 100644 index 0000000..c0ded8e --- /dev/null +++ b/packages/billing/package.json @@ -0,0 +1,25 @@ +{ + "name": "@orkide/billing", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./webhook": "./src/webhook.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@polar-sh/sdk": "^0.28.0" + }, + "devDependencies": { + "@orkide/config": "workspace:*", + "@orkide/logger": "workspace:*", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts new file mode 100644 index 0000000..0c044db --- /dev/null +++ b/packages/billing/src/index.ts @@ -0,0 +1,56 @@ +import { Polar } from "@polar-sh/sdk"; +import type { + PolarBillingConfig, + CreateCheckoutParams, + CustomerPortalParams, +} from "./types"; + +export * from "./types"; +export * from "./webhook"; + +export class PolarBillingService { + private client: Polar; + private server: "sandbox" | "production"; + + constructor(config: PolarBillingConfig) { + this.server = config.server || "sandbox"; + this.client = new Polar({ + accessToken: config.accessToken, + server: this.server, + }); + } + + /** + * Generuje sesję checkout dla zakupu produktu lub subskrypcji + */ + async createCheckoutSession(params: CreateCheckoutParams) { + const session = await this.client.checkouts.custom.create({ + productId: params.productId, + customerEmail: params.customerEmail, + successUrl: params.successUrl, + metadata: params.metadata || {}, + }); + return session; + } + + /** + * Generuje link do portalu klienta Polar (zarządzanie subskrypcją) + */ + async createCustomerPortalSession(params: CustomerPortalParams) { + const session = await this.client.customerSessions.create({ + customerId: params.customerId, + }); + return session; + } + + /** + * Pobiera listę aktywnych subskrypcji dla danego klienta + */ + async listSubscriptions(customerId: string) { + const list = await this.client.subscriptions.list({ + customerId, + active: true, + }); + return list; + } +} diff --git a/packages/billing/src/types.ts b/packages/billing/src/types.ts new file mode 100644 index 0000000..59aa04d --- /dev/null +++ b/packages/billing/src/types.ts @@ -0,0 +1,32 @@ +export interface PolarBillingConfig { + accessToken: string; + server?: "sandbox" | "production"; + webhookSecret?: string; +} + +export interface CreateCheckoutParams { + productId: string; + customerEmail: string; + successUrl: string; + metadata?: Record; +} + +export interface CustomerPortalParams { + customerId: string; +} + +export type WebhookEventType = + | "checkout.created" + | "checkout.updated" + | "subscription.created" + | "subscription.updated" + | "subscription.active" + | "subscription.canceled" + | "subscription.revoked" + | "order.created"; + +export interface PolarWebhookPayload { + type: WebhookEventType; + data: T; + timestamp: string; +} diff --git a/packages/billing/src/webhook.test.ts b/packages/billing/src/webhook.test.ts new file mode 100644 index 0000000..4952340 --- /dev/null +++ b/packages/billing/src/webhook.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { verifyPolarWebhookSignature, parsePolarWebhook } from "../src/packages/billing/src/webhook"; + +describe("Polar Webhook Verification", () => { + const secret = "sample_test_secret_for_vitest"; + const now = Math.floor(Date.now() / 1000).toString(); + const id = "msg_polar_test_123"; + const payload = JSON.stringify({ + type: "subscription.active", + data: { id: "sub_polar_abc", metadata: { userId: "user_42" } }, + timestamp: new Date().toISOString(), + }); + + it("should parse polar webhook payload correctly", () => { + const event = parsePolarWebhook(payload); + expect(event.type).toBe("subscription.active"); + expect((event.data as any).id).toBe("sub_polar_abc"); + }); + + it("should reject expired timestamp (replay prevention)", async () => { + const oldTimestamp = (Math.floor(Date.now() / 1000) - 400).toString(); + const isValid = await verifyPolarWebhookSignature( + payload, + { id, timestamp: oldTimestamp, signature: "v1,invalid" }, + secret + ); + expect(isValid).toBe(false); + }); + + it("should reject invalid signature format", async () => { + const isValid = await verifyPolarWebhookSignature( + payload, + { id, timestamp: now, signature: "v1,invalid_sig" }, + secret + ); + expect(isValid).toBe(false); + }); +}); diff --git a/packages/billing/src/webhook.ts b/packages/billing/src/webhook.ts new file mode 100644 index 0000000..0c3fcc5 --- /dev/null +++ b/packages/billing/src/webhook.ts @@ -0,0 +1,87 @@ +import type { PolarWebhookPayload } from "./types"; + +/** + * Weryfikuje podpis kryptograficzny Standard Webhook (Svix/Polar) + * Kompatybilny ze standardem Web Crypto API (Cloudflare Workers, Hono, Node 18+) + */ +export async function verifyPolarWebhookSignature( + payloadText: string, + headers: { + id: string; + timestamp: string; + signature: string; + }, + webhookSecret: string +): Promise { + if (!headers.id || !headers.timestamp || !headers.signature || !webhookSecret) { + return false; + } + + // Zabezpieczenie przed atakami typu replay (tolerancja max 5 minut) + const now = Math.floor(Date.now() / 1000); + const ts = parseInt(headers.timestamp, 10); + if (isNaN(ts) || Math.abs(now - ts) > 300) { + return false; + } + + // Standardowy format wiadomości: `${id}.${timestamp}.${body}` + const signedPayload = `${headers.id}.${headers.timestamp}.${payloadText}`; + const enc = new TextEncoder(); + + try { + // Normalizacja klucza: jeśli zaczyna się od prefixu 'whsec_' + const rawSecret = webhookSecret.startsWith("whsec_") + ? webhookSecret.slice(6) + : webhookSecret; + + // Klucz może być w base64 lub plain text + let secretKeyBytes: Uint8Array; + try { + const binaryString = atob(rawSecret); + secretKeyBytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + secretKeyBytes[i] = binaryString.charCodeAt(i); + } + } catch { + secretKeyBytes = enc.encode(rawSecret); + } + + const key = await crypto.subtle.importKey( + "raw", + secretKeyBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + const signatureBuffer = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(signedPayload) + ); + + const expectedSignature = btoa( + String.fromCharCode(...new Uint8Array(signatureBuffer)) + ); + + // Weryfikacja sygnatur (nagłówek może zawierać np. v1,sygnatura) + const signatures = headers.signature.split(" "); + for (const sig of signatures) { + const cleanSig = sig.startsWith("v1,") ? sig.slice(3) : sig; + if (cleanSig === expectedSignature) { + return true; + } + } + + return false; + } catch (err) { + return false; + } +} + +/** + * Bezpiecznie parsuje zweryfikowany payload zdarzenia Polar + */ +export function parsePolarWebhook(payloadText: string): PolarWebhookPayload { + return JSON.parse(payloadText) as PolarWebhookPayload; +} diff --git a/packages/billing/tsconfig.json b/packages/billing/tsconfig.json new file mode 100644 index 0000000..19dcd90 --- /dev/null +++ b/packages/billing/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@orkide/config/tsconfig.base.json", + "compilerOptions": { + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +}