From 3dcece5c6767606a6e113234449800037cbcf848 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 23 Apr 2026 11:59:05 -0400 Subject: [PATCH] feat: ko-fi integration for credit purchases --- backend/prisma/schema.prisma | 23 ++++ backend/src/routes/webhooks.ts | 112 ++++++++++++++++++ .../dashboard/components/BuyCreditsModal.tsx | 86 ++++++++++++++ frontend/src/app/dashboard/page.tsx | 12 ++ 4 files changed, 233 insertions(+) create mode 100644 frontend/src/app/dashboard/components/BuyCreditsModal.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 61f3824..45c1f96 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index e7c2b34..d8582f2 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -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 }; diff --git a/frontend/src/app/dashboard/components/BuyCreditsModal.tsx b/frontend/src/app/dashboard/components/BuyCreditsModal.tsx new file mode 100644 index 0000000..dd2bbff --- /dev/null +++ b/frontend/src/app/dashboard/components/BuyCreditsModal.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Coffee, ExternalLink, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface BuyCreditsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const PRESETS = [ + { dollars: 5, credits: 500, label: "Small Feed", emoji: "🌾" }, + { dollars: 10, credits: 1000, label: "Big Feed", emoji: "🌽" }, + { dollars: 20, credits: 2000, label: "Feast", emoji: "🍗" }, +]; + +export function BuyCreditsModal({ open, onOpenChange }: BuyCreditsModalProps) { + return ( + + + +
+ + Stock the Coop + + +
+

+ 100 $COOP per dollar. Use same email as Plex for auto-delivery. +

+
+ +
+ {PRESETS.map((preset) => ( + onOpenChange(false)} + > +
+ {preset.emoji} +
+
{preset.label}
+
+ ${preset.dollars} → {preset.credits} $COOP +
+
+
+ +
+ ))} + +
+

+ Any amount works. $1 = 100 $COOP. +

+ onOpenChange(false)} + > + + Support on Ko-fi + +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 4fb9f7a..2450275 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -2,6 +2,7 @@ import { Activity, + Coffee, Egg, ExternalLink, History, @@ -19,6 +20,7 @@ import { api, authApi, overseerApi, userApi } from "@/lib/api"; import { useStore } from "@/lib/store"; import { formatNumber } from "@/lib/utils"; import { ActivityFeed } from "./components/ActivityFeed"; +import { BuyCreditsModal } from "./components/BuyCreditsModal"; import { Leaderboard } from "./components/Leaderboard"; import { RequestHistory } from "./components/RequestHistory"; import { SearchRequestModal } from "./components/SearchRequestModal"; @@ -38,6 +40,7 @@ export default function DashboardPage() { const [pendingRequests, setPendingRequests] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + const [isBuyModalOpen, setIsBuyModalOpen] = useState(false); useEffect(() => { if (!isAuthenticated) { @@ -122,6 +125,14 @@ export default function DashboardPage() { PECK FOR FEED +