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
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -54,11 +56,16 @@ All routes are under `/api/v1`. Send `Authorization: Bearer <token>` 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).
Expand Down Expand Up @@ -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.

Expand All @@ -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
```
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<void> {
const started = performance.now();
Expand Down
39 changes: 39 additions & 0 deletions src/coupons.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +33 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude expired and exhausted coupons from cart totals

If a coupon expires after being applied, or another customer consumes its last permitted use, appliedCoupon still returns it because only isActive is checked. The cart view passes this result directly to calculateTotals, so GET /cart continues advertising a discount and total that checkout rejects with a 409. Check expiry and remaining uses when calculating the cart view, and expose the unavailable coupon without presenting its discount as usable.

Useful? React with 👍 / 👎.

}

export function listCoupons(db: Db): Coupon[] {
return db.prepare(`SELECT ${COLUMNS} FROM coupons ORDER BY code`).all() as unknown as Coupon[];
}
18 changes: 18 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 27 additions & 5 deletions src/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Calculate discounted VAT before rounding

When the original VAT requires rounding, scaling the already-rounded grossVat introduces a second rounding error instead of recalculating VAT on the discounted amount. For example, a single item priced at 3 kuruş with 20% VAT and a fixed 1-kuruş discount produces vatTotal: 1, although vatIncluded(2, 20) is 0. This incorrect value is persisted on the order. Allocate the discount to taxable lines and calculate VAT from their discounted amounts before rounding, rather than scaling the rounded original VAT.

Useful? React with 👍 / 👎.

// 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 ₺" */
Expand Down
26 changes: 25 additions & 1 deletion src/routes/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
};
};

Expand Down Expand Up @@ -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);
});
}
54 changes: 54 additions & 0 deletions src/routes/coupons.ts
Original file line number Diff line number Diff line change
@@ -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<CouponInput>(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);
});
}
28 changes: 23 additions & 5 deletions src/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,14 +25,17 @@ interface OrderRow {
userId: number;
status: Status;
subtotal: number;
discount: number;
couponCode: string | null;
vatTotal: number;
shipping: number;
total: number;
shippingAddress: string;
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 {
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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);
}
});
}
Expand Down
2 changes: 1 addition & 1 deletion test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading