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:
1 parent
f5f788b38c
commit
c6ffc1336e
21 files changed
+786
-6
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} €
|
||||
</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)} €
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
@@ -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
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user