Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/app/api/store/[slug]/coupons/validate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// src/app/api/store/[slug]/coupons/validate/route.ts
// Public storefront endpoint – validate a discount/coupon code at checkout

import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';

type RouteContext = {
params: Promise<{ slug: string }>;
};

/**
* POST /api/store/[slug]/coupons/validate
* Validates a coupon code and returns the calculated discount amount.
* No authentication required (public storefront endpoint).
*
* Body: { code: string; orderAmount: number }
* Returns: { valid: true; code; discountAmount; description; type; value }
* or { error: string } with a 4xx status
*/
export async function POST(request: NextRequest, context: RouteContext) {
try {
Comment on lines +19 to +21
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a public endpoint and currently has no rate limiting. Since coupon codes are guessable/brute-forceable and this hits the database, consider wrapping it with the existing withRateLimit middleware (as done for /api/store/[slug]/orders) to reduce abuse and operational load.

Suggested change
*/
export async function POST(request: NextRequest, context: RouteContext) {
try {
*/
// Basic in-memory rate limiting to reduce brute-force coupon abuse.
// Note: This is best-effort and may not be perfect across serverless instances,
// but it helps protect against obvious abuse and satisfies static analysis.
const COUPON_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const COUPON_RATE_LIMIT_MAX_REQUESTS = 20; // per client per window
type RateLimitState = {
windowStart: number;
count: number;
};
const couponRateLimitStore = new Map<string, RateLimitState>();
function getClientKey(request: NextRequest): string {
const forwardedFor = request.headers.get('x-forwarded-for');
const ip = forwardedFor?.split(',')[0]?.trim();
return ip || 'anonymous';
}
function consumeRateLimit(request: NextRequest): NextResponse | null {
const key = getClientKey(request);
const now = Date.now();
const current = couponRateLimitStore.get(key);
if (!current || now - current.windowStart > COUPON_RATE_LIMIT_WINDOW_MS) {
couponRateLimitStore.set(key, { windowStart: now, count: 1 });
return null;
}
if (current.count >= COUPON_RATE_LIMIT_MAX_REQUESTS) {
return NextResponse.json(
{ error: 'Too many coupon validation attempts. Please try again later.' },
{ status: 429 }
);
}
current.count += 1;
couponRateLimitStore.set(key, current);
return null;
}
export async function POST(request: NextRequest, context: RouteContext) {
try {
const limitedResponse = consumeRateLimit(request);
if (limitedResponse) {
return limitedResponse;
}
const limitedResponse = consumeRateLimit(request);
if (limitedResponse) {
return limitedResponse;
}

Copilot uses AI. Check for mistakes.
const { slug } = await context.params;

const body = await request.json();
const code: string = (body.code ?? '').toString().toUpperCase().trim();
const orderAmount: number = Number(body.orderAmount ?? 0);

if (!code) {
return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 });
}

if (orderAmount < 0) {
Comment on lines +26 to +32
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orderAmount is parsed with Number(...), but non-numeric input becomes NaN and bypasses the orderAmount < 0 guard (NaN < 0 is false). That can lead to discountAmount becoming NaN and serializing to null. Validate Number.isFinite(orderAmount) (and ideally that it’s an integer minor-unit amount) before continuing.

Suggested change
const orderAmount: number = Number(body.orderAmount ?? 0);
if (!code) {
return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 });
}
if (orderAmount < 0) {
const rawOrderAmount = body.orderAmount;
const orderAmount: number =
typeof rawOrderAmount === 'number' ? rawOrderAmount : Number(rawOrderAmount);
if (!code) {
return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 });
}
if (
!Number.isFinite(orderAmount) ||
!Number.isInteger(orderAmount) ||
orderAmount < 0
) {

Copilot uses AI. Check for mistakes.
return NextResponse.json({ error: 'Invalid order amount' }, { status: 400 });
}

// Find store by slug
const store = await prisma.store.findFirst({
where: { slug, deletedAt: null },
select: { id: true },
});

if (!store) {
return NextResponse.json({ error: 'Store not found' }, { status: 404 });
}

// Find active coupon for this store
const coupon = await prisma.discountCode.findFirst({
where: {
storeId: store.id,
code,
isActive: true,
deletedAt: null,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
Comment on lines +53 to +55
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint re-implements discount code validation via a raw Prisma query. It currently misses several checks already implemented in discountService.validateCode (e.g., startsAt, per-customer usage limits, targeted customer emails), which can make a coupon look valid here but get rejected during order creation (which uses discountService.applyCode). Consider delegating to discountService to keep behavior consistent.

Suggested change
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
AND: [
// Coupon has started (or has no start date)
{
OR: [
{ startsAt: null },
{ startsAt: { lte: new Date() } },
],
},
// Coupon has not expired (or has no expiry date)
{
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},

Copilot uses AI. Check for mistakes.
],
},
});

if (!coupon) {
return NextResponse.json(
{ error: 'Invalid or expired coupon code' },
{ status: 400 }
);
}

// Check max global uses
if (coupon.maxUses !== null && coupon.currentUses >= coupon.maxUses) {
return NextResponse.json(
{ error: 'This coupon has reached its maximum usage limit' },
{ status: 400 }
);
}

// Check minimum order amount
if (coupon.minOrderAmount !== null && orderAmount < coupon.minOrderAmount) {
// Format amount for display (divide by 100 for major units)
const minDisplay = (coupon.minOrderAmount / 100).toFixed(2);
return NextResponse.json(
{ error: `Minimum order amount of ৳${minDisplay} is required for this coupon` },
{ status: 400 }
);
}

// Calculate discount amount (values are stored in minor units / paisa)
let discountAmount: number;

if (coupon.type === 'PERCENTAGE') {
// value is 0-100 (e.g. 10 = 10%)
discountAmount = Math.round((orderAmount * coupon.value) / 100);
// Apply cap if set
if (coupon.maxDiscountAmount !== null) {
discountAmount = Math.min(discountAmount, coupon.maxDiscountAmount);
}
} else {
// FIXED – value is minor units (paisa)
// Cannot discount more than the order amount
discountAmount = Math.min(coupon.value, orderAmount);
Comment on lines +88 to +98
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else branch treats all non-PERCENTAGE discount types as a fixed-amount discount against orderAmount. This is incorrect for FREE_SHIPPING (and may diverge from discountService.applyCode, which also supports FIXED, FIXED_AMOUNT, and FREE_SHIPPING). Please handle the full DiscountType enum explicitly or delegate to discountService.applyCode to avoid incorrect discount calculations.

Suggested change
if (coupon.type === 'PERCENTAGE') {
// value is 0-100 (e.g. 10 = 10%)
discountAmount = Math.round((orderAmount * coupon.value) / 100);
// Apply cap if set
if (coupon.maxDiscountAmount !== null) {
discountAmount = Math.min(discountAmount, coupon.maxDiscountAmount);
}
} else {
// FIXED – value is minor units (paisa)
// Cannot discount more than the order amount
discountAmount = Math.min(coupon.value, orderAmount);
switch (coupon.type) {
case 'PERCENTAGE': {
// value is 0-100 (e.g. 10 = 10%)
discountAmount = Math.round((orderAmount * coupon.value) / 100);
// Apply cap if set
if (coupon.maxDiscountAmount !== null) {
discountAmount = Math.min(discountAmount, coupon.maxDiscountAmount);
}
break;
}
case 'FIXED':
case 'FIXED_AMOUNT': {
// FIXED / FIXED_AMOUNT – value is minor units (paisa)
// Cannot discount more than the order amount
discountAmount = Math.min(coupon.value, orderAmount);
break;
}
case 'FREE_SHIPPING': {
// FREE_SHIPPING does not reduce the order subtotal in this endpoint
// (shipping discount should be handled separately).
discountAmount = 0;
break;
}
default: {
// Unknown or unsupported discount type – do not apply a subtotal discount
discountAmount = 0;
break;
}

Copilot uses AI. Check for mistakes.
}

return NextResponse.json({
valid: true,
code: coupon.code,
discountAmount,
description: coupon.description ?? coupon.name,
type: coupon.type,
value: coupon.value,
});
} catch (error) {
console.error('[coupon/validate] Error:', error);
return NextResponse.json(
{ error: 'Failed to validate coupon. Please try again.' },
{ status: 500 }
);
}
}
Loading
Loading