mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
🔥 Remove legacy billing system (paddle) (#1583)
This commit is contained in:
parent
5f49502922
commit
01758f81ae
8 changed files with 12 additions and 378 deletions
|
@ -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;
|
|
@ -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 {
|
||||
}
|
||||
}
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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")
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -96,7 +96,6 @@
|
|||
"OIDC_NAME_CLAIM_PATH",
|
||||
"OIDC_NAME",
|
||||
"OIDC_PICTURE_CLAIM_PATH",
|
||||
"PADDLE_PUBLIC_KEY",
|
||||
"PORT",
|
||||
"SECRET_PASSWORD",
|
||||
"SENTRY_AUTH_TOKEN",
|
||||
|
|
Loading…
Add table
Reference in a new issue