Skip to content
Closed
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
164 changes: 164 additions & 0 deletions app/api/carts/[cartId]/apply-promo/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getPromoCode, isPromoCodeValid, calculateDiscount, applyPromoRequestSchema, applyPromoResponseSchema } from '@/lib/promo';

/**
* Applies a promo code to a cart and returns the updated cart with discount breakdown.
*
* POST /api/carts/:cartId/apply-promo
*
* Request body:
* ```json
* { "promoCode": "SAVE10" }
* ```
*
* Response on success (200):
* ```json
* {
* "cartId": "cart_123",
* "promoCode": "SAVE10",
* "discountPercentage": 10,
* "discountAmount": 1000,
* "originalTotal": 10000,
* "newTotal": 9000,
* "validFrom": "2026-01-01T00:00:00Z",
* "validUntil": "2026-12-31T23:59:59Z"
* }
* ```
*
* Error responses:
* - 400 if promo code is invalid, expired, or malformed
* - 404 if cart not found
*
* @param req - The Next.js request object
* @param context - Route context containing cartId
* @returns JSON response with discount information or error
*
* @example
* const res = await fetch('/api/carts/cart_123/apply-promo', {
* method: 'POST',
* body: JSON.stringify({ promoCode: 'SAVE10' }),
* });
* const data = await res.json();
*/
export async function POST(
req: NextRequest,
{ params }: { params: { cartId: string } }
) {
try {
// Validate and parse request body
const body = await req.json();
const parsed = applyPromoRequestSchema.safeParse(body);

if (!parsed.success) {
return NextResponse.json(
{
error: 'invalid_request',
message: 'Promo code must be a non-empty string',
},
{ status: 400 }
);
}

const { promoCode } = parsed.data;
const { cartId } = params;

// Validate cart exists
const cart = await db.getCart(cartId);
if (!cart) {
return NextResponse.json(
{
error: 'cart_not_found',
message: 'Cart not found',
},
{ status: 404 }
);
}

// Fetch promo code from database
const promoDB = await getPromoCode(db, promoCode);
if (!promoDB) {
return NextResponse.json(
{
error: 'promo_code_invalid',
message: 'Promo code is invalid',
},
{ status: 400 }
);
}

// Validate promo code is active and within date range
if (!isPromoCodeValid(promoDB)) {
return NextResponse.json(
{
error: 'promo_code_invalid',
message: 'Promo code is invalid',
},
{ status: 400 }
);
}

// Calculate discount
const discountAmount = calculateDiscount(cart.subtotalCents, promoDB.discountPercentage);
const newTotal = Math.max(0, cart.subtotalCents - discountAmount);

// Update cart with promo code and discount using optimistic locking
const updated = await db.updateCartWithPromo(cartId, promoDB.code, discountAmount, newTotal, cart.updatedAt);
if (!updated) {
return NextResponse.json(
{
error: 'conflict',
message: 'Cart has been modified. Please try again.',
},
{ status: 409 }
);
}

// Return response
const parseResult = applyPromoResponseSchema.safeParse({
cartId,
promoCode: promoDB.code,
discountPercentage: promoDB.discountPercentage,
discountAmount,
originalTotal: cart.subtotalCents,
newTotal,
validFrom: promoDB.validFrom,
validUntil: promoDB.validUntil,
});

if (!parseResult.success) {
console.error('Response validation error:', parseResult.error);
return NextResponse.json(
{
error: 'internal_error',
message: 'An internal error occurred while applying the promo code',
},
{ status: 500 }
);
}

return NextResponse.json(parseResult.data, { status: 200 });
} catch (error) {
if (error instanceof SyntaxError) {
return NextResponse.json(
{
error: 'invalid_json',
message: 'Request body must be valid JSON',
},
{ status: 400 }
);
}

// Log unexpected errors
console.error('Error applying promo code:', error);

return NextResponse.json(
{
error: 'internal_error',
message: 'An internal error occurred while applying the promo code',
},
{ status: 500 }
);
}
}
85 changes: 85 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ export type Product = {
priceCents: number;
};

export type Cart = {
id: string;
items: Array<{ productId: string; quantity: number; unitPriceCents: number }>;
promoCode: string | null;
subtotalCents: number;
discountCents: number;
totalCents: number;
createdAt: string;
updatedAt: string;
};

export const db = {
async listProducts({ limit = 20, offset = 0 }: { limit?: number; offset?: number }): Promise<Product[]> {
const result = await pool.query<{ id: string; name: string; description: string; price_cents: number }>(
Expand All @@ -30,6 +41,80 @@ export const db = {
priceCents: r.price_cents,
}));
},

/**
* Fetches a cart by ID.
*
* @param cartId - The cart ID
* @returns Cart if found, null otherwise
*
* @example
* const cart = await db.getCart("cart_123");
*/
async getCart(cartId: string): Promise<Cart | null> {
const result = await pool.query<{
id: string;
items: string;
promo_code: string | null;
subtotal_cents: number;
discount_cents: number;
total_cents: number;
created_at: string;
updated_at: string;
}>(
`SELECT id, items, promo_code, subtotal_cents, discount_cents, total_cents, created_at, updated_at
FROM carts
WHERE id = $1`,
[cartId]
);

if (result.rows.length === 0) {
return null;
}

const row = result.rows[0];
return {
id: row.id,
items: JSON.parse(row.items),
promoCode: row.promo_code,
subtotalCents: row.subtotal_cents,
discountCents: row.discount_cents,
totalCents: row.total_cents,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
},

/**
* Updates a cart with discount and promo code information using optimistic locking.
*
* @param cartId - The cart ID
* @param promoCode - The promo code to apply
* @param discountCents - The discount amount in cents
* @param newTotalCents - The new total in cents
* @param previousUpdatedAt - The cart's updatedAt timestamp from when it was read (for optimistic locking)
* @returns true if update succeeded, false if cart was concurrently modified or deleted
*
* @example
* const success = await db.updateCartWithPromo("cart_123", "SAVE10", 1000, 9000, "2026-01-01T00:00:00Z");
*/
async updateCartWithPromo(
cartId: string,
promoCode: string,
discountCents: number,
newTotalCents: number,
previousUpdatedAt: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await pool.query(
`UPDATE carts
SET promo_code = $1, discount_cents = $2, total_cents = $3, updated_at = $4
WHERE id = $5 AND updated_at = $6`,
[promoCode, discountCents, newTotalCents, now, cartId, previousUpdatedAt]
);
return result.rowCount === 1;
},

async query<T = Record<string, unknown>>(
sql: string,
params: ReadonlyArray<unknown> = []
Expand Down
128 changes: 128 additions & 0 deletions lib/promo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { z } from 'zod';
import type { Db } from './db';

/**
* Schema for promo code validation.
*
* @example
* const result = promoCodeSchema.safeParse({
* code: "SAVE10",
* discountPercentage: 10,
* validFrom: "2026-01-01T00:00:00Z",
* validUntil: "2026-12-31T23:59:59Z",
* });
*/
export const promoCodeSchema = z.object({
id: z.string().min(1),
code: z.string().min(1).max(50),
discountPercentage: z.number().min(0).max(100),
validFrom: z.string().datetime(),
validUntil: z.string().datetime(),
active: z.boolean(),
createdAt: z.string().datetime(),
});

/**
* PromoCode represents a discount code in the system.
*
* @example
* const promo: PromoCode = {
* id: "promo_123",
* code: "SAVE10",
* discountPercentage: 10,
* validFrom: "2026-01-01T00:00:00Z",
* validUntil: "2026-12-31T23:59:59Z",
* active: true,
* createdAt: "2026-01-01T00:00:00Z",
* };
*/
export type PromoCode = z.infer<typeof promoCodeSchema>;

/**
* Request body schema for applying a promo code.
*
* @example
* const body = { promoCode: "SAVE10" };
*/
export const applyPromoRequestSchema = z.object({
promoCode: z.string().min(1).max(50),
});

/**
* Response schema for applying a promo code to a cart.
*/
export const applyPromoResponseSchema = z.object({
cartId: z.string(),
promoCode: z.string(),
discountPercentage: z.number(),
discountAmount: z.number(),
originalTotal: z.number(),
newTotal: z.number(),
validFrom: z.string().datetime(),
validUntil: z.string().datetime(),
});

export type ApplyPromoResponse = z.infer<typeof applyPromoResponseSchema>;

/**
* Fetches a promo code by its code value.
*
* @param db - Database instance
* @param code - The promo code string (e.g., "SAVE10")
* @returns PromoCode if found, null otherwise
*
* @example
* const promo = await getPromoCode(db, "SAVE10");
* if (!promo) {
* throw new Error("Promo code not found");
* }
*/
export async function getPromoCode(db: Db, code: string): Promise<PromoCode | null> {
const { rows } = await db.query<PromoCode>(
`SELECT id, code, discount_percentage AS "discountPercentage",
valid_from AS "validFrom", valid_until AS "validUntil",
active, created_at AS "createdAt"
FROM promo_codes
WHERE UPPER(code) = UPPER($1)
LIMIT 1`,
[code],
);
return rows[0] ?? null;
}

/**
* Validates that a promo code is active and within its valid date range.
*
* @param promoCode - The promo code to validate
* @returns true if valid, false otherwise
*
* @example
* if (!isPromoCodeValid(promo)) {
* throw new Error("Promo code is expired or inactive");
* }
*/
export function isPromoCodeValid(promoCode: PromoCode): boolean {
if (!promoCode.active) {
return false;
}

const now = new Date();
const validFrom = new Date(promoCode.validFrom);
const validUntil = new Date(promoCode.validUntil);

return now >= validFrom && now <= validUntil;
}

/**
* Calculates the discount amount based on the original total and discount percentage.
*
* @param subtotalCents - The original subtotal in cents
* @param discountPercentage - The discount percentage (0-100)
* @returns The discount amount in cents
*
* @example
* const discount = calculateDiscount(10000, 10); // 1000 cents (10% of $100)
*/
export function calculateDiscount(subtotalCents: number, discountPercentage: number): number {
return Math.floor((subtotalCents * discountPercentage) / 100);
}
5 changes: 5 additions & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
Loading