- enable Next.js standalone output and add Docker/Caddy production stack - add production env template and deployment runbook - add healthcheck endpoint for container supervision - fix existing lint/type blockers discovered during validation Co-Authored-By: Paperclip <noreply@paperclip.ing>
163 lines
4.7 KiB
TypeScript
163 lines
4.7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import Stripe from "stripe";
|
|
|
|
import {
|
|
BookingStatus,
|
|
PaymentStatus,
|
|
SubscriptionStatus,
|
|
} from "@/generated/prisma/enums";
|
|
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
|
|
switch (status) {
|
|
case "trialing":
|
|
return SubscriptionStatus.TRIAL;
|
|
case "active":
|
|
return SubscriptionStatus.ACTIVE;
|
|
case "past_due":
|
|
case "unpaid":
|
|
case "paused":
|
|
return SubscriptionStatus.PAST_DUE;
|
|
case "canceled":
|
|
case "incomplete_expired":
|
|
return SubscriptionStatus.CANCELED;
|
|
case "incomplete":
|
|
return SubscriptionStatus.TRIAL;
|
|
default:
|
|
return SubscriptionStatus.TRIAL;
|
|
}
|
|
}
|
|
|
|
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|
const bookingId = session.metadata?.bookingId;
|
|
const type = session.metadata?.type;
|
|
|
|
if (type === "booking" && bookingId) {
|
|
const booking = await prisma.booking.update({
|
|
where: { id: bookingId },
|
|
data: {
|
|
paymentStatus: PaymentStatus.SUCCEEDED,
|
|
status: BookingStatus.CONFIRMED,
|
|
},
|
|
select: {
|
|
carbetId: true,
|
|
},
|
|
});
|
|
await refreshCarbetLastBookedAt(booking.carbetId);
|
|
return;
|
|
}
|
|
|
|
if (type === "owner_subscription") {
|
|
const ownerId = session.metadata?.ownerId;
|
|
const carbetId = session.metadata?.carbetId;
|
|
const providerSubId = typeof session.subscription === "string" ? session.subscription : null;
|
|
|
|
if (!ownerId || !carbetId || !providerSubId) {
|
|
return;
|
|
}
|
|
|
|
await prisma.subscription.upsert({
|
|
where: { providerSubId },
|
|
update: {
|
|
status: SubscriptionStatus.ACTIVE,
|
|
renewedAt: new Date(),
|
|
},
|
|
create: {
|
|
ownerId,
|
|
carbetId,
|
|
provider: "stripe",
|
|
providerSubId,
|
|
status: SubscriptionStatus.ACTIVE,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
|
|
const bookingId = paymentIntent.metadata?.bookingId;
|
|
if (!bookingId) {
|
|
return;
|
|
}
|
|
|
|
const booking = await prisma.booking.update({
|
|
where: { id: bookingId },
|
|
data: {
|
|
paymentStatus: PaymentStatus.FAILED,
|
|
status: BookingStatus.CANCELLED,
|
|
},
|
|
select: {
|
|
carbetId: true,
|
|
},
|
|
});
|
|
await refreshCarbetLastBookedAt(booking.carbetId);
|
|
}
|
|
|
|
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
|
const currentPeriodEnd =
|
|
subscription.items.data[0]?.current_period_end ??
|
|
subscription.trial_end ??
|
|
subscription.canceled_at;
|
|
|
|
await prisma.subscription.upsert({
|
|
where: { providerSubId: subscription.id },
|
|
update: {
|
|
status: mapStripeSubscriptionStatus(subscription.status),
|
|
renewedAt: fromStripeTimestamp(currentPeriodEnd),
|
|
canceledAt: fromStripeTimestamp(subscription.canceled_at),
|
|
},
|
|
create: {
|
|
ownerId: subscription.metadata.ownerId,
|
|
carbetId: subscription.metadata.carbetId,
|
|
provider: "stripe",
|
|
providerSubId: subscription.id,
|
|
status: mapStripeSubscriptionStatus(subscription.status),
|
|
startedAt: fromStripeTimestamp(subscription.start_date) ?? new Date(),
|
|
renewedAt: fromStripeTimestamp(currentPeriodEnd),
|
|
canceledAt: fromStripeTimestamp(subscription.canceled_at),
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
if (!secret) {
|
|
return NextResponse.json({ error: "STRIPE_WEBHOOK_SECRET manquante." }, { status: 500 });
|
|
}
|
|
|
|
const stripe = getStripeClient();
|
|
const signature = request.headers.get("stripe-signature");
|
|
if (!signature) {
|
|
return NextResponse.json({ error: "Signature Stripe absente." }, { status: 400 });
|
|
}
|
|
|
|
const payload = await request.text();
|
|
|
|
let event: Stripe.Event;
|
|
try {
|
|
event = stripe.webhooks.constructEvent(payload, signature, secret);
|
|
} catch {
|
|
return NextResponse.json({ error: "Signature Stripe invalide." }, { status: 400 });
|
|
}
|
|
|
|
switch (event.type) {
|
|
case "checkout.session.completed":
|
|
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
|
break;
|
|
case "payment_intent.payment_failed":
|
|
await handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
|
|
break;
|
|
case "customer.subscription.created":
|
|
case "customer.subscription.updated":
|
|
case "customer.subscription.deleted":
|
|
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return NextResponse.json({ received: true });
|
|
}
|