Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"file-type": "catalog:",
"hono": "catalog:",
"resend": "catalog:",
"zod": "catalog:"
"zod": "catalog:",
"@orkide/billing": "workspace:*"
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
Expand All @@ -50,4 +51,4 @@
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
}
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -42,6 +43,7 @@ const routes = app
.route("/", taxonomyRoutes)
.route("/", mediaRoutes)
.route("/", contactRoutes)
.route("/", billingRoutes)
.route("/", statsRoutes);

registerOpenApi(app);
Expand Down
72 changes: 72 additions & 0 deletions apps/api/src/modules/billing/billing.routes.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
54 changes: 54 additions & 0 deletions apps/api/src/modules/billing/billing.service.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
}
25 changes: 25 additions & 0 deletions packages/billing/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
56 changes: 56 additions & 0 deletions packages/billing/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
32 changes: 32 additions & 0 deletions packages/billing/src/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

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<T = unknown> {
type: WebhookEventType;
data: T;
timestamp: string;
}
38 changes: 38 additions & 0 deletions packages/billing/src/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading