Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { getEffectivePrice } from '@/lib/pricing';
|
||||
import { fetchActivePromotions } from '@/lib/promotions';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
type CheckoutCartItem = {
|
||||
productId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string;
|
||||
size: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
designJson: { front: unknown[]; back: unknown[] };
|
||||
designPreviewUrl: string;
|
||||
designPreviewUrlBack: string | null;
|
||||
placementPreviewUrl: string | null;
|
||||
placementPreviewUrlBack: string | null;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe is not configured on this server yet. Add STRIPE_SECRET_KEY to .env.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = getClientIp(req.headers);
|
||||
const { allowed } = await checkRateLimit(`checkout:${ip}`, 20, 10 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests — please wait a moment and try again.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const items: CheckoutCartItem[] = body.items ?? [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Never trust the client-submitted unitPrice for the actual charge — look up
|
||||
// each product and recompute its current price (including any active sale)
|
||||
// server-side. The client's unitPrice is only used for display before this point.
|
||||
const products = await prisma.product.findMany({
|
||||
where: { id: { in: items.map((i) => i.productId) } },
|
||||
});
|
||||
const productsById = new Map(products.map((p) => [p.id, p]));
|
||||
|
||||
for (const i of items) {
|
||||
if (!productsById.has(i.productId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One of the items in your bag is no longer available.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
const pricedItems = items.map((i) => ({
|
||||
...i,
|
||||
unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price,
|
||||
}));
|
||||
|
||||
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
// Save the order up front, before redirecting to Stripe — this is what makes
|
||||
// the design files (and the order itself) retrievable afterward, whether or
|
||||
// not the customer ever makes it back to the success page.
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
total,
|
||||
customerId: customer?.id,
|
||||
items: {
|
||||
create: pricedItems.map((i) => ({
|
||||
productId: i.productId,
|
||||
productName: i.name,
|
||||
quantity: i.quantity,
|
||||
color: i.color,
|
||||
size: i.size,
|
||||
unitPrice: i.unitPrice,
|
||||
designJson: JSON.stringify(i.designJson),
|
||||
designPreviewUrl: i.designPreviewUrl,
|
||||
designPreviewUrlBack: i.designPreviewUrlBack,
|
||||
placementPreviewUrl: i.placementPreviewUrl,
|
||||
placementPreviewUrlBack: i.placementPreviewUrlBack,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: pricedItems.map((i) => ({
|
||||
price_data: {
|
||||
currency: 'gbp',
|
||||
unit_amount: i.unitPrice,
|
||||
product_data: {
|
||||
name: `${i.name} — ${i.color}${i.size ? `, ${i.size}` : ''}`,
|
||||
},
|
||||
},
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
shipping_address_collection: {
|
||||
allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'],
|
||||
},
|
||||
customer_email: customer?.email,
|
||||
success_url: `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${siteUrl}/checkout/cancel`,
|
||||
metadata: { orderId: order.id },
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { stripeSessionId: session.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (err) {
|
||||
// Stripe call failed — don't leave an orphaned PENDING order with no way to pay it.
|
||||
await prisma.order.delete({ where: { id: order.id } });
|
||||
const message = err instanceof Error ? err.message : 'Could not start checkout.';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(_req: Request, { params }: { params: { id: string } }) {
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.designProof.update({
|
||||
where: { id: params.id },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { MessageDTO } from '@/lib/types';
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
const rows = await prisma.message.findMany({
|
||||
where: { proofId: params.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const messages: MessageDTO[] = rows.map((m) => ({
|
||||
id: m.id,
|
||||
proofId: m.proofId,
|
||||
sender: m.sender as MessageDTO['sender'],
|
||||
body: m.body,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json(messages);
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
const { sender, body } = await req.json();
|
||||
|
||||
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
|
||||
return NextResponse.json({ error: 'Invalid sender' }, { status: 400 });
|
||||
}
|
||||
const trimmed = String(body ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
|
||||
if (!proof) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const created = await prisma.message.create({
|
||||
data: { proofId: params.id, sender, body: trimmed },
|
||||
});
|
||||
|
||||
const message: MessageDTO = {
|
||||
id: created.id,
|
||||
proofId: created.proofId,
|
||||
sender: created.sender as MessageDTO['sender'],
|
||||
body: created.body,
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(message);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { upsertMailingListEntry } from '@/lib/mailingList';
|
||||
import { applyStockMovement } from '@/lib/stock';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const signature = req.headers.get('stripe-signature');
|
||||
const rawBody = await req.text();
|
||||
|
||||
if (!process.env.STRIPE_WEBHOOK_SECRET || !signature) {
|
||||
return NextResponse.json({ error: 'Webhook not configured.' }, { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Invalid signature';
|
||||
return NextResponse.json({ error: `Webhook signature verification failed: ${message}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const orderId = session.metadata?.orderId;
|
||||
|
||||
if (orderId) {
|
||||
const shipping = (session as unknown as { shipping_details?: Stripe.Checkout.Session.ShippingDetails })
|
||||
.shipping_details;
|
||||
|
||||
// Stripe retries webhooks, so the same completed event can arrive more
|
||||
// than once — remember whether this is the first PENDING→PAID flip and
|
||||
// only move stock on that one.
|
||||
const before = await prisma.order.findUnique({ where: { id: orderId } });
|
||||
const firstTimePaid = before != null && before.status !== 'PAID';
|
||||
|
||||
const updated = await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
email: session.customer_details?.email ?? undefined,
|
||||
shippingAddress: shipping ? JSON.stringify(shipping) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstTimePaid) {
|
||||
const items = await prisma.orderItem.findMany({ where: { orderId } });
|
||||
await applyStockMovement(items, -1);
|
||||
}
|
||||
|
||||
if (!updated.customerId && updated.email) {
|
||||
await upsertMailingListEntry({ email: updated.email, source: 'GUEST' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.expired' || event.type === 'checkout.session.async_payment_failed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (orderId) {
|
||||
await prisma.order.update({ where: { id: orderId }, data: { status: 'FAILED' } });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
Reference in New Issue
Block a user