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 ( +
+
+
+ + {!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 && ( + + )} +
+ )} + +
+
+ {(["ONE_TIME", "SUBSCRIPTION"] as const).map((option) => ( + + ))} +
+ +
+ {AMOUNT_CHIPS.map((chip) => ( + + ))} +
+ + + setAmount(e.target.value)} + className="mb-1" + /> + {!isValidAmount && ( +

{t("minAmountHint", { min: MIN_AMOUNT_EUR })}

+ )} + + + +

{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), + }; + }), +});