🔥 Remove legacy billing system (paddle) (#1583)

This commit is contained in:
Luke Vella 2025-02-27 11:14:49 +00:00 committed by GitHub
parent 5f49502922
commit 01758f81ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 12 additions and 378 deletions

View file

@ -1,100 +0,0 @@
// Original source: https://gist.github.com/dsumer/5a4b120d6c8bde061b75667b067797c7
export interface PaddlePassthrough {
userId: string; // the id of the user in our supabase database
}
export type PaddleSubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "paused"
| "deleted";
type AlertName =
| "subscription_created"
| "subscription_updated"
| "subscription_cancelled"
| "subscription_payment_succeeded"
| "subscription_payment_failed"
| "subscription_payment_refunded";
export type PaymentStatus = "success" | "error" | "refund";
interface BasePaddleRequest {
alert_id: string;
alert_name: AlertName;
status: PaddleSubscriptionStatus;
/**
* Holds the data we pass to Paddle at the checkout as a JSON string.
* Take a look at {@link PaddlePassthrough} to see what it contains.
*/
passthrough: string;
subscription_id: string;
subscription_plan_id: string;
currency: string;
}
interface SubscriptionCreatedRequest extends BasePaddleRequest {
alert_name: "subscription_created";
next_bill_date: string;
cancel_url: string;
update_url: string;
unit_price: string;
}
interface SubscriptionUpdatedRequest extends BasePaddleRequest {
alert_name: "subscription_updated";
next_bill_date: string;
cancel_url: string;
update_url: string;
new_unit_price: string;
}
interface SubscriptionCancelledRequest extends BasePaddleRequest {
alert_name: "subscription_cancelled";
cancellation_effective_date: string;
}
interface SubscriptionPaymentSucceededRequest extends BasePaddleRequest {
alert_name: "subscription_payment_succeeded";
subscription_payment_id: string;
country: string;
currency: string;
customer_name: string;
fee: string;
payment_method: string;
payment_tax: string;
receipt_url: string;
sale_gross: string;
next_bill_date: string;
initial_payment: string;
}
interface SubscriptionPaymentFailedRequest extends BasePaddleRequest {
alert_name: "subscription_payment_failed";
subscription_payment_id: string;
amount: string;
currency: string;
next_retry_date?: string;
attempt_number: string;
}
interface SubscriptionPaymentRefundedRequest extends BasePaddleRequest {
alert_name: "subscription_payment_refunded";
subscription_payment_id: string;
gross_refund: string;
fee_refund: string;
tax_refund: string;
currency: string;
refund_reason: string;
refund_type: string;
}
export type PaddleRequest =
| SubscriptionCreatedRequest
| SubscriptionUpdatedRequest
| SubscriptionCancelledRequest
| SubscriptionPaymentSucceededRequest
| SubscriptionPaymentFailedRequest
| SubscriptionPaymentRefundedRequest;

View file

@ -1,209 +0,0 @@
// Original source: https://gist.github.com/dsumer/3594cda57e84a93a9019cddc71831882
import { prisma } from "@rallly/database";
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import * as Serialize from "php-serialize";
import type { PaddlePassthrough, PaddleRequest } from "@/paddle.interface";
const allowedIpAdresses = [
// Sandbox
"34.194.127.46",
"54.234.237.108",
"3.208.120.145",
"44.226.236.210",
"44.241.183.62",
"100.20.172.113",
// Production
"34.232.58.13",
"34.195.105.136",
"34.237.3.244",
"35.155.119.135",
"52.11.166.252",
"34.212.5.7",
];
const getIpAddress = (req: NextApiRequest): string => {
const forwarded = req.headers["x-forwarded-for"] || "";
if (typeof forwarded === "string") {
return forwarded.split(",")[0] || req.socket.remoteAddress || "";
}
return forwarded[0] || req.socket.remoteAddress || "";
};
function ksort(obj: Record<string, unknown>) {
const keys = Object.keys(obj).sort();
const sortedObj: Record<string, unknown> = {};
for (const i in keys) {
sortedObj[keys[i]] = obj[keys[i]];
}
return sortedObj;
}
export function validateWebhook(req: NextApiRequest) {
if (!allowedIpAdresses.includes(getIpAddress(req))) {
console.error("No valid paddle ip address");
return false;
}
let jsonObj = req.body;
// Grab p_signature
const mySig = Buffer.from(jsonObj.p_signature, "base64");
// Remove p_signature from object - not included in array of fields used in verification.
delete jsonObj.p_signature;
// Need to sort array by key in ascending order
jsonObj = ksort(jsonObj);
for (const property in jsonObj) {
if (
jsonObj.hasOwnProperty(property) &&
typeof jsonObj[property] !== "string"
) {
if (Array.isArray(jsonObj[property])) {
// is it an array
jsonObj[property] = jsonObj[property].toString();
} else {
//if its not an array and not a string, then it is a JSON obj
jsonObj[property] = JSON.stringify(jsonObj[property]);
}
}
}
// Serialise remaining fields of jsonObj
const serialized = Serialize.serialize(jsonObj);
// verify the serialized array against the signature using SHA1 with your public key.
const verifier = crypto.createVerify("sha1");
verifier.update(serialized);
verifier.end();
if (!process.env.PADDLE_PUBLIC_KEY) {
throw new Error("Missing paddle public key");
}
const publicKey = crypto.createPublicKey(
`-----BEGIN PUBLIC KEY-----\n${process.env.PADDLE_PUBLIC_KEY}\n-----END PUBLIC KEY-----`,
);
const isValid = verifier.verify(publicKey, mySig);
if (!isValid) {
console.error("Invalid paddle signature");
}
return isValid;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// Your Paddle webhook code will be handled here
// The webhook payload is sent as a form-data
const payload: PaddleRequest = req.body;
const isValid = validateWebhook(req);
if (!isValid) {
// The signature is not valid, response with an error
return res.status(500).json({ error: "Invalid signature" });
}
let passthrough: PaddlePassthrough | null = null;
try {
passthrough = JSON.parse(payload.passthrough) as PaddlePassthrough;
} catch {}
if (!passthrough) {
res.status(400).send("Invalid passthrough: " + payload.passthrough);
return;
}
// At this point, the webhook is valid, handle the webhook event
switch (payload.alert_name) {
case "subscription_created": {
// Handle new subscription
const data = {
subscriptionId: payload.subscription_id,
status: payload.status,
planId: payload.subscription_plan_id,
endDate: new Date(payload.next_bill_date),
updateUrl: payload.update_url,
cancelUrl: payload.cancel_url,
};
await prisma.userPaymentData.upsert({
where: {
userId: passthrough.userId,
},
create: {
userId: passthrough.userId,
...data,
},
update: data,
});
break;
}
case "subscription_payment_succeeded":
// Handle successful payment
// This event is triggered before subscription_created which means
// the row has not been created yet. If the subscription is renewed inital_payment
// won't be "1" and we can update the row.
if (payload.initial_payment !== "1") {
await prisma.userPaymentData.update({
where: {
userId: passthrough.userId,
},
data: {
status: payload.status,
endDate: new Date(payload.next_bill_date),
},
});
}
break;
case "subscription_payment_failed":
await prisma.userPaymentData.update({
where: {
userId: passthrough.userId,
},
data: {
status: payload.status,
},
});
break;
case "subscription_updated":
// Handle updated subscription
await prisma.userPaymentData.update({
where: {
userId: passthrough.userId,
},
data: {
status: payload.status,
planId: payload.subscription_plan_id,
endDate: new Date(payload.next_bill_date),
updateUrl: payload.update_url,
cancelUrl: payload.cancel_url,
},
});
break;
case "subscription_cancelled":
// Handle cancelled subscription
await prisma.userPaymentData.update({
where: {
userId: passthrough.userId,
},
data: {
status: payload.status,
endDate: new Date(payload.cancellation_effective_date),
},
});
break;
default:
// If the webhook event is not handled, respond with an error
return res.status(400).json({ error: "Webhook event not supported" });
}
// If everything went well, send a 200 OK
return res.status(200).json({ success: true });
} else {
}
}

View file

@ -24,21 +24,6 @@ const mimeToExtension = {
} as const;
export const user = router({
getBilling: privateProcedure.query(async ({ ctx }) => {
return await prisma.userPaymentData.findUnique({
select: {
subscriptionId: true,
status: true,
planId: true,
endDate: true,
updateUrl: true,
cancelUrl: true,
},
where: {
userId: ctx.user.id,
},
});
}),
getByEmail: publicProcedure
.input(z.object({ email: z.string() }))
.query(async ({ input }) => {

View file

@ -18,26 +18,6 @@ export const getSubscriptionStatus = async (userId: string) => {
if (user?.subscription?.active === true) {
return {
active: true,
legacy: false,
} as const;
}
const userPaymentData = await prisma.userPaymentData.findFirst({
where: {
userId,
endDate: {
gt: new Date(),
},
},
select: {
endDate: true,
},
});
if (userPaymentData) {
return {
active: true,
legacy: true,
} as const;
}

View file

@ -0,0 +1,11 @@
-- DropTable
DROP TABLE "user_payment_data";
-- AlterEnum
BEGIN;
CREATE TYPE "subscription_status_new" AS ENUM ('incomplete', 'incomplete_expired', 'active', 'paused', 'trialing', 'past_due', 'canceled', 'unpaid');
ALTER TABLE "subscriptions" ALTER COLUMN "status" TYPE "subscription_status_new" USING ("status"::text::"subscription_status_new");
ALTER TYPE "subscription_status" RENAME TO "subscription_status_old";
ALTER TYPE "subscription_status_new" RENAME TO "subscription_status";
DROP TYPE "subscription_status_old";
COMMIT;

View file

@ -73,7 +73,6 @@ enum SubscriptionStatus {
past_due
canceled
unpaid
deleted // @deprecated - Only used in UserPaymentData which is also deprecated
@@map("subscription_status")
}
@ -100,18 +99,6 @@ model PaymentMethod {
@@map("payment_methods")
}
model UserPaymentData {
userId String @id @map("user_id")
subscriptionId String @map("subscription_id")
planId String @map("plan_id")
endDate DateTime @map("end_date")
status SubscriptionStatus
updateUrl String @map("update_url")
cancelUrl String @map("cancel_url")
@@map("user_payment_data")
}
model Subscription {
id String @id
priceId String @map("price_id")

View file

@ -142,27 +142,8 @@ async function main() {
},
});
const proUserLegacy = await prisma.user.create({
data: {
name: "Pro User Legacy",
email: "dev+prolegacy@rallly.co",
},
});
await prisma.userPaymentData.create({
data: {
userId: proUserLegacy.id,
status: "active",
endDate: dayjs().add(1, "year").toDate(),
planId: "pro_123",
updateUrl: "https://example.com/update",
cancelUrl: "https://example.com/cancel",
subscriptionId: "sub_123",
},
});
await Promise.all(
[freeUser, proUser, proUserLegacy].map(async (user) => {
[freeUser, proUser].map(async (user) => {
Array.from({ length: 20 }).forEach(async () => {
await createPollForUser(user.id);
});

View file

@ -96,7 +96,6 @@
"OIDC_NAME_CLAIM_PATH",
"OIDC_NAME",
"OIDC_PICTURE_CLAIM_PATH",
"PADDLE_PUBLIC_KEY",
"PORT",
"SECRET_PASSWORD",
"SENTRY_AUTH_TOKEN",