diff --git a/.env.example b/.env.example
index e006e16..79a6f64 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,15 @@ SUPABASE_SERVICE_ROLE_KEY="eyJxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxx"
CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxx"
+# ── Stripe (Sponsoring, Gitea #22) ──────────────────────────
+# Stripe Dashboard → Developers → API keys
+STRIPE_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxx"
+# Stripe Dashboard → Developers → Webhooks → Add endpoint → copy Signing Secret
+# Endpoint must point at https:///api/webhooks/stripe
+# Subscribe to: checkout.session.completed, invoice.paid,
+# customer.subscription.updated, customer.subscription.deleted
+STRIPE_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"
+
# ── Mux (Video) ──────────────────────────────────────────────
# Mux Dashboard → Settings → Access Tokens → Create Token (Mux Video)
MUX_TOKEN_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
diff --git a/messages/de.json b/messages/de.json
index 58c991d..4ded3a1 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -127,6 +127,27 @@
"Theme": {
"toggleLabel": "Dark Mode"
},
+ "Sponsor": {
+ "title": "PawFeed unterstützen",
+ "subtitle": "PawFeed lebt von freiwilliger Unterstützung statt Werbung. Mit deinem Beitrag hilfst du, die Plattform am Laufen zu halten — als Dankeschön bekommst du das Sponsor-Badge auf deinem Profil und bei jedem Beitrag.",
+ "oneTime": "Einmalig",
+ "monthly": "Monatlich",
+ "customAmountLabel": "Betrag in Euro",
+ "minAmountHint": "Mindestbetrag: {min}€",
+ "submitOneTime": "Jetzt einmalig unterstützen",
+ "submitSubscription": "Jetzt Abo starten",
+ "noPerksHint": "Kein Vorteil außer dem Sponsor-Badge — freiwilliger Beitrag, keine steuerlich absetzbare Spende.",
+ "alreadySponsorTitle": "Du bist schon Sponsor — danke!",
+ "oneTimeSince": "Einmaliger Beitrag am {date}.",
+ "activeSubscriptionSince": "Aktives Abo seit {date}.",
+ "cancelSubscription": "Abo kündigen",
+ "canceledUntil": "Gekündigt — dein Sponsor-Badge bleibt bis zum {date} aktiv.",
+ "historyTitle": "Deine Beiträge",
+ "paymentSuccess": "Danke für deine Unterstützung!",
+ "paymentCanceled": "Zahlung abgebrochen — kein Betrag wurde abgebucht.",
+ "checkoutError": "Der Bezahlvorgang konnte nicht gestartet werden. Bitte versuch es später erneut.",
+ "cancelError": "Die Kündigung ist fehlgeschlagen. Bitte versuch es später erneut."
+ },
"AiDisclosure": {
"question": "Enthält dieser Inhalt KI-generierte oder KI-bearbeitete Bilder/Texte?",
"none": "Nein",
diff --git a/messages/en.json b/messages/en.json
index 8a8691b..b2c78b9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -127,6 +127,27 @@
"Theme": {
"toggleLabel": "Dark Mode"
},
+ "Sponsor": {
+ "title": "Support PawFeed",
+ "subtitle": "PawFeed runs on voluntary support instead of ads. Your contribution helps keep the platform running — as a thank-you, you get the Sponsor badge on your profile and on every post.",
+ "oneTime": "One-time",
+ "monthly": "Monthly",
+ "customAmountLabel": "Amount in Euro",
+ "minAmountHint": "Minimum: {min}€",
+ "submitOneTime": "Support now",
+ "submitSubscription": "Start subscription",
+ "noPerksHint": "No perk other than the Sponsor badge — a voluntary contribution, not a tax-deductible donation.",
+ "alreadySponsorTitle": "You're already a sponsor — thank you!",
+ "oneTimeSince": "One-time contribution on {date}.",
+ "activeSubscriptionSince": "Active subscription since {date}.",
+ "cancelSubscription": "Cancel subscription",
+ "canceledUntil": "Canceled — your Sponsor badge stays active until {date}.",
+ "historyTitle": "Your contributions",
+ "paymentSuccess": "Thank you for your support!",
+ "paymentCanceled": "Payment canceled — nothing was charged.",
+ "checkoutError": "Couldn't start the checkout. Please try again later.",
+ "cancelError": "Cancellation failed. Please try again later."
+ },
"AiDisclosure": {
"question": "Does this content include AI-generated or AI-modified images/text?",
"none": "No",
diff --git a/package-lock.json b/package-lock.json
index 977beba..08d4e9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,7 @@
"react-intersection-observer": "^10.0.3",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
+ "stripe": "^22.5.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
@@ -15555,6 +15556,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/stripe": {
+ "version": "22.5.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.5.0.tgz",
+ "integrity": "sha512-QVwMwriC0bbySx6R4dpsvJ0W//GojC1kwWVS6rPSoVqDUIZX4Hy3TaUrd2AZeXEAaKbfWIjQjvo3vKAReHZ0vQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
diff --git a/package.json b/package.json
index 6c788cd..8c3ace0 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"react-intersection-observer": "^10.0.3",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
+ "stripe": "^22.5.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0ce35ef..70ff904 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -23,6 +23,14 @@ model Owner {
warnings Warning[]
themePreference ThemePreference? // null = follows system preference, never explicitly toggled
+ // Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge.
+ stripeCustomerId String? @unique
+ sponsorSince DateTime? // permanent for one-time givers; for subscribers, valid only through sponsorPeriodEnd
+ sponsorPeriodEnd DateTime? // only set for an active subscription; controls how long the badge stays visible after cancellation
+ sponsorSubscriptionId String? @unique
+ sponsorSubscriptionStatus String? @db.VarChar(20) // mirrors Stripe subscription.status
+ contributions SponsorContribution[]
+
@@index([createdAt(sort: Desc)])
}
@@ -803,3 +811,21 @@ model LegalDocumentVersion {
@@index([publishedAt(sort: Desc)])
}
+
+// Gitea #22 — one row per successful Stripe payment (one-time or a
+// subscription's recurring invoice). Kept as a durable record independent
+// of Stripe's own dashboard, and as the source for the owner-facing
+// contribution history on /unterstuetzen.
+model SponsorContribution {
+ id String @id @default(cuid())
+ owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
+ ownerId String
+ type String @db.VarChar(20) // ONE_TIME | SUBSCRIPTION
+ amountCents Int
+ currency String @db.VarChar(3) @default("eur")
+ stripeCheckoutSessionId String? @unique
+ stripeInvoiceId String? @unique
+ createdAt DateTime @default(now())
+
+ @@index([ownerId, createdAt(sort: Desc)])
+}
diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts
index 1083e9b..4297e5e 100644
--- a/src/__tests__/helpers/prisma-mock.ts
+++ b/src/__tests__/helpers/prisma-mock.ts
@@ -182,6 +182,11 @@ interface MockHashtagClient {
upsert: MockFn;
}
+interface MockSponsorContributionClient {
+ create: MockFn;
+ findMany: MockFn;
+}
+
interface MockPostHashtagClient {
upsert: MockFn;
findMany: MockFn;
@@ -282,6 +287,7 @@ interface MockPrismaClient {
repost: MockRepostClient;
hashtag: MockHashtagClient;
postHashtag: MockPostHashtagClient;
+ sponsorContribution: MockSponsorContributionClient;
advertisement: MockAdvertisementClient;
adReaction: MockAdReactionClient;
adComment: MockAdCommentClient;
@@ -447,6 +453,10 @@ export function createMockPrisma(): MockPrismaClient {
findMany: vi.fn(),
upsert: vi.fn(),
},
+ sponsorContribution: {
+ create: vi.fn(),
+ findMany: vi.fn(),
+ },
postHashtag: {
upsert: vi.fn(),
findMany: vi.fn(),
diff --git a/src/__tests__/sponsor.test.ts b/src/__tests__/sponsor.test.ts
new file mode 100644
index 0000000..65e7c3d
--- /dev/null
+++ b/src/__tests__/sponsor.test.ts
@@ -0,0 +1,157 @@
+/**
+ * Sponsor Router Unit Tests (Gitea #22)
+ *
+ * Covers: getStatus, createCheckoutSession (new + existing Stripe customer),
+ * cancelSubscription (success + no-active-subscription error).
+ */
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { TRPCError } from "@trpc/server";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@clerk/nextjs/server", () => ({
+ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
+}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+const mockCustomersCreate = vi.fn();
+const mockCheckoutSessionsCreate = vi.fn();
+const mockSubscriptionsUpdate = vi.fn();
+vi.mock("@/lib/stripe", () => ({
+ stripe: {
+ customers: { create: (...args: unknown[]) => mockCustomersCreate(...args) },
+ checkout: { sessions: { create: (...args: unknown[]) => mockCheckoutSessionsCreate(...args) } },
+ subscriptions: { update: (...args: unknown[]) => mockSubscriptionsUpdate(...args) },
+ },
+ STRIPE_WEBHOOK_SECRET: "whsec_test",
+}));
+
+import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
+
+async function createSponsorCaller(overrides?: {
+ userId?: string | null;
+ prisma?: ReturnType;
+ ip?: string | null;
+}) {
+ const { sponsorRouter } = await import("@/trpc/routers/sponsor");
+ const { initTRPC } = await import("@trpc/server");
+
+ const mockPrisma = overrides?.prisma ?? createMockPrisma();
+ const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
+ const ip = overrides?.ip !== undefined ? overrides.ip : "203.0.113.42";
+
+ const t = initTRPC
+ .context<{ userId: string | null; prisma: typeof mockPrisma; ip: string | null }>()
+ .create();
+
+ const testRouter = t.router({ sponsor: sponsorRouter });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any, ip });
+ return { caller, mockPrisma };
+}
+
+afterEach(() => {
+ mockCustomersCreate.mockReset();
+ mockCheckoutSessionsCreate.mockReset();
+ mockSubscriptionsUpdate.mockReset();
+});
+
+describe("sponsor.getStatus", () => {
+ it("reports isSponsor:false when the owner has never contributed", async () => {
+ const { caller, mockPrisma } = await createSponsorCaller();
+ mockPrisma.owner.findUnique.mockResolvedValue({
+ sponsorSince: null,
+ sponsorPeriodEnd: null,
+ sponsorSubscriptionId: null,
+ sponsorSubscriptionStatus: null,
+ contributions: [],
+ });
+
+ const result = await caller.sponsor.getStatus();
+ expect(result.isSponsor).toBe(false);
+ expect(result.contributions).toEqual([]);
+ });
+
+ it("reports isSponsor:true for a one-time giver (no period end needed)", async () => {
+ const { caller, mockPrisma } = await createSponsorCaller();
+ mockPrisma.owner.findUnique.mockResolvedValue({
+ sponsorSince: new Date("2026-01-01"),
+ sponsorPeriodEnd: null,
+ sponsorSubscriptionId: null,
+ sponsorSubscriptionStatus: null,
+ contributions: [],
+ });
+
+ const result = await caller.sponsor.getStatus();
+ expect(result.isSponsor).toBe(true);
+ expect(result.hasActiveSubscription).toBe(false);
+ });
+});
+
+describe("sponsor.createCheckoutSession", () => {
+ it("creates a new Stripe customer when the owner has none yet", async () => {
+ const { caller, mockPrisma } = await createSponsorCaller();
+ mockPrisma.owner.upsert.mockResolvedValue({ stripeCustomerId: null });
+ mockPrisma.owner.update.mockResolvedValue({});
+ mockCustomersCreate.mockResolvedValue({ id: "cus_new" });
+ mockCheckoutSessionsCreate.mockResolvedValue({ url: "https://checkout.stripe.com/session_1" });
+
+ const result = await caller.sponsor.createCheckoutSession({ type: "ONE_TIME", amountCents: 500 });
+
+ expect(mockCustomersCreate).toHaveBeenCalledWith({ metadata: { ownerId: "owner-1" } });
+ expect(mockPrisma.owner.update).toHaveBeenCalledWith({
+ where: { id: "owner-1" },
+ data: { stripeCustomerId: "cus_new" },
+ });
+ expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: "cus_new", mode: "payment" })
+ );
+ expect(result).toEqual({ url: "https://checkout.stripe.com/session_1" });
+ });
+
+ it("reuses an existing Stripe customer without creating a new one", async () => {
+ const { caller, mockPrisma } = await createSponsorCaller();
+ mockPrisma.owner.upsert.mockResolvedValue({ stripeCustomerId: "cus_existing" });
+ mockCheckoutSessionsCreate.mockResolvedValue({ url: "https://checkout.stripe.com/session_2" });
+
+ await caller.sponsor.createCheckoutSession({ type: "SUBSCRIPTION", amountCents: 1000 });
+
+ expect(mockCustomersCreate).not.toHaveBeenCalled();
+ expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: "cus_existing", mode: "subscription" })
+ );
+ });
+
+ it("rejects amounts below the 2€ minimum", async () => {
+ const { caller } = await createSponsorCaller();
+ await expect(
+ caller.sponsor.createCheckoutSession({ type: "ONE_TIME", amountCents: 150 })
+ ).rejects.toThrow();
+ expect(mockCheckoutSessionsCreate).not.toHaveBeenCalled();
+ });
+});
+
+describe("sponsor.cancelSubscription", () => {
+ it("cancels at period end and returns the active-until date", async () => {
+ const { caller, mockPrisma } = await createSponsorCaller();
+ mockPrisma.owner.findUnique.mockResolvedValue({ sponsorSubscriptionId: "sub_1" });
+ const periodEndUnix = Math.floor(new Date("2026-09-01").getTime() / 1000);
+ mockSubscriptionsUpdate.mockResolvedValue({
+ items: { data: [{ current_period_end: periodEndUnix }] },
+ });
+
+ const result = await caller.sponsor.cancelSubscription();
+
+ expect(mockSubscriptionsUpdate).toHaveBeenCalledWith("sub_1", { cancel_at_period_end: true });
+ expect(result.success).toBe(true);
+ expect(result.activeUntil).toEqual(new Date(periodEndUnix * 1000));
+ });
+
+ it("throws when the owner has no active subscription", async () => {
+ const { caller, mockPrisma } = await createSponsorCaller();
+ mockPrisma.owner.findUnique.mockResolvedValue({ sponsorSubscriptionId: null });
+
+ await expect(caller.sponsor.cancelSubscription()).rejects.toThrow(TRPCError);
+ expect(mockSubscriptionsUpdate).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx
index 14344af..5b9b0de 100644
--- a/src/app/(app)/pets/[petId]/page.tsx
+++ b/src/app/(app)/pets/[petId]/page.tsx
@@ -6,8 +6,9 @@ import { createTRPCCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
-import { BadgeCheck } from "lucide-react";
+import { BadgeCheck, Sparkles } from "lucide-react";
import { getAvatarUrl } from "@/lib/avatar-url";
+import { isActiveSponsor } from "@/lib/sponsor";
import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions";
import { ProfileTabs } from "@/components/profile/ProfileTabs";
import { PetAvatarZoom } from "@/components/pet/PetAvatarZoom";
@@ -77,6 +78,12 @@ export default async function PetProfilePage({ params }: PageProps) {
aria-label={t("verified")}
/>
)}
+ {isActiveSponsor({
+ sponsorSince: pet.owner.sponsorSince,
+ sponsorPeriodEnd: pet.owner.sponsorPeriodEnd,
+ }) && (
+
+ )}
{pet.species.name}
diff --git a/src/app/(app)/unterstuetzen/page.tsx b/src/app/(app)/unterstuetzen/page.tsx
new file mode 100644
index 0000000..b26e427
--- /dev/null
+++ b/src/app/(app)/unterstuetzen/page.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { Heart, Loader2 } from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+
+const AMOUNT_CHIPS = [3, 5, 10] as const;
+const MIN_AMOUNT_EUR = 2;
+
+type ContributionType = "ONE_TIME" | "SUBSCRIPTION";
+
+export default function SponsorPage() {
+ const t = useTranslations("Sponsor");
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const searchParams = useSearchParams();
+
+ const [type, setType] = useState("ONE_TIME");
+ const [amount, setAmount] = useState(String(AMOUNT_CHIPS[1]));
+ const [redirectUrl, setRedirectUrl] = useState(null);
+
+ const { data: status, isLoading } = useQuery(trpc.sponsor.getStatus.queryOptions());
+
+ const checkout = useMutation(
+ trpc.sponsor.createCheckoutSession.mutationOptions({
+ onSuccess: ({ url }) => setRedirectUrl(url),
+ onError: () => toast.error(t("checkoutError")),
+ })
+ );
+
+ useEffect(() => {
+ if (redirectUrl) window.location.href = redirectUrl;
+ }, [redirectUrl]);
+
+ const cancel = useMutation(
+ trpc.sponsor.cancelSubscription.mutationOptions({
+ onSuccess: ({ activeUntil }) => {
+ toast.success(t("canceledUntil", { date: new Date(activeUntil).toLocaleDateString() }));
+ void qc.invalidateQueries({ queryKey: trpc.sponsor.getStatus.queryKey() });
+ },
+ onError: () => toast.error(t("cancelError")),
+ })
+ );
+
+ useEffect(() => {
+ if (searchParams.get("success")) toast.success(t("paymentSuccess"));
+ if (searchParams.get("canceled")) toast.info(t("paymentCanceled"));
+ }, [searchParams, t]);
+
+ const amountNumber = Number(amount.replace(",", "."));
+ const isValidAmount = Number.isFinite(amountNumber) && amountNumber >= MIN_AMOUNT_EUR;
+
+ function handleSubmit() {
+ if (!isValidAmount) return;
+ checkout.mutate({ type, amountCents: Math.round(amountNumber * 100) });
+ }
+
+ return (
+
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+ {!isLoading && status?.isSponsor && (
+
+
{t("alreadySponsorTitle")}
+
+ {status.hasActiveSubscription
+ ? t("activeSubscriptionSince", {
+ date: status.sponsorSince ? new Date(status.sponsorSince).toLocaleDateString() : "",
+ })
+ : t("oneTimeSince", {
+ date: status.sponsorSince ? new Date(status.sponsorSince).toLocaleDateString() : "",
+ })}
+
+ {status.hasActiveSubscription && (
+
cancel.mutate()}
+ >
+ {cancel.isPending ? : t("cancelSubscription")}
+
+ )}
+
+ )}
+
+
+
+ {(["ONE_TIME", "SUBSCRIPTION"] as const).map((option) => (
+ setType(option)}
+ className={cn(
+ "flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
+ type === option
+ ? "border-orange-500 bg-orange-500/10 text-orange-600 dark:text-orange-400"
+ : "border-border text-muted-foreground hover:text-foreground"
+ )}
+ >
+ {option === "ONE_TIME" ? t("oneTime") : t("monthly")}
+
+ ))}
+
+
+
+ {AMOUNT_CHIPS.map((chip) => (
+ setAmount(String(chip))}
+ className={cn(
+ "flex-1 rounded-lg border px-3 py-2 text-sm font-semibold transition-colors",
+ amount === String(chip)
+ ? "border-orange-500 bg-orange-500/10 text-orange-600 dark:text-orange-400"
+ : "border-border text-muted-foreground hover:text-foreground"
+ )}
+ >
+ {chip} €
+
+ ))}
+
+
+
+ {t("customAmountLabel")}
+
+ setAmount(e.target.value)}
+ className="mb-1"
+ />
+ {!isValidAmount && (
+
{t("minAmountHint", { min: MIN_AMOUNT_EUR })}
+ )}
+
+
+ {checkout.isPending ? (
+
+ ) : type === "ONE_TIME" ? (
+ t("submitOneTime")
+ ) : (
+ t("submitSubscription")
+ )}
+
+
+
{t("noPerksHint")}
+
+
+ {!isLoading && status && status.contributions.length > 0 && (
+
+
{t("historyTitle")}
+
+ {status.contributions.map((c) => (
+
+
+ {new Date(c.createdAt).toLocaleDateString()}
+
+
+ {(c.amountCents / 100).toFixed(2)} €
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts
new file mode 100644
index 0000000..2faaa76
--- /dev/null
+++ b/src/app/api/webhooks/stripe/route.ts
@@ -0,0 +1,116 @@
+import { NextRequest, NextResponse } from "next/server";
+import type Stripe from "stripe";
+import { prisma } from "@/lib/prisma";
+import { stripe, STRIPE_WEBHOOK_SECRET } from "@/lib/stripe";
+
+/**
+ * POST /api/webhooks/stripe
+ *
+ * Handles Stripe webhook events for the Sponsoring feature (Gitea #22):
+ * - checkout.session.completed → one-time: record contribution + set sponsorSince.
+ * subscription: link stripeSubscriptionId (period/status come from invoice.paid).
+ * - invoice.paid → record contribution + extend sponsorPeriodEnd (first + every renewal).
+ * - customer.subscription.updated/deleted → sync sponsorSubscriptionStatus.
+ *
+ * Signature verification uses stripe.webhooks.constructEvent() — returns 400 on mismatch.
+ * Configure in Stripe dashboard: Developers → Webhooks → Add endpoint →
+ * https://yourdomain/api/webhooks/stripe. Subscribe to the four events above.
+ */
+export async function POST(req: NextRequest) {
+ const body = await req.text();
+ const signature = req.headers.get("stripe-signature") ?? "";
+
+ if (!STRIPE_WEBHOOK_SECRET) {
+ if (process.env.NODE_ENV === "production") {
+ return NextResponse.json({ error: "Webhook not configured" }, { status: 500 });
+ }
+ }
+
+ let event: Stripe.Event;
+ try {
+ event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
+ } catch {
+ return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
+ }
+
+ switch (event.type) {
+ case "checkout.session.completed": {
+ const session = event.data.object as Stripe.Checkout.Session;
+ const ownerId = session.metadata?.ownerId;
+ if (!ownerId) break;
+
+ if (session.mode === "payment") {
+ await prisma.$transaction([
+ prisma.sponsorContribution.create({
+ data: {
+ ownerId,
+ type: "ONE_TIME",
+ amountCents: session.amount_total ?? 0,
+ currency: session.currency ?? "eur",
+ stripeCheckoutSessionId: session.id,
+ },
+ }),
+ prisma.owner.updateMany({
+ where: { id: ownerId, sponsorSince: null },
+ data: { sponsorSince: new Date() },
+ }),
+ ]);
+ } else if (session.mode === "subscription" && typeof session.subscription === "string") {
+ await prisma.owner.update({
+ where: { id: ownerId },
+ data: { sponsorSubscriptionId: session.subscription },
+ });
+ }
+ break;
+ }
+
+ case "invoice.paid": {
+ const invoice = event.data.object as Stripe.Invoice;
+ const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
+ if (!customerId) break;
+
+ const owner = await prisma.owner.findUnique({ where: { stripeCustomerId: customerId } });
+ if (!owner) break;
+
+ const periodEndUnix = invoice.lines.data[0]?.period?.end;
+ const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : null;
+
+ await prisma.$transaction([
+ prisma.sponsorContribution.create({
+ data: {
+ ownerId: owner.id,
+ type: "SUBSCRIPTION",
+ amountCents: invoice.amount_paid,
+ currency: invoice.currency,
+ stripeInvoiceId: invoice.id,
+ },
+ }),
+ prisma.owner.update({
+ where: { id: owner.id },
+ data: {
+ sponsorSince: owner.sponsorSince ?? new Date(),
+ sponsorPeriodEnd: periodEnd,
+ sponsorSubscriptionStatus: "active",
+ },
+ }),
+ ]);
+ break;
+ }
+
+ case "customer.subscription.updated":
+ case "customer.subscription.deleted": {
+ const subscription = event.data.object as Stripe.Subscription;
+ await prisma.owner.updateMany({
+ where: { sponsorSubscriptionId: subscription.id },
+ data: { sponsorSubscriptionStatus: subscription.status },
+ });
+ break;
+ }
+
+ default:
+ // Unhandled event type — acknowledge without error
+ break;
+ }
+
+ return NextResponse.json({ received: true });
+}
diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx
index 2686760..5cce823 100644
--- a/src/app/datenschutz/page.tsx
+++ b/src/app/datenschutz/page.tsx
@@ -83,6 +83,7 @@ export default function DatenschutzPage() {
{ name: "Supabase Inc.", detail: "Datenbankhosting (AWS EU-West) · AVV abgeschlossen" },
{ name: "Cloudflare Inc.", detail: "Medienspeicherung und CDN (USA/EU) · AVV abgeschlossen" },
{ name: "Mux Inc.", detail: "Videoverarbeitung (USA) · SCC-Basis" },
+ { name: "Stripe Inc.", detail: "Zahlungsabwicklung für freiwillige Sponsoring-Beiträge (USA/EU) · SCC-Basis" },
{ name: "GlitchTip", detail: "Fehler- und Absturzberichte (self-hosted, eigene Server-Infrastruktur in der EU) · keine Drittlandübermittlung" },
].map((p) => (
diff --git a/src/app/nutzungsbedingungen/page.tsx b/src/app/nutzungsbedingungen/page.tsx
index 33b6412..5d14663 100644
--- a/src/app/nutzungsbedingungen/page.tsx
+++ b/src/app/nutzungsbedingungen/page.tsx
@@ -186,6 +186,36 @@ export default function NutzungsbedingungenPage() {
+
+
+ Du kannst PawFeed freiwillig mit einem Einmalbetrag oder einem
+ monatlich kündbaren Beitrag (Community-Abo) ab 2€ unterstützen. Die
+ Zahlungsabwicklung erfolgt über unseren Zahlungsdienstleister
+ Stripe (siehe{" "}
+
+ Datenschutzerklärung
+
+ ).
+
+
+ Als Gegenleistung erhältst du ein "Sponsor"-Badge, das auf deinem
+ Profil und bei jedem Beitrag all deiner Haustiere sichtbar ist —
+ es schaltet keine zusätzlichen Funktionen frei. Da PawFeed ein
+ gewerbliches Angebot und keine gemeinnützige Organisation ist,
+ handelt es sich bei deinem Beitrag um eine freiwillige Zuwendung an
+ ein Gewerbe, nicht um eine steuerlich absetzbare Spende.
+
+
+ Ein Community-Abo kannst du jederzeit zum Ende der laufenden
+ Abrechnungsperiode kündigen; dein Sponsor-Badge bleibt bis zu
+ diesem Zeitpunkt bestehen. Mit Bestätigung deiner Zahlung
+ stimmst du der sofortigen Erbringung der Leistung (Freischaltung
+ des Badges) zu und nimmst zur Kenntnis, dass dein gesetzliches
+ Widerrufsrecht damit erlischt, sobald die Leistung vollständig
+ erbracht wurde.
+
+
+
Weitere rechtliche Informationen
diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx
index fba22a8..9464dde 100644
--- a/src/components/feed/PostCard.tsx
+++ b/src/components/feed/PostCard.tsx
@@ -1,11 +1,12 @@
"use client";
import { useState, useEffect, useCallback, memo } from "react";
-import { MoreHorizontal, MessageCircle } from "lucide-react";
+import { MoreHorizontal, MessageCircle, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
+import { isActiveSponsor } from "@/lib/sponsor";
import { PetAvatar } from "@/components/ui/pet-avatar";
import { BlurImage } from "@/components/ui/blur-image";
import {
@@ -78,6 +79,7 @@ type PostPet = {
id: string;
name: string;
avatarKey: string | null;
+ owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
};
type PostMilestone = { milestoneType: import("@prisma/client").MilestoneType; notes: string | null };
@@ -242,8 +244,18 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
-
+
{pet.name}
+ {pet.owner &&
+ isActiveSponsor({
+ sponsorSince: pet.owner.sponsorSince ? new Date(pet.owner.sponsorSince) : null,
+ sponsorPeriodEnd: pet.owner.sponsorPeriodEnd ? new Date(pet.owner.sponsorPeriodEnd) : null,
+ }) && (
+
+ )}
{formatRelativeTime(createdAt)}
diff --git a/src/lib/sponsor.ts b/src/lib/sponsor.ts
new file mode 100644
index 0000000..c53cf10
--- /dev/null
+++ b/src/lib/sponsor.ts
@@ -0,0 +1,14 @@
+/**
+ * Gitea #22 — whether an owner's Sponsor badge should currently be shown.
+ * One-time givers stay a sponsor permanently (sponsorPeriodEnd stays null).
+ * Subscribers stay a sponsor through the end of their current paid period,
+ * even after cancelling — the badge does not vanish mid-period.
+ */
+export function isActiveSponsor(owner: {
+ sponsorSince: Date | null;
+ sponsorPeriodEnd: Date | null;
+}): boolean {
+ if (!owner.sponsorSince) return false;
+ if (!owner.sponsorPeriodEnd) return true;
+ return owner.sponsorPeriodEnd.getTime() > Date.now();
+}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
new file mode 100644
index 0000000..7f684df
--- /dev/null
+++ b/src/lib/stripe.ts
@@ -0,0 +1,8 @@
+import "server-only";
+import Stripe from "stripe";
+
+// Singleton Stripe client — STRIPE_SECRET_KEY must be set in .env.local.
+// Get credentials at dashboard.stripe.com → Developers → API keys.
+export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "");
+
+export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? "";
diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts
index 01487b1..6fa6833 100644
--- a/src/trpc/routers/_app.ts
+++ b/src/trpc/routers/_app.ts
@@ -22,6 +22,7 @@ import { invitesRouter } from "@/trpc/routers/invites";
import { adsRouter } from "@/trpc/routers/ads";
import { legalRouter } from "@/trpc/routers/legal";
import { ownerRouter } from "@/trpc/routers/owner";
+import { sponsorRouter } from "@/trpc/routers/sponsor";
export const appRouter = router({
pets: petsRouter,
@@ -48,6 +49,7 @@ export const appRouter = router({
ads: adsRouter,
legal: legalRouter,
owner: ownerRouter,
+ sponsor: sponsorRouter,
});
// Export the type for use in the client
diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts
index 3b153db..335e76b 100644
--- a/src/trpc/routers/explore.ts
+++ b/src/trpc/routers/explore.ts
@@ -68,7 +68,14 @@ export const exploreRouter = router({
},
include: {
images: { orderBy: { position: "asc" } },
- pet: { select: { id: true, name: true, avatarKey: true } },
+ pet: {
+ select: {
+ id: true,
+ name: true,
+ avatarKey: true,
+ owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
+ },
+ },
milestone: true,
},
orderBy: [{ pinnedAt: { sort: "desc", nulls: "last" } }, { reactionCount: "desc" }],
diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts
index b7cb262..92989ac 100644
--- a/src/trpc/routers/feed.ts
+++ b/src/trpc/routers/feed.ts
@@ -26,7 +26,14 @@ function algoScore(
}
const postInclude = {
- pet: { select: { id: true, name: true, avatarKey: true } },
+ pet: {
+ select: {
+ id: true,
+ name: true,
+ avatarKey: true,
+ owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
+ },
+ },
images: { orderBy: { position: "asc" } as const },
milestone: true,
videoPost: { select: { status: true, muxPlaybackId: true } },
@@ -34,7 +41,14 @@ const postInclude = {
include: {
originalPost: {
include: {
- pet: { select: { id: true, name: true, avatarKey: true } },
+ pet: {
+ select: {
+ id: true,
+ name: true,
+ avatarKey: true,
+ owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
+ },
+ },
images: { orderBy: { position: "asc" } as const },
milestone: true,
},
diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts
index 7e93e80..aeb52c7 100644
--- a/src/trpc/routers/pets.ts
+++ b/src/trpc/routers/pets.ts
@@ -122,6 +122,7 @@ export const petsRouter = router({
include: {
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
+ owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
},
});
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
diff --git a/src/trpc/routers/sponsor.ts b/src/trpc/routers/sponsor.ts
new file mode 100644
index 0000000..26ae416
--- /dev/null
+++ b/src/trpc/routers/sponsor.ts
@@ -0,0 +1,114 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure } from "@/trpc/init";
+import { stripe } from "@/lib/stripe";
+import { isActiveSponsor } from "@/lib/sponsor";
+
+const MIN_AMOUNT_CENTS = 200; // 2€ minimum
+
+function resolveAppUrl(): string {
+ return process.env.NEXT_PUBLIC_APP_URL ?? "https://pawfeed.org";
+}
+
+export const sponsorRouter = router({
+ getStatus: protectedProcedure.query(async ({ ctx }) => {
+ const owner = await ctx.prisma.owner.findUnique({
+ where: { id: ctx.userId },
+ select: {
+ sponsorSince: true,
+ sponsorPeriodEnd: true,
+ sponsorSubscriptionId: true,
+ sponsorSubscriptionStatus: true,
+ contributions: {
+ orderBy: { createdAt: "desc" },
+ take: 20,
+ select: { id: true, type: true, amountCents: true, currency: true, createdAt: true },
+ },
+ },
+ });
+
+ return {
+ isSponsor: owner ? isActiveSponsor(owner) : false,
+ sponsorSince: owner?.sponsorSince ?? null,
+ sponsorPeriodEnd: owner?.sponsorPeriodEnd ?? null,
+ hasActiveSubscription: owner?.sponsorSubscriptionStatus === "active",
+ contributions: owner?.contributions ?? [],
+ };
+ }),
+
+ createCheckoutSession: protectedProcedure
+ .input(
+ z.object({
+ type: z.enum(["ONE_TIME", "SUBSCRIPTION"]),
+ amountCents: z.number().int().min(MIN_AMOUNT_CENTS),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const owner = await ctx.prisma.owner.upsert({
+ where: { id: ctx.userId },
+ create: { id: ctx.userId, registrationIp: ctx.ip },
+ update: {},
+ select: { stripeCustomerId: true },
+ });
+
+ let stripeCustomerId = owner.stripeCustomerId;
+ if (!stripeCustomerId) {
+ const customer = await stripe.customers.create({
+ metadata: { ownerId: ctx.userId },
+ });
+ stripeCustomerId = customer.id;
+ await ctx.prisma.owner.update({
+ where: { id: ctx.userId },
+ data: { stripeCustomerId },
+ });
+ }
+
+ const appUrl = resolveAppUrl();
+ const isSubscription = input.type === "SUBSCRIPTION";
+
+ const session = await stripe.checkout.sessions.create({
+ customer: stripeCustomerId,
+ mode: isSubscription ? "subscription" : "payment",
+ payment_method_types: isSubscription ? ["sepa_debit", "card"] : ["card"],
+ line_items: [
+ {
+ price_data: {
+ currency: "eur",
+ unit_amount: input.amountCents,
+ product_data: { name: "PawFeed Sponsoring" },
+ ...(isSubscription ? { recurring: { interval: "month" } } : {}),
+ },
+ quantity: 1,
+ },
+ ],
+ metadata: { ownerId: ctx.userId, type: input.type },
+ success_url: `${appUrl}/unterstuetzen?success=1`,
+ cancel_url: `${appUrl}/unterstuetzen?canceled=1`,
+ });
+
+ if (!session.url) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Stripe returned no checkout URL." });
+ }
+ return { url: session.url };
+ }),
+
+ cancelSubscription: protectedProcedure.mutation(async ({ ctx }) => {
+ const owner = await ctx.prisma.owner.findUnique({
+ where: { id: ctx.userId },
+ select: { sponsorSubscriptionId: true },
+ });
+ if (!owner?.sponsorSubscriptionId) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "No active subscription to cancel." });
+ }
+
+ const subscription = await stripe.subscriptions.update(owner.sponsorSubscriptionId, {
+ cancel_at_period_end: true,
+ });
+
+ return {
+ success: true,
+ activeUntil: new Date(subscription.items.data[0].current_period_end * 1000),
+ };
+ }),
+});