Repository navigation
feat: discount coupons with usage limits, expiry and minimum cart value #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
|
|
||
| export function listCoupons(db: Db): Coupon[] { | ||
| return db.prepare(`SELECT ${COLUMNS} FROM coupons ORDER BY code`).all() as unknown as Coupon[]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the original VAT requires rounding, scaling the already-rounded 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 ₺" */ | ||
|
|
||
| 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); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a coupon expires after being applied, or another customer consumes its last permitted use,
appliedCouponstill returns it because onlyisActiveis checked. The cart view passes this result directly tocalculateTotals, soGET /cartcontinues 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 👍 / 👎.