210 lines
5.6 KiB
TypeScript
210 lines
5.6 KiB
TypeScript
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<string, unknown>;
|
|
}
|
|
|
|
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<string>("PADDLE_API_KEY", "");
|
|
this.webhookSecret = this.config.get<string>("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<void> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown> };
|
|
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<string>("PADDLE_PLUS_MONTHLY_PRICE_ID", "")]: {
|
|
plan: "plus",
|
|
interval: "monthly",
|
|
},
|
|
[this.config.get<string>("PADDLE_PLUS_YEARLY_PRICE_ID", "")]: {
|
|
plan: "plus",
|
|
interval: "yearly",
|
|
},
|
|
[this.config.get<string>("PADDLE_PREMIUM_MONTHLY_PRICE_ID", "")]: {
|
|
plan: "premium",
|
|
interval: "monthly",
|
|
},
|
|
[this.config.get<string>("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<string>(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<string>("PADDLE_CLIENT_TOKEN", "");
|
|
}
|
|
|
|
/**
|
|
* Get the Paddle environment
|
|
*/
|
|
getEnvironment(): "sandbox" | "production" {
|
|
return this.environment;
|
|
}
|
|
}
|