feat(sponsor): Stripe-based voluntary sponsoring with Sponsor badge (Gitea #22)

Replaces the (never-launched) ad business direction with a freiwilliger
Beitrag/Community-Abo model: owners can support PawFeed via Stripe with
a one-time payment or a monthly SEPA/card subscription (2 EUR minimum),
declared as Sponsoring/Community-Abo, not a tax-deductible donation. The
Sponsor badge (only perk, no feature unlocks) is Owner-scoped and shows
on every post of every one of their pets, plus their profile.

- Schema: Owner.stripeCustomerId/sponsorSince/sponsorPeriodEnd/
  sponsorSubscriptionId/sponsorSubscriptionStatus, new
  SponsorContribution model (payment history)
- src/lib/stripe.ts (singleton client, mux.ts pattern) + src/lib/sponsor.ts
  (isActiveSponsor: one-time givers keep the badge permanently, subscribers
  through sponsorPeriodEnd even after cancelling -- no mid-period cliff)
- src/trpc/routers/sponsor.ts: getStatus, createCheckoutSession (SEPA +
  card for subscriptions, card for one-time, price_data since amounts are
  user-chosen), cancelSubscription (cancel_at_period_end, no hard stop)
- src/app/api/webhooks/stripe: checkout.session.completed, invoice.paid,
  customer.subscription.updated/deleted (mux webhook route pattern)
- src/app/(app)/unterstuetzen: amount chips (3/5/10 EUR) prefilling an
  editable field, one-time/subscription toggle, status + contribution
  history, in-app cancel button (no Stripe-hosted portal, per requirement)
- Badge surfaced in feed/explore postInclude + PostCard, and on the pet
  profile page next to the existing isVerified badge
- Nutzungsbedingungen Sec. 12 (draft, needs sign-off before launch) +
  Stripe added to the Datenschutzerklaerung's Auftragsverarbeiter list
- src/__tests__/sponsor.test.ts (7 tests), Stripe client fully mocked

STRIPE_WEBHOOK_SECRET intentionally not yet configured -- the webhook
endpoint can only be registered in the Stripe dashboard once this is
deployed and reachable at https://pawfeed.org/api/webhooks/stripe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-08-14 10:24:08 +02:00
1 parent f5f788b38c
commit c6ffc1336e
21 files changed
+786 -6

No files matched your search

+9
View File
@@ -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://<your-domain>/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"
+21
View File
@@ -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",
+21
View File
@@ -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",
+18
View File
@@ -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",
+1
View File
@@ -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"
+26
View File
@@ -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)])
}
+10
View File
@@ -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(),
+157
View File
@@ -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<typeof createMockPrisma>;
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();
});
});
+8 -1
View File
@@ -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,
}) && (
<Sparkles className="h-4 w-4 fill-amber-500 text-amber-500" aria-label="Sponsor" />
)}
</h1>
<Badge variant="secondary">{pet.species.name}</Badge>
+191
View File
@@ -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<ContributionType>("ONE_TIME");
const [amount, setAmount] = useState<string>(String(AMOUNT_CHIPS[1]));
const [redirectUrl, setRedirectUrl] = useState<string | null>(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 (
<div className="mx-auto max-w-xl px-4 py-8 sm:py-12">
<div className="mb-8 text-center">
<Heart className="mx-auto h-8 w-8 text-orange-500" fill="currentColor" aria-hidden="true" />
<h1 className="mt-3 text-2xl font-bold text-foreground">{t("title")}</h1>
<p className="mt-2 text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
{!isLoading && status?.isSponsor && (
<div className="mb-8 rounded-2xl border border-orange-500/30 bg-orange-500/5 p-5">
<p className="text-sm font-semibold text-foreground">{t("alreadySponsorTitle")}</p>
<p className="mt-1 text-sm text-muted-foreground">
{status.hasActiveSubscription
? t("activeSubscriptionSince", {
date: status.sponsorSince ? new Date(status.sponsorSince).toLocaleDateString() : "",
})
: t("oneTimeSince", {
date: status.sponsorSince ? new Date(status.sponsorSince).toLocaleDateString() : "",
})}
</p>
{status.hasActiveSubscription && (
<Button
variant="outline"
size="sm"
className="mt-3"
disabled={cancel.isPending}
onClick={() => cancel.mutate()}
>
{cancel.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("cancelSubscription")}
</Button>
)}
</div>
)}
<div className="rounded-2xl border border-border bg-card p-6">
<div className="mb-5 flex gap-2">
{(["ONE_TIME", "SUBSCRIPTION"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={type === option}
onClick={() => 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")}
</button>
))}
</div>
<div className="mb-4 flex gap-2">
{AMOUNT_CHIPS.map((chip) => (
<button
key={chip}
type="button"
onClick={() => 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}&nbsp;€
</button>
))}
</div>
<label className="mb-1 block text-xs font-medium text-muted-foreground" htmlFor="sponsor-amount">
{t("customAmountLabel")}
</label>
<Input
id="sponsor-amount"
type="text"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="mb-1"
/>
{!isValidAmount && (
<p className="mb-3 text-xs text-destructive">{t("minAmountHint", { min: MIN_AMOUNT_EUR })}</p>
)}
<Button
className="mt-3 w-full bg-orange-500 text-white hover:bg-orange-600"
disabled={!isValidAmount || checkout.isPending}
onClick={handleSubmit}
>
{checkout.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : type === "ONE_TIME" ? (
t("submitOneTime")
) : (
t("submitSubscription")
)}
</Button>
<p className="mt-4 text-center text-xs text-muted-foreground">{t("noPerksHint")}</p>
</div>
{!isLoading && status && status.contributions.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-foreground">{t("historyTitle")}</h2>
<ul className="space-y-2">
{status.contributions.map((c) => (
<li
key={c.id}
className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-2.5 text-sm"
>
<span className="text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString()}
</span>
<span className="font-medium text-foreground">
{(c.amountCents / 100).toFixed(2)}&nbsp;€
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
+116
View File
@@ -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 });
}
+1
View File
@@ -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) => (
<li key={p.name} className="flex flex-col gap-0.5">
+30
View File
@@ -186,6 +186,36 @@ export default function NutzungsbedingungenPage() {
</p>
</LegalSection>
<LegalSection number="12" title="Sponsoring">
<p>
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{" "}
<Link href="/datenschutz" className="text-orange-600 dark:text-orange-400 hover:underline">
Datenschutzerklärung
</Link>
).
</p>
<p className="mt-2">
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.
</p>
<p className="mt-2">
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.
</p>
</LegalSection>
<div className="bg-card border rounded-xl p-6 flex items-center justify-between">
<p className="text-sm text-muted-foreground">Weitere rechtliche Informationen</p>
<div className="flex gap-4">
+14 -2
View File
@@ -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) {
</PawRing>
<div className="flex-1 min-w-0">
<p className="text-base font-semibold text-foreground leading-tight truncate">
<p className="flex items-center gap-1 text-base font-semibold text-foreground leading-tight truncate">
{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,
}) && (
<Sparkles
className="h-3.5 w-3.5 shrink-0 fill-amber-500 text-amber-500"
aria-label="Sponsor"
/>
)}
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(createdAt)}
+14
View File
@@ -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();
}
+8
View File
@@ -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 ?? "";
+2
View File
@@ -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
+8 -1
View File
@@ -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" }],
+16 -2
View File
@@ -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,
},
+1
View File
@@ -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" });
+114
View File
@@ -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),
};
}),
});