This commit is contained in:
2026-05-10 10:37:45 +03:00
parent 4f7090e2d9
commit c525b12dfd
32 changed files with 2374 additions and 209 deletions
+209
View File
@@ -0,0 +1,209 @@
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;
}
}