Skip to content
Open
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
5 changes: 5 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": [
"next/core-web-vitals"
]
}
59 changes: 59 additions & 0 deletions app/api/cart/promo-code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { cartItemSchema, resolvePromoCodePercent, totalize } from '../../../../lib/cart';

const PromoCodeApplySchema = z.object({
items: z.array(cartItemSchema).min(1),
promoCode: z
.string()
.trim()
.min(1)
.max(32)
.regex(/^[A-Za-z0-9_-]+$/),
region: z.string().trim().min(2).max(8),
});

function authorize(req: NextRequest): NextResponse | null {
const authorization = req.headers.get('authorization');
const userId = req.headers.get('x-user-id');
const role = req.headers.get('x-user-role');

if (!authorization || !authorization.startsWith('Bearer ') || !userId) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}

if (role !== 'customer') {
return NextResponse.json({ error: 'forbidden' }, { status: 403 });
}

return null;
}

export async function POST(req: NextRequest) {
const authFailure = authorize(req);
if (authFailure) {
return authFailure;
}

const body = await req.json();
const parsed = PromoCodeApplySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'invalid_body' }, { status: 400 });
}

const subtotalCents = parsed.data.items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
const discountPercent = resolvePromoCodePercent(subtotalCents, parsed.data.promoCode);
if (discountPercent <= 0) {
return NextResponse.json({ error: 'invalid_or_inapplicable_promo_code' }, { status: 400 });
}

const totals = totalize(parsed.data.items, parsed.data.promoCode, parsed.data.region);
return NextResponse.json({
promoCode: parsed.data.promoCode.toUpperCase(),
discountPercent,
totals,
});
}
26 changes: 20 additions & 6 deletions lib/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface CartTotals {
totalCents: number;
}

const PROMO_CODE_PERCENTAGES: Record<string, number> = {
WELCOME10: 10,
VIP25: 25,
};

export function addItem(cart: CartItem[], item: CartItem): CartItem[] {
const existing = cart.findIndex((c) => c.productId === item.productId);
if (existing === -1) return [...cart, item];
Expand All @@ -27,13 +32,22 @@ export function removeItem(cart: CartItem[], productId: string): CartItem[] {
return cart.filter((c) => c.productId !== productId);
}

export function applyDiscount(subtotalCents: number, code: string | null): number {
export function resolvePromoCodePercent(subtotalCents: number, code: string | null): number {
if (!code) return 0;
const upper = code.toUpperCase();
if (upper === 'WELCOME10') return Math.floor(subtotalCents * 0.10);
if (upper === 'VIP25' && subtotalCents >= 10_000) return Math.floor(subtotalCents * 0.25);
if (upper === 'FREESHIP') return 0;
return 0;
const normalized = code.toUpperCase().trim();
if (normalized === 'VIP25' && subtotalCents < 10_000) return 0;
return PROMO_CODE_PERCENTAGES[normalized] ?? 0;
}

export function applyPercentageDiscount(subtotalCents: number, percent: number): number {
if (!Number.isFinite(percent)) return 0;
const boundedPercent = Math.max(0, Math.min(100, percent));
return Math.floor(subtotalCents * (boundedPercent / 100));
}

export function applyDiscount(subtotalCents: number, code: string | null): number {
const percent = resolvePromoCodePercent(subtotalCents, code);
return applyPercentageDiscount(subtotalCents, percent);
}

export function computeTax(taxableCents: number, region: string): number {
Expand Down
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.
15 changes: 14 additions & 1 deletion tests/cart.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { addItem, removeItem, totalize } from '../lib/cart';
import { addItem, applyPercentageDiscount, removeItem, resolvePromoCodePercent, totalize } from '../lib/cart';

describe('cart · happy path', () => {
it('adds new items', () => {
Expand Down Expand Up @@ -29,6 +29,19 @@ describe('cart · happy path', () => {
expect(totals.taxCents).toBe(400);
expect(totals.totalCents).toBe(2400);
});

it('resolves promo code percentage with threshold checks', () => {
expect(resolvePromoCodePercent(5_000, 'WELCOME10')).toBe(10);
expect(resolvePromoCodePercent(9_999, 'VIP25')).toBe(0);
expect(resolvePromoCodePercent(10_000, 'VIP25')).toBe(25);
expect(resolvePromoCodePercent(5_000, 'UNKNOWN')).toBe(0);
});

it('applies percentage discounts with clamping', () => {
expect(applyPercentageDiscount(10_000, 10)).toBe(1_000);
expect(applyPercentageDiscount(10_000, 500)).toBe(10_000);
expect(applyPercentageDiscount(10_000, -5)).toBe(0);
});
});

// NOTE (workshop): the following branches have NO tests yet — Track 1 (test-improver)
Expand Down
101 changes: 101 additions & 0 deletions tests/promo-code-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { NextRequest } from 'next/server';
import { POST } from '../app/api/cart/promo-code/route';

function buildRequest(
body: unknown,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest('http://localhost:3000/api/cart/promo-code', {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers,
},
body: JSON.stringify(body),
});
}

describe('POST /api/cart/promo-code', () => {
it('returns unauthorized when auth headers are missing', async () => {
const response = await POST(
buildRequest({
items: [{ productId: 'p1', quantity: 1, unitPriceCents: 10_000 }],
promoCode: 'WELCOME10',
region: 'GB',
}),
);

expect(response.status).toBe(401);
});

it('returns forbidden when role is not customer', async () => {
const response = await POST(
buildRequest(
{
items: [{ productId: 'p1', quantity: 1, unitPriceCents: 10_000 }],
promoCode: 'WELCOME10',
region: 'GB',
},
{
authorization: 'Bearer test-token',
'x-user-id': 'u1',
'x-user-role': 'admin',
},
),
);

expect(response.status).toBe(403);
});

it('returns bad request for an invalid or inapplicable code', async () => {
const response = await POST(
buildRequest(
{
items: [{ productId: 'p1', quantity: 1, unitPriceCents: 1_000 }],
promoCode: 'VIP25',
region: 'GB',
},
{
authorization: 'Bearer test-token',
'x-user-id': 'u1',
'x-user-role': 'customer',
},
),
);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: 'invalid_or_inapplicable_promo_code',
});
});

it('applies percentage discount and returns totals', async () => {
const response = await POST(
buildRequest(
{
items: [{ productId: 'p1', quantity: 1, unitPriceCents: 10_000 }],
promoCode: 'vip25',
region: 'GB',
},
{
authorization: 'Bearer test-token',
'x-user-id': 'u1',
'x-user-role': 'customer',
},
),
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
promoCode: 'VIP25',
discountPercent: 25,
totals: {
subtotalCents: 10_000,
discountCents: 2_500,
taxCents: 1_500,
totalCents: 9_000,
},
});
});
});
28 changes: 23 additions & 5 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
Expand All @@ -16,9 +20,23 @@
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
"@/*": [
"./*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "security-fixtures"]
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules",
"security-fixtures"
]
}