gg
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user