import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import * as crypto from "crypto"; export interface PaddleWebhookEvent { event_id: string; event_type: string; occurred_at: string; notification_id: string; data: Record; } interface PaddleTransactionResponse { data: { id: string; customer_id: string; status: string; }; } @Injectable() export class PaddleService { private readonly logger = new Logger(PaddleService.name); private readonly apiKey: string; private readonly webhookSecret: string; private readonly environment: "sandbox" | "production"; private readonly baseUrl: string; constructor(private readonly config: ConfigService) { this.apiKey = this.config.get("PADDLE_API_KEY", ""); this.webhookSecret = this.config.get("PADDLE_WEBHOOK_SECRET", ""); this.environment = this.config.get<"sandbox" | "production">( "PADDLE_ENVIRONMENT", "sandbox", ); this.baseUrl = this.environment === "production" ? "https://api.paddle.com" : "https://sandbox-api.paddle.com"; } /** * Verify Paddle webhook signature (Paddle Billing v2) */ verifyWebhookSignature(rawBody: string, signatureHeader: string): boolean { if (!this.webhookSecret) { this.logger.warn( "PADDLE_WEBHOOK_SECRET not configured, skipping verification", ); return false; } try { // Paddle signature format: ts=TIMESTAMP;h1=HASH const parts = signatureHeader.split(";"); const tsValue = parts .find((p) => p.startsWith("ts=")) ?.replace("ts=", ""); const h1Value = parts .find((p) => p.startsWith("h1=")) ?.replace("h1=", ""); if (!tsValue || !h1Value) { this.logger.warn("Invalid Paddle signature format"); return false; } // Compute expected signature: HMAC-SHA256(ts + ':' + rawBody) const signedPayload = `${tsValue}:${rawBody}`; const expectedSignature = crypto .createHmac("sha256", this.webhookSecret) .update(signedPayload) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(h1Value), Buffer.from(expectedSignature), ); } catch (error: unknown) { const err = error as Error; this.logger.error( `Webhook signature verification failed: ${err.message}`, ); return false; } } /** * Cancel a Paddle subscription */ async cancelSubscription( paddleSubscriptionId: string, effectiveFrom: | "immediately" | "next_billing_period" = "next_billing_period", ): Promise { const url = `${this.baseUrl}/subscriptions/${paddleSubscriptionId}/cancel`; const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ effective_from: effectiveFrom }), }); if (!response.ok) { const body = await response.text(); this.logger.error(`Paddle cancel failed: ${response.status} ${body}`); throw new Error( `Failed to cancel Paddle subscription: ${response.status}`, ); } this.logger.log( `Paddle subscription ${paddleSubscriptionId} cancelled (effective: ${effectiveFrom})`, ); } /** * Get subscription details from Paddle */ async getSubscription( paddleSubscriptionId: string, ): Promise> { const url = `${this.baseUrl}/subscriptions/${paddleSubscriptionId}`; const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }, }); if (!response.ok) { throw new Error(`Failed to get Paddle subscription: ${response.status}`); } const data = (await response.json()) as { data: Record }; return data.data; } /** * Map Paddle price ID to our internal plan */ mapPriceIdToPlan(priceId: string): { plan: "plus" | "premium"; interval: "monthly" | "yearly"; } | null { const mapping: Record< string, { plan: "plus" | "premium"; interval: "monthly" | "yearly" } > = { [this.config.get("PADDLE_PLUS_MONTHLY_PRICE_ID", "")]: { plan: "plus", interval: "monthly", }, [this.config.get("PADDLE_PLUS_YEARLY_PRICE_ID", "")]: { plan: "plus", interval: "yearly", }, [this.config.get("PADDLE_PREMIUM_MONTHLY_PRICE_ID", "")]: { plan: "premium", interval: "monthly", }, [this.config.get("PADDLE_PREMIUM_YEARLY_PRICE_ID", "")]: { plan: "premium", interval: "yearly", }, }; // Remove empty key (from missing env vars) delete mapping[""]; return mapping[priceId] ?? null; } /** * Get the Paddle price ID for a given plan and interval */ getPriceId(plan: "plus" | "premium", interval: "monthly" | "yearly"): string { const key = `PADDLE_${plan.toUpperCase()}_${interval.toUpperCase()}_PRICE_ID`; const priceId = this.config.get(key, ""); if (!priceId) { throw new Error( `Price ID not configured for ${plan} ${interval} (env: ${key})`, ); } return priceId; } /** * Get the client-side token for Paddle.js */ getClientToken(): string { return this.config.get("PADDLE_CLIENT_TOKEN", ""); } /** * Get the Paddle environment */ getEnvironment(): "sandbox" | "production" { return this.environment; } }