mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-30 02:36:30 +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;
|
} as const;
|
||||||
|
|
||||||
export const user = router({
|
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
|
getByEmail: publicProcedure
|
||||||
.input(z.object({ email: z.string() }))
|
.input(z.object({ email: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
|
|
@ -18,26 +18,6 @@ export const getSubscriptionStatus = async (userId: string) => {
|
||||||
if (user?.subscription?.active === true) {
|
if (user?.subscription?.active === true) {
|
||||||
return {
|
return {
|
||||||
active: true,
|
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;
|
} 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
|
past_due
|
||||||
canceled
|
canceled
|
||||||
unpaid
|
unpaid
|
||||||
deleted // @deprecated - Only used in UserPaymentData which is also deprecated
|
|
||||||
|
|
||||||
@@map("subscription_status")
|
@@map("subscription_status")
|
||||||
}
|
}
|
||||||
|
@ -100,18 +99,6 @@ model PaymentMethod {
|
||||||
@@map("payment_methods")
|
@@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 {
|
model Subscription {
|
||||||
id String @id
|
id String @id
|
||||||
priceId String @map("price_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(
|
await Promise.all(
|
||||||
[freeUser, proUser, proUserLegacy].map(async (user) => {
|
[freeUser, proUser].map(async (user) => {
|
||||||
Array.from({ length: 20 }).forEach(async () => {
|
Array.from({ length: 20 }).forEach(async () => {
|
||||||
await createPollForUser(user.id);
|
await createPollForUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
|
@ -96,7 +96,6 @@
|
||||||
"OIDC_NAME_CLAIM_PATH",
|
"OIDC_NAME_CLAIM_PATH",
|
||||||
"OIDC_NAME",
|
"OIDC_NAME",
|
||||||
"OIDC_PICTURE_CLAIM_PATH",
|
"OIDC_PICTURE_CLAIM_PATH",
|
||||||
"PADDLE_PUBLIC_KEY",
|
|
||||||
"PORT",
|
"PORT",
|
||||||
"SECRET_PASSWORD",
|
"SECRET_PASSWORD",
|
||||||
"SENTRY_AUTH_TOKEN",
|
"SENTRY_AUTH_TOKEN",
|
||||||
|
|
Loading…
Add table
Reference in a new issue