feat: ko-fi integration for credit purchases

This commit is contained in:
2026-04-23 11:59:05 -04:00
parent 4a68cc85f3
commit 3dcece5c67
4 changed files with 233 additions and 0 deletions
+23
View File
@@ -23,6 +23,7 @@ model User {
watchEvents WatchEvent[]
contentRequests ContentRequest[]
sessions Session[]
kofiPayments KofiPayment[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -118,11 +119,33 @@ model SystemSettings {
@@map("system_settings")
}
model KofiPayment {
id String @id @default(uuid())
kofiTransactionId String @unique @map("kofi_transaction_id")
email String
fromName String @map("from_name")
amount Float
creditsGranted Int @map("credits_granted")
message String?
isPublic Boolean @default(true) @map("is_public")
currency String @default("USD")
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
status String @default("PENDING")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId])
@@index([email])
@@index([status])
@@map("kofi_payments")
}
enum TransactionType {
EARN
SPEND
BONUS
ADJUSTMENT
PURCHASE
}
enum RequestStatus {
+112
View File
@@ -6,6 +6,7 @@ import { prisma } from "../utils/prisma";
const router = Router();
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
const KOFI_VERIFICATION_TOKEN = process.env.KOFI_VERIFICATION_TOKEN || "";
function verifyWebhookSignature(payload: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return true;
@@ -115,4 +116,115 @@ router.post(
}),
);
router.post(
"/kofi",
asyncHandler(async (req, res) => {
const rawData = req.body.data;
if (!rawData) return res.status(400).json({ error: "Missing data" });
let payload: any;
try {
payload = JSON.parse(rawData);
} catch {
return res.status(400).json({ error: "Invalid JSON" });
}
if (payload.verification_token !== KOFI_VERIFICATION_TOKEN) {
return res.status(403).json({ error: "Invalid verification token" });
}
if (payload.type !== "Donation") {
return res.json({ message: "Non-donation event ignored" });
}
const kofiTxId = payload.kofi_transaction_id;
if (!kofiTxId) {
return res.status(400).json({ error: "Missing transaction id" });
}
const existing = await prisma.kofiPayment.findUnique({
where: { kofiTransactionId: kofiTxId },
});
if (existing) {
return res.json({ success: true, alreadyProcessed: true });
}
const amount = parseFloat(payload.amount) || 0;
const creditsGranted = Math.floor(amount * 100);
const email = (payload.email || "").toLowerCase().trim();
if (creditsGranted <= 0) {
return res.status(400).json({ error: "Invalid amount" });
}
const user = email
? await prisma.user.findFirst({
where: {
email: { equals: email, mode: "insensitive" },
},
})
: null;
if (user) {
await prisma.$transaction([
prisma.kofiPayment.create({
data: {
kofiTransactionId: kofiTxId,
email: payload.email || "",
fromName: payload.from_name || "",
amount,
creditsGranted,
message: payload.message || null,
isPublic: payload.is_public ?? true,
currency: payload.currency || "USD",
userId: user.id,
status: "CREDITED",
},
}),
prisma.transaction.create({
data: {
userId: user.id,
type: "PURCHASE",
amount: creditsGranted,
description: `Ko-fi $${amount.toFixed(2)}`,
contentTitle: "Ko-fi Credit Purchase",
},
}),
prisma.user.update({
where: { id: user.id },
data: { totalEarned: { increment: creditsGranted } },
}),
]);
io.to(`user:${user.id}`).emit("credits_earned", {
amount: creditsGranted,
title: "Ko-fi Purchase",
transaction: {
id: kofiTxId,
type: "PURCHASE",
amount: creditsGranted,
contentTitle: "Ko-fi Credit Purchase",
createdAt: new Date(),
},
});
} else {
await prisma.kofiPayment.create({
data: {
kofiTransactionId: kofiTxId,
email: payload.email || "",
fromName: payload.from_name || "",
amount,
creditsGranted,
message: payload.message || null,
isPublic: payload.is_public ?? true,
currency: payload.currency || "USD",
status: "UNCLAIMED",
},
});
}
res.json({ success: true, credited: !!user, creditsGranted });
}),
);
export { router as webhookRouter };