// app/api/webhooks/kofi/route.ts import { NextRequest, NextResponse } from "next/server"; /** * ENV required: * - KOFI_VERIFY_TOKEN * - DIRECTUS_URL (e.g. https://forms.lasereverything.net) * - DIRECTUS_TOKEN_ADMIN_SUPPORTER (supporter-bot service token) * * Notes: * - renews_at is computed as (timestamp + 1 calendar month + 1 day) * - started_at is the first subscription payment timestamp seen and is preserved */ const VERIFY = process.env.KOFI_VERIFY_TOKEN!; const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; const COLLECTION = "user_memberships"; type KofiPayload = { verification_token: string; message_id: string; timestamp: string; // ISO type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string; is_public?: boolean; from_name?: string; message?: string | null; amount?: string; url?: string; email?: string | null; currency?: string; is_subscription_payment?: boolean; is_first_subscription_payment?: boolean; kofi_transaction_id?: string; shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null; tier_name?: string | null; shipping?: Record | null; discord_username?: string | null; discord_userid?: string | null; }; export async function POST(req: NextRequest) { // 1) Expect application/x-www-form-urlencoded with "data" JSON string let data: KofiPayload | null = null; try { const ctype = req.headers.get("content-type") || ""; if (ctype.includes("application/x-www-form-urlencoded")) { const form = await req.formData(); const raw = form.get("data"); if (typeof raw !== "string") throw new Error("missing data"); data = JSON.parse(raw) as KofiPayload; } else { // Try to be forgiving if Ko-fi ever sends JSON directly data = (await req.json()) as KofiPayload; } } catch { return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 }); } if (!data) { return NextResponse.json({ ok: false, error: "empty_payload" }, { status: 400 }); } // 2) Verify token (Ko-fi puts it inside the data JSON) if (!data.verification_token || data.verification_token !== VERIFY) { // Ko-fi retries on non-200, so 401 is fine here return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); } // 3) Normalize → membership record const t = (data.type || "").toLowerCase(); const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription"); const status = isRecurring ? "active" : "one_time"; // Choose a stable external id (prefer email; fallback to discord id, then a name) const external_user_id = (data.email && data.email.trim()) || (data.discord_userid && data.discord_userid.trim()) || (data.from_name && data.from_name.trim()) || "unknown"; // Fetch existing record for started_at preservation const filter = encodeURIComponent( JSON.stringify({ _and: [{ provider: { _eq: "kofi" } }, { external_user_id: { _eq: external_user_id } }], }) ); const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store", }); if (!existingRes.ok) { return NextResponse.json( { ok: false, error: "directus_error", detail: `read failed ${existingRes.status}` }, { status: 500 } ); } const existingJson = await existingRes.json().catch(() => ({} as any)); const existing = existingJson?.data?.[0]; const ts = data.timestamp ? new Date(data.timestamp) : null; // Helper: add 1 calendar month + 1 day, clamping end-of-month correctly function addOneMonthPlusOneDay(d: Date) { const nd = new Date(d); const origDay = nd.getDate(); const origMonth = nd.getMonth(); nd.setMonth(origMonth + 1); // If month rolled over (e.g., Jan 31 -> Mar 02/03), clamp to end of intended next month. if (nd.getMonth() !== ((origMonth + 1) % 12)) { nd.setDate(0); // last day of previous month (i.e., end of the intended next month) } else { // Keep the original day if possible (handles 28/29/30-day months naturally) // Already set by setMonth above. } // Plus one extra day buffer nd.setDate(nd.getDate() + 1); return nd; } // Determine started_at: first subscription payment time; keep existing if present const started_at = isRecurring && ts ? existing?.started_at ? new Date(existing.started_at) : ts : existing?.started_at ? new Date(existing.started_at) : null; // Determine renews_at: for subscription payments, slide to 1 month + 1 day past ts const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts) : null; const record = { provider: "kofi", external_user_id, email: data.email || null, username: data.from_name || null, // Ko-fi often provides from_name rather than a handle status, // "active" for subs; "one_time" for donations/shop/commission tier: data.tier_name || null, // present for membership tiers started_at, renews_at, last_event_at: new Date(), raw: safeStringify(data), // Long Text column with JSON editor interface }; // 4) Upsert by (provider, external_user_id) try { const url = existing?.id ? `${DIRECTUS}/items/${COLLECTION}/${existing.id}` : `${DIRECTUS}/items/${COLLECTION}`; const method = existing?.id ? "PATCH" : "POST"; // If we're patching and this event wasn't a subscription, avoid clobbering existing started_at if (existing?.id && (!isRecurring || !ts)) { // don't send started_at; preserve what's in DB delete (record as any).started_at; delete (record as any).renews_at; // keep existing renews_at if not a sub event } const writeRes = await fetch(url, { method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${BOT_TOKEN}`, }, body: JSON.stringify(record), }); if (!writeRes.ok) { const body = await writeRes.text().catch(() => ""); throw new Error(`${method} failed ${writeRes.status} ${body}`); } } catch (err: any) { // Non-200 makes Ko-fi retry, which is usually what we want on DB error return NextResponse.json( { ok: false, error: "directus_error", detail: String(err?.message || err) }, { status: 500 } ); } // 5) Return 200 per Ko-fi requirement (stops retries) return NextResponse.json({ ok: true }, { status: 200 }); } function safeStringify(v: unknown) { try { return JSON.stringify(v); } catch { return JSON.stringify({ _unstringifiable: true }); } }