diff --git a/README.md b/README.md index da445a9..713f076 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ It focuses on the parts that usually go wrong in real shops: `UPDATE … WHERE stock >= ?`. Two customers racing for the last item: one gets the order, the other a `409`. - **Safe retries.** `POST /orders` accepts an `Idempotency-Key` header. A double-clicked "Pay" button creates one order. - **Correct money.** Prices are integer kuruş, VAT-inclusive, with per-product Turkish VAT (KDV) rates of 0, 1, 10 or 20 %. +- **Discount coupons.** Percent or fixed-amount codes with minimum cart value, usage limit and + expiry. Usage is counted inside the checkout transaction and given back if the order is cancelled. - **Order workflow.** `pending → paid → shipped → delivered`, or `cancelled`, which puts the stock back. - **Secure auth.** scrypt password hashes, HS256 JWTs verified in constant time, no user enumeration on login. @@ -26,7 +28,7 @@ It focuses on the parts that usually go wrong in real shops: git clone https://github.com/bahriy-dev/ecommerce-api.git cd ecommerce-api npm start # http://localhost:3000/api/v1 (seeds an admin and 3 demo products) -npm test # 27 tests: unit + full HTTP flows +npm test # 36 tests: unit + full HTTP flows ``` With Docker: @@ -54,11 +56,16 @@ All routes are under `/api/v1`. Send `Authorization: Bearer ` for protect | `GET` | `/cart` | user | Cart lines, availability and totals | | `PUT` | `/cart/items/:productId` | user | Set quantity (`0` removes) | | `DELETE` | `/cart` | user | Empty the cart | +| `PUT` | `/cart/coupon` | user | Apply a coupon code (`{ "code": "HOSGELDIN10" }`) | +| `DELETE` | `/cart/coupon` | user | Remove the coupon | | `POST` | `/orders` | user | Checkout. Supports `Idempotency-Key` | | `GET` | `/orders` | user | My orders (`?all=true` for admins) | | `GET` | `/orders/:id` | user | Order detail (owner or admin) | | `POST` | `/orders/:id/cancel` | user | Cancel a pending order | | `PATCH` | `/orders/:id/status` | admin | Move an order through the workflow | +| `GET` | `/coupons` | admin | List coupons with usage counts | +| `POST` | `/coupons` | admin | Create a coupon | +| `DELETE` | `/coupons/:code` | admin | Deactivate a coupon | **Product query parameters:** `q`, `category` (slug), `minPrice`, `maxPrice` (kuruş), `inStock=true`, `sort` (`newest`, `price_asc`, `price_desc`, `name`), `page`, `limit` (max 100). @@ -89,6 +96,22 @@ curl -X POST localhost:3000/api/v1/orders -H "Authorization: Bearer $TOKEN" \ } ``` +### Example: coupon + +```bash +# admin: 10% off orders of 500 TL or more, 1000 uses, valid until the end of the year +curl -X POST localhost:3000/api/v1/coupons -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"code":"HOSGELDIN10","type":"percent","value":10,"minSubtotal":50000,"maxUses":1000,"expiresAt":"2026-12-31T23:59:59+03:00"}' + +# customer +curl -X PUT localhost:3000/api/v1/cart/coupon -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"code":"hosgeldin10"}' +``` + +The cart and the order then show `discount` and `couponCode`. VAT is recalculated on the +discounted amount, and the free-shipping threshold is checked after the discount. + Errors always look like `{ "error": "…", "details": { … } }`. Validation errors return `422` with one message per field. @@ -113,8 +136,9 @@ src/ auth.ts scrypt passwords, JWT, role guards validate.ts tiny schema validator (422 with per-field errors) db.ts schema migrations (PRAGMA user_version), transactions, seed data - pricing.ts VAT, shipping and totals in integer kuruş - routes/ auth, catalog, cart, orders + pricing.ts VAT, coupon discounts, shipping and totals in integer kuruş + coupons.ts coupon lookup and validity checks (expiry, usage limit) + routes/ auth, catalog, cart, orders, coupons app.ts wiring, CORS, error handling test/ node:test unit tests and end-to-end HTTP tests ``` diff --git a/src/app.ts b/src/app.ts index 1a0233e..4d8c158 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { authRoutes } from './routes/auth.ts'; import { catalogRoutes } from './routes/catalog.ts'; import { cartRoutes } from './routes/cart.ts'; import { orderRoutes } from './routes/orders.ts'; +import { couponRoutes } from './routes/coupons.ts'; export interface Config { jwtSecret: string; @@ -26,6 +27,7 @@ export function createApp(deps: AppDeps) { catalogRoutes(router, deps); cartRoutes(router, deps); orderRoutes(router, deps); + couponRoutes(router, deps); return async function handler(req: IncomingMessage, res: ServerResponse): Promise { const started = performance.now(); diff --git a/src/coupons.ts b/src/coupons.ts new file mode 100644 index 0000000..4cf487f --- /dev/null +++ b/src/coupons.ts @@ -0,0 +1,39 @@ +import { HttpError } from './http.ts'; +import type { Db } from './db.ts'; +import type { CouponRule } from './pricing.ts'; + +export interface Coupon extends CouponRule { + code: string; + maxUses: number | null; + usedCount: number; + expiresAt: string | null; + isActive: number; +} + +const COLUMNS = `code, type, value, min_subtotal AS minSubtotal, max_uses AS maxUses, + used_count AS usedCount, expires_at AS expiresAt, is_active AS isActive`; + +export function findCoupon(db: Db, code: string): Coupon | undefined { + return db.prepare(`SELECT ${COLUMNS} FROM coupons WHERE code = ?`).get(code.trim()) as unknown as Coupon | undefined; +} + +/** Returns the coupon if it can be used right now, otherwise throws a 4xx explaining why. */ +export function checkCoupon(db: Db, code: string, now = new Date()): Coupon { + const coupon = findCoupon(db, code); + if (!coupon || !coupon.isActive) throw new HttpError(404, 'Coupon not found'); + if (coupon.expiresAt && new Date(coupon.expiresAt) <= now) throw new HttpError(409, 'Coupon has expired'); + if (coupon.maxUses !== null && coupon.usedCount >= coupon.maxUses) throw new HttpError(409, 'Coupon usage limit reached'); + return coupon; +} + +/** The coupon a customer applied to their cart, if it still exists and is active. */ +export function appliedCoupon(db: Db, userId: number): Coupon | null { + const row = db.prepare('SELECT code FROM cart_coupons WHERE user_id = ?').get(userId) as { code: string } | undefined; + if (!row) return null; + const coupon = findCoupon(db, row.code); + return coupon && coupon.isActive ? coupon : null; +} + +export function listCoupons(db: Db): Coupon[] { + return db.prepare(`SELECT ${COLUMNS} FROM coupons ORDER BY code`).all() as unknown as Coupon[]; +} diff --git a/src/db.ts b/src/db.ts index b51355d..f30a0ef 100644 --- a/src/db.ts +++ b/src/db.ts @@ -59,6 +59,24 @@ const MIGRATIONS: string[] = [ quantity INTEGER NOT NULL, PRIMARY KEY (order_id, product_id) );`, + // 2: discount coupons + `CREATE TABLE coupons ( + code TEXT PRIMARY KEY COLLATE NOCASE, + type TEXT NOT NULL CHECK (type IN ('percent', 'fixed')), + value INTEGER NOT NULL CHECK (value > 0), + min_subtotal INTEGER NOT NULL DEFAULT 0, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + CHECK (type = 'fixed' OR value <= 100) + ); + CREATE TABLE cart_coupons ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + code TEXT NOT NULL REFERENCES coupons(code) ON DELETE CASCADE + ); + ALTER TABLE orders ADD COLUMN discount INTEGER NOT NULL DEFAULT 0; + ALTER TABLE orders ADD COLUMN coupon_code TEXT;`, ]; export function openDb(path = ':memory:'): Db { diff --git a/src/pricing.ts b/src/pricing.ts index c9f9826..430e8c0 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -10,6 +10,8 @@ export interface PricedLine { export interface Totals { subtotal: number; + /** Coupon discount, already subtracted from `total`. */ + discount: number; vatTotal: number; shipping: number; total: number; @@ -28,16 +30,36 @@ export function vatIncluded(grossAmount: number, vatRate: number): number { return Math.round((grossAmount * vatRate) / (100 + vatRate)); } -export function calculateTotals(lines: PricedLine[], shipping: ShippingRule): Totals { +export interface CouponRule { + type: 'percent' | 'fixed'; + /** Percent (1–100) or a fixed amount in kuruş. */ + value: number; + /** Minimum cart subtotal in kuruş for the coupon to apply. */ + minSubtotal: number; +} + +/** Discount a coupon gives on a subtotal; never more than the subtotal itself. */ +export function couponDiscount(coupon: CouponRule, subtotal: number): number { + if (subtotal < coupon.minSubtotal) return 0; + const amount = coupon.type === 'percent' ? Math.round((subtotal * coupon.value) / 100) : coupon.value; + return Math.min(amount, subtotal); +} + +export function calculateTotals(lines: PricedLine[], shipping: ShippingRule, coupon: CouponRule | null = null): Totals { let subtotal = 0; - let vatTotal = 0; + let grossVat = 0; for (const line of lines) { const gross = line.unitPrice * line.quantity; subtotal += gross; - vatTotal += vatIncluded(gross, line.vatRate); + grossVat += vatIncluded(gross, line.vatRate); } - const shippingFee = subtotal === 0 || subtotal >= shipping.freeOver ? 0 : shipping.fee; - return { subtotal, vatTotal, shipping: shippingFee, total: subtotal + shippingFee }; + const discount = coupon ? couponDiscount(coupon, subtotal) : 0; + const net = subtotal - discount; + // A discount lowers the taxable amount, so the VAT inside it shrinks proportionally. + const vatTotal = subtotal === 0 ? 0 : Math.round((grossVat * net) / subtotal); + // Free-shipping threshold is checked against what the customer actually pays for goods. + const shippingFee = subtotal === 0 || net >= shipping.freeOver ? 0 : shipping.fee; + return { subtotal, discount, vatTotal, shipping: shippingFee, total: net + shippingFee }; } /** 249900 → "2.499,00 ₺" */ diff --git a/src/routes/cart.ts b/src/routes/cart.ts index 42cca0a..c33c51a 100644 --- a/src/routes/cart.ts +++ b/src/routes/cart.ts @@ -3,6 +3,7 @@ import { HttpError } from '../http.ts'; import { requireUser } from '../auth.ts'; import { validate } from '../validate.ts'; import { calculateTotals } from '../pricing.ts'; +import { appliedCoupon, checkCoupon } from '../coupons.ts'; import type { AppDeps } from '../app.ts'; export interface CartLine { @@ -28,13 +29,15 @@ export function loadCart(db: AppDeps['db'], userId: number): CartLine[] { export function cartRoutes(r: Router, { db, config }: AppDeps): void { const view = (userId: number) => { const lines = loadCart(db, userId); + const coupon = appliedCoupon(db, userId); return { + coupon: coupon ? { code: coupon.code, type: coupon.type, value: coupon.value, minSubtotal: coupon.minSubtotal } : null, items: lines.map(({ stock, isActive, ...line }) => ({ ...line, lineTotal: line.unitPrice * line.quantity, available: Boolean(isActive) && stock >= line.quantity, })), - totals: calculateTotals(lines, config.shipping), + totals: calculateTotals(lines, config.shipping, coupon), }; }; @@ -66,6 +69,27 @@ export function cartRoutes(r: Router, { db, config }: AppDeps): void { r.delete('/cart', (ctx) => { const { id } = requireUser(ctx); db.prepare('DELETE FROM cart_items WHERE user_id = ?').run(id); + db.prepare('DELETE FROM cart_coupons WHERE user_id = ?').run(id); + return view(id); + }); + + /** Applies a coupon code to the cart. Only one coupon per cart; a new one replaces the old. */ + r.put('/cart/coupon', (ctx) => { + const { id: userId } = requireUser(ctx); + const { code } = validate<{ code: string }>(ctx.body, { code: { type: 'string', min: 3, max: 32 } }); + const coupon = checkCoupon(db, code); + const subtotal = loadCart(db, userId).reduce((sum, l) => sum + l.unitPrice * l.quantity, 0); + if (subtotal < coupon.minSubtotal) { + throw new HttpError(409, `Coupon needs a cart subtotal of at least ${coupon.minSubtotal} kuruş`); + } + db.prepare(`INSERT INTO cart_coupons (user_id, code) VALUES (?, ?) + ON CONFLICT (user_id) DO UPDATE SET code = excluded.code`).run(userId, coupon.code); + return view(userId); + }); + + r.delete('/cart/coupon', (ctx) => { + const { id } = requireUser(ctx); + db.prepare('DELETE FROM cart_coupons WHERE user_id = ?').run(id); return view(id); }); } diff --git a/src/routes/coupons.ts b/src/routes/coupons.ts new file mode 100644 index 0000000..6063ade --- /dev/null +++ b/src/routes/coupons.ts @@ -0,0 +1,54 @@ +import type { Router } from '../http.ts'; +import { HttpError, withStatus } from '../http.ts'; +import { requireAdmin } from '../auth.ts'; +import { validate } from '../validate.ts'; +import { findCoupon, listCoupons } from '../coupons.ts'; +import type { AppDeps } from '../app.ts'; + +interface CouponInput { + code: string; + type: 'percent' | 'fixed'; + value: number; + minSubtotal?: number; + maxUses?: number; + expiresAt?: string; + isActive?: boolean; +} + +export function couponRoutes(r: Router, { db }: AppDeps): void { + r.get('/coupons', (ctx) => { + requireAdmin(ctx); + return listCoupons(db); + }); + + r.post('/coupons', (ctx) => { + requireAdmin(ctx); + const c = validate(ctx.body, { + code: { type: 'string', min: 3, max: 32, pattern: /^[A-Za-z0-9_-]+$/ }, + type: { type: 'enum', values: ['percent', 'fixed'] }, + value: { type: 'int', min: 1, max: 100_000_000 }, + minSubtotal: { type: 'int', min: 0, max: 100_000_000, optional: true }, + maxUses: { type: 'int', min: 1, max: 1_000_000, optional: true }, + expiresAt: { type: 'string', max: 40, optional: true }, + isActive: { type: 'boolean', optional: true }, + }); + if (c.type === 'percent' && c.value > 100) throw new HttpError(422, 'Validation failed', { value: 'must be <= 100 for percent coupons' }); + if (c.expiresAt && Number.isNaN(Date.parse(c.expiresAt))) throw new HttpError(422, 'Validation failed', { expiresAt: 'must be an ISO date' }); + const code = c.code.toUpperCase(); + if (findCoupon(db, code)) throw new HttpError(409, 'Coupon code already exists'); + + db.prepare(`INSERT INTO coupons (code, type, value, min_subtotal, max_uses, expires_at, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(code, c.type, c.value, c.minSubtotal ?? 0, c.maxUses ?? null, + c.expiresAt ? new Date(c.expiresAt).toISOString() : null, c.isActive === false ? 0 : 1); + return withStatus(201, findCoupon(db, code)); + }); + + /** Deactivates a coupon. Past orders keep their coupon code and discount. */ + r.delete('/coupons/:code', (ctx) => { + requireAdmin(ctx); + const { changes } = db.prepare('UPDATE coupons SET is_active = 0 WHERE code = ?').run(ctx.params.code); + if (!changes) throw new HttpError(404, 'Coupon not found'); + return withStatus(204, null); + }); +} diff --git a/src/routes/orders.ts b/src/routes/orders.ts index f6f6629..5b9e21f 100644 --- a/src/routes/orders.ts +++ b/src/routes/orders.ts @@ -5,6 +5,7 @@ import { validate, intParam } from '../validate.ts'; import { transaction } from '../db.ts'; import { calculateTotals } from '../pricing.ts'; import { loadCart } from './cart.ts'; +import { appliedCoupon, checkCoupon } from '../coupons.ts'; import type { AppDeps } from '../app.ts'; export const ORDER_STATUSES = ['pending', 'paid', 'shipped', 'delivered', 'cancelled'] as const; @@ -24,6 +25,8 @@ interface OrderRow { userId: number; status: Status; subtotal: number; + discount: number; + couponCode: string | null; vatTotal: number; shipping: number; total: number; @@ -31,7 +34,8 @@ interface OrderRow { createdAt: string; } -const ORDER_COLUMNS = `id, user_id AS userId, status, subtotal, vat_total AS vatTotal, shipping, total, +const ORDER_COLUMNS = `id, user_id AS userId, status, subtotal, discount, coupon_code AS couponCode, + vat_total AS vatTotal, shipping, total, shipping_address AS shippingAddress, created_at AS createdAt`; export function orderRoutes(r: Router, { db, config }: AppDeps): void { @@ -77,17 +81,28 @@ export function orderRoutes(r: Router, { db, config }: AppDeps): void { } if (Object.keys(problems).length) throw new HttpError(409, 'Some items are out of stock', problems); - const totals = calculateTotals(lines, config.shipping); + // Re-check the coupon inside the transaction: it may have expired or run out since it was applied. + const applied = appliedCoupon(db, userId); + const coupon = applied ? checkCoupon(db, applied.code) : null; + const totals = calculateTotals(lines, config.shipping, coupon); + if (coupon && totals.discount > 0) { + const { changes } = db + .prepare('UPDATE coupons SET used_count = used_count + 1 WHERE code = ? AND (max_uses IS NULL OR used_count < max_uses)') + .run(coupon.code); + if (!changes) throw new HttpError(409, 'Coupon usage limit reached'); + } const { lastInsertRowid } = db - .prepare(`INSERT INTO orders (user_id, subtotal, vat_total, shipping, total, shipping_address, idempotency_key) - VALUES (?, ?, ?, ?, ?, ?, ?)`) - .run(userId, totals.subtotal, totals.vatTotal, totals.shipping, totals.total, shippingAddress, idempotencyKey); + .prepare(`INSERT INTO orders (user_id, subtotal, discount, coupon_code, vat_total, shipping, total, shipping_address, idempotency_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(userId, totals.subtotal, totals.discount, totals.discount > 0 ? coupon!.code : null, + totals.vatTotal, totals.shipping, totals.total, shippingAddress, idempotencyKey); const id = Number(lastInsertRowid); const item = db.prepare('INSERT INTO order_items (order_id, product_id, name, unit_price, vat_rate, quantity) VALUES (?, ?, ?, ?, ?, ?)'); for (const line of lines) item.run(id, line.productId, line.name, line.unitPrice, line.vatRate, line.quantity); db.prepare('DELETE FROM cart_items WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM cart_coupons WHERE user_id = ?').run(userId); return id; }); @@ -142,6 +157,9 @@ export function orderRoutes(r: Router, { db, config }: AppDeps): void { db.prepare(`UPDATE products SET stock = stock + ( SELECT quantity FROM order_items WHERE order_id = ? AND product_id = products.id) WHERE id IN (SELECT product_id FROM order_items WHERE order_id = ?)`).run(id, id); + // Give the coupon use back so the customer can use it again. + db.prepare(`UPDATE coupons SET used_count = used_count - 1 + WHERE code = (SELECT coupon_code FROM orders WHERE id = ?) AND used_count > 0`).run(id); } }); } diff --git a/test/api.test.ts b/test/api.test.ts index 0d03d34..3d8d750 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -88,7 +88,7 @@ describe('cart & checkout', () => { const token = await registerCustomer(app); const cart = await app.request('PUT', `/cart/items/${productId('KTP-003')}`, { token, body: { quantity: 1 } }); assert.equal(cart.status, 200); - assert.deepEqual(cart.body.totals, { subtotal: 32000, vatTotal: 0, shipping: 4990, total: 36990 }); + assert.deepEqual(cart.body.totals, { subtotal: 32000, discount: 0, vatTotal: 0, shipping: 4990, total: 36990 }); }); it('refuses quantities above stock', async () => { diff --git a/test/coupons.test.ts b/test/coupons.test.ts new file mode 100644 index 0000000..67cc980 --- /dev/null +++ b/test/coupons.test.ts @@ -0,0 +1,102 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startApp, registerCustomer, loginAdmin, type TestApp } from './helpers.ts'; +import { couponDiscount, calculateTotals } from '../src/pricing.ts'; + +describe('couponDiscount', () => { + it('applies percent and fixed coupons', () => { + assert.equal(couponDiscount({ type: 'percent', value: 10, minSubtotal: 0 }, 32000), 3200); + assert.equal(couponDiscount({ type: 'fixed', value: 5000, minSubtotal: 0 }, 32000), 5000); + }); + it('never discounts more than the subtotal', () => { + assert.equal(couponDiscount({ type: 'fixed', value: 99999, minSubtotal: 0 }, 1000), 1000); + }); + it('respects the minimum subtotal', () => { + assert.equal(couponDiscount({ type: 'percent', value: 10, minSubtotal: 50000 }, 49999), 0); + }); + it('lowers VAT and can drop below the free-shipping threshold', () => { + const lines = [{ unitPrice: 60000, vatRate: 20, quantity: 1 }]; + const t = calculateTotals(lines, { fee: 4990, freeOver: 50000 }, { type: 'percent', value: 20, minSubtotal: 0 }); + assert.deepEqual(t, { subtotal: 60000, discount: 12000, vatTotal: 8000, shipping: 4990, total: 52990 }); + }); +}); + +describe('coupons API', () => { + let app: TestApp; + let admin: string; + before(async () => { + app = await startApp(); + admin = await loginAdmin(app); + }); + after(() => app.close()); + + const productId = (sku: string) => (app.db.prepare('SELECT id FROM products WHERE sku = ?').get(sku) as { id: number }).id; + const usedCount = (code: string) => (app.db.prepare('SELECT used_count AS n FROM coupons WHERE code = ?').get(code) as { n: number }).n; + const createCoupon = (body: object) => app.request('POST', '/coupons', { token: admin, body }); + + it('lets only admins create coupons and validates them', async () => { + const customer = await registerCustomer(app); + assert.equal((await app.request('POST', '/coupons', { token: customer, body: { code: 'X10', type: 'percent', value: 10 } })).status, 403); + assert.equal((await createCoupon({ code: 'BAD', type: 'percent', value: 150 })).status, 422); + const res = await createCoupon({ code: 'hosgeldin10', type: 'percent', value: 10 }); + assert.equal(res.status, 201); + assert.equal(res.body.code, 'HOSGELDIN10'); + assert.equal((await createCoupon({ code: 'HOSGELDIN10', type: 'fixed', value: 100 })).status, 409); + }); + + it('applies a coupon to the cart and records it on the order', async () => { + const token = await registerCustomer(app); + await app.request('PUT', `/cart/items/${productId('KTP-003')}`, { token, body: { quantity: 1 } }); + const cart = await app.request('PUT', '/cart/coupon', { token, body: { code: 'hosgeldin10' } }); + assert.equal(cart.status, 200); + assert.equal(cart.body.coupon.code, 'HOSGELDIN10'); + assert.deepEqual(cart.body.totals, { subtotal: 32000, discount: 3200, vatTotal: 0, shipping: 4990, total: 33790 }); + + const order = await app.request('POST', '/orders', { token, body: { shippingAddress: 'Moda, Kadıköy, İstanbul 34710' } }); + assert.equal(order.status, 201); + assert.equal(order.body.discount, 3200); + assert.equal(order.body.couponCode, 'HOSGELDIN10'); + assert.equal(order.body.total, 33790); + assert.equal(usedCount('HOSGELDIN10'), 1); + assert.equal((await app.request('GET', '/cart', { token })).body.coupon, null); + }); + + it('enforces the usage limit and gives the use back on cancel', async () => { + await createCoupon({ code: 'TEK', type: 'fixed', value: 1000, maxUses: 1 }); + const [a, b] = [await registerCustomer(app), await registerCustomer(app)]; + for (const token of [a, b]) { + await app.request('PUT', `/cart/items/${productId('MOU-002')}`, { token, body: { quantity: 1 } }); + assert.equal((await app.request('PUT', '/cart/coupon', { token, body: { code: 'TEK' } })).status, 200); + } + const first = await app.request('POST', '/orders', { token: a, body: { shippingAddress: 'Çankaya, Ankara 06420' } }); + assert.equal(first.status, 201); + const second = await app.request('POST', '/orders', { token: b, body: { shippingAddress: 'Konak, İzmir 35250' } }); + assert.equal(second.status, 409); + assert.match(second.body.error, /usage limit/); + + await app.request('POST', `/orders/${first.body.id}/cancel`, { token: a }); + assert.equal(usedCount('TEK'), 0); + assert.equal((await app.request('POST', '/orders', { token: b, body: { shippingAddress: 'Konak, İzmir 35250' } })).status, 201); + }); + + it('rejects expired, unknown and below-minimum coupons', async () => { + await createCoupon({ code: 'ESKI', type: 'percent', value: 50, expiresAt: '2020-01-01T00:00:00Z' }); + await createCoupon({ code: 'BUYUK', type: 'percent', value: 15, minSubtotal: 1_000_000 }); + const token = await registerCustomer(app); + await app.request('PUT', `/cart/items/${productId('KTP-003')}`, { token, body: { quantity: 1 } }); + assert.equal((await app.request('PUT', '/cart/coupon', { token, body: { code: 'ESKI' } })).status, 409); + assert.equal((await app.request('PUT', '/cart/coupon', { token, body: { code: 'YOKBOYLE' } })).status, 404); + const below = await app.request('PUT', '/cart/coupon', { token, body: { code: 'BUYUK' } }); + assert.equal(below.status, 409); + assert.match(below.body.error, /at least/); + }); + + it('stops working once an admin deactivates it', async () => { + await createCoupon({ code: 'KAPAT', type: 'fixed', value: 500 }); + const token = await registerCustomer(app); + await app.request('PUT', `/cart/items/${productId('KTP-003')}`, { token, body: { quantity: 1 } }); + await app.request('PUT', '/cart/coupon', { token, body: { code: 'KAPAT' } }); + assert.equal((await app.request('DELETE', '/coupons/KAPAT', { token: admin })).status, 204); + assert.equal((await app.request('GET', '/cart', { token })).body.totals.discount, 0); + }); +});