From aacfe6c171864d156834738b0ea577430052e0d9 Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 00:28:45 +0530 Subject: [PATCH] feat(coupons): add exact discounts and scoped eligibility rules --- src/checkout/checkout.module.ts | 6 +- src/checkout/money.ts | 26 +++++++++ src/coupons/coupon-policy.ts | 59 +++++++++++++++++++ src/coupons/coupon.schema.ts | 38 +++++++++++++ src/coupons/coupon.store.ts | 55 ++++++++++++++++++ src/coupons/coupons.controller.ts | 51 +++++++++++++++++ src/identity/permissions.ts | 3 + test/checkout-policy.spec.ts | 94 +++++++++++++++++++++++++++++++ 8 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 src/checkout/money.ts create mode 100644 src/coupons/coupon-policy.ts create mode 100644 src/coupons/coupon.schema.ts create mode 100644 src/coupons/coupon.store.ts create mode 100644 src/coupons/coupons.controller.ts create mode 100644 test/checkout-policy.spec.ts diff --git a/src/checkout/checkout.module.ts b/src/checkout/checkout.module.ts index 362d6c3..33e9db0 100644 --- a/src/checkout/checkout.module.ts +++ b/src/checkout/checkout.module.ts @@ -3,9 +3,11 @@ import { DatabaseModule } from '../database/database.module'; import { IdentityModule } from '../identity/identity.module'; import { CartStore } from './cart.store'; import { CartController } from './cart.controller'; +import { CouponStore } from '../coupons/coupon.store'; +import { CouponsController } from '../coupons/coupons.controller'; @Module({ imports: [DatabaseModule, IdentityModule], - controllers: [CartController], - providers: [CartStore], + controllers: [CartController, CouponsController], + providers: [CartStore, CouponStore], }) export class CheckoutModule {} diff --git a/src/checkout/money.ts b/src/checkout/money.ts new file mode 100644 index 0000000..9ab6a1e --- /dev/null +++ b/src/checkout/money.ts @@ -0,0 +1,26 @@ +import { AppError } from '../common/errors/app-error'; + +export function minor(value: string): bigint { + if (!/^\d+(\.\d{1,2})?$/.test(value)) throw new AppError('MONEY_RANGE'); + const [whole, fraction = ''] = value.split('.'); + return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')); +} +export function decimal(value: bigint): string { + if (value < 0n || value > 9999999999999999n) + throw new AppError('MONEY_RANGE'); + return `${value / 100n}.${(value % 100n).toString().padStart(2, '0')}`; +} +export function discountFor( + subtotal: bigint, + coupon: { + kind: 'FIXED' | 'PERCENT'; + amount: string | null; + percentBps: number | null; + }, +): bigint { + const amount = + coupon.kind === 'FIXED' + ? minor(coupon.amount!) + : (subtotal * BigInt(coupon.percentBps!) + 5000n) / 10000n; + return amount > subtotal ? subtotal : amount; +} diff --git a/src/coupons/coupon-policy.ts b/src/coupons/coupon-policy.ts new file mode 100644 index 0000000..e6d37e6 --- /dev/null +++ b/src/coupons/coupon-policy.ts @@ -0,0 +1,59 @@ +import type { Coupon, Prisma } from '../generated/prisma/client'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { minor, discountFor } from '../checkout/money'; + +export function assertCoupon( + coupon: Coupon | null, + currency: string, + subtotal: bigint, + now: Date, + uses: number, + userUses: number, +): asserts coupon is Coupon { + if (!coupon) throw new AppError('COUPON_INELIGIBLE', 'COUPON_UNKNOWN'); + const failures: [boolean, string][] = [ + [!coupon.active, 'COUPON_DISABLED'], + [coupon.currency !== currency, 'COUPON_CURRENCY'], + [coupon.startsAt > now, 'COUPON_NOT_STARTED'], + [coupon.endsAt <= now, 'COUPON_EXPIRED'], + [minor(coupon.minimumSubtotal.toString()) > subtotal, 'COUPON_MINIMUM'], + [uses >= coupon.maxUses, 'COUPON_TOTAL_LIMIT'], + [userUses >= coupon.perUserLimit, 'COUPON_USER_LIMIT'], + ]; + const failure = failures.find(([failed]) => failed); + if (failure) throw new AppError('COUPON_INELIGIBLE', failure[1]); +} +export async function priceCoupon( + tx: Prisma.TransactionClient, + actor: Principal, + code: string | undefined, + currency: string, + subtotal: bigint, + now: Date, +) { + if (!code) return { discount: 0n, coupon: null }; + const coupon = await tx.coupon.findUnique({ + where: { + organizationId_code: { organizationId: actor.organizationId, code }, + }, + }); + const where = { + couponId: coupon?.id ?? '00000000-0000-0000-0000-000000000000', + status: 'PENDING_PAYMENT' as const, + expiresAt: { gt: now }, + }; + const uses = await tx.order.count({ where }); + const userUses = await tx.order.count({ + where: { ...where, userId: actor.userId }, + }); + assertCoupon(coupon, currency, subtotal, now, uses, userUses); + return { + coupon, + discount: discountFor(subtotal, { + kind: coupon.kind, + amount: coupon.amount?.toString() ?? null, + percentBps: coupon.percentBps, + }), + }; +} diff --git a/src/coupons/coupon.schema.ts b/src/coupons/coupon.schema.ts new file mode 100644 index 0000000..52164f8 --- /dev/null +++ b/src/coupons/coupon.schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { CURRENCIES } from '../common/currency'; +import { couponCode } from '../checkout/checkout.schemas'; +const amount = z.string().regex(/^(0|[1-9]\d{0,9})\.\d{2}$/); +const common = z.object({ + code: couponCode, + currency: z.enum(CURRENCIES), + minimumSubtotal: amount.default('0.00'), + maxUses: z.number().int().min(1).max(1000000), + perUserLimit: z.number().int().min(1).max(100), + startsAt: z.iso + .datetime({ offset: true }) + .transform((value) => new Date(value)), + endsAt: z.iso + .datetime({ offset: true }) + .transform((value) => new Date(value)), +}); +export const couponSchema = z + .discriminatedUnion('kind', [ + common + .extend({ + kind: z.literal('FIXED'), + amount: amount.refine((value) => value !== '0.00'), + }) + .strict(), + common + .extend({ + kind: z.literal('PERCENT'), + percentBps: z.number().int().min(1).max(10000), + }) + .strict(), + ]) + .refine((value) => value.endsAt > value.startsAt, { path: ['endsAt'] }) + .refine((value) => value.perUserLimit <= value.maxUses, { + path: ['perUserLimit'], + }); +export const couponStatusSchema = z.object({ active: z.boolean() }).strict(); +export type CouponInput = z.infer; diff --git a/src/coupons/coupon.store.ts b/src/coupons/coupon.store.ts new file mode 100644 index 0000000..74d33d3 --- /dev/null +++ b/src/coupons/coupon.store.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import type { Principal } from '../identity/identity.types'; +import { recordAudit } from '../identity/audit'; +import { AppError } from '../common/errors/app-error'; +import type { CouponInput } from './coupon.schema'; + +@Injectable() +export class CouponStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list(actor: Principal, page: { limit: number; offset: number }) { + return this.db.coupon.findMany({ + where: { organizationId: actor.organizationId }, + take: page.limit, + skip: page.offset, + orderBy: { id: 'asc' }, + }); + } + create(actor: Principal, input: CouponInput) { + return this.access.mutate(actor, 'coupons.manage', async (tx) => { + const coupon = await tx.coupon.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'coupon.created', + coupon.id, + ); + return coupon; + }); + } + status(actor: Principal, id: string, active: boolean) { + return this.access.mutate(actor, 'coupons.manage', async (tx) => { + const coupon = await tx.coupon.findFirst({ + where: { id, organizationId: actor.organizationId }, + }); + if (!coupon) throw new AppError('COUPON_NOT_FOUND'); + const row = await tx.coupon.update({ where: { id }, data: { active } }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'coupon.status.changed', + id, + ); + return row; + }); + } +} diff --git a/src/coupons/coupons.controller.ts b/src/coupons/coupons.controller.ts new file mode 100644 index 0000000..c588e52 --- /dev/null +++ b/src/coupons/coupons.controller.ts @@ -0,0 +1,51 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { SchemaPipe } from '../common/validation.pipe'; +import { pageSchema } from '../identity/identity.schemas'; +import { + couponSchema, + couponStatusSchema, + type CouponInput, +} from './coupon.schema'; +import { CouponStore } from './coupon.store'; + +@Controller('coupons') +@RequirePermission('coupons.manage') +export class CouponsController { + constructor(private readonly coupons: CouponStore) {} + @Get() + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: { limit: number; offset: number }, + ) { + return this.coupons.list(actor, page); + } + @Post() + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(couponSchema)) input: CouponInput, + ) { + return this.coupons.create(actor, input); + } + @Patch(':id/status') + status( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(couponStatusSchema)) input: { active: boolean }, + ) { + return this.coupons.status(actor, id, input.active); + } +} diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts index e52b281..febcbd0 100644 --- a/src/identity/permissions.ts +++ b/src/identity/permissions.ts @@ -1,4 +1,7 @@ export const PERMISSIONS = [ + 'coupons.manage', + 'orders.read', + 'orders.manage', 'users.read', 'users.create', 'users.approve', diff --git a/test/checkout-policy.spec.ts b/test/checkout-policy.spec.ts new file mode 100644 index 0000000..f018fa9 --- /dev/null +++ b/test/checkout-policy.spec.ts @@ -0,0 +1,94 @@ +import { decimal, minor, discountFor } from '../src/checkout/money'; +import { couponSchema } from '../src/coupons/coupon.schema'; +import { + cartLineSchema, + checkoutSchema, +} from '../src/checkout/checkout.schemas'; +import { assertCoupon } from '../src/coupons/coupon-policy'; +import { Prisma, type Coupon } from '../src/generated/prisma/client'; +import { couponInput } from './helpers/checkout'; + +describe('checkout money and eligibility policies', () => { + it('uses exact minor units and round-half-up percentage discounts', () => { + expect(minor('0.10') + minor('0.20')).toBe(30n); + expect(minor('499')).toBe(49900n); + expect(minor('1.5')).toBe(150n); + expect(decimal(1n)).toBe('0.01'); + expect( + discountFor(101n, { kind: 'PERCENT', percentBps: 5000, amount: null }), + ).toBe(51n); + expect( + discountFor(100n, { kind: 'FIXED', amount: '5.00', percentBps: null }), + ).toBe(100n); + expect( + discountFor(1000n, { kind: 'FIXED', amount: '1.50', percentBps: null }), + ).toBe(150n); + expect(decimal(9999999999999999n)).toBe('99999999999999.99'); + for (const value of ['-1', '0.001', 'NaN', '1e3']) + expect(() => minor(value)).toThrow(); + expect(() => decimal(-1n)).toThrow(); + expect(() => decimal(10000000000000000n)).toThrow(); + }); + it('validates coupon dates, currency, amount, quotas and mutually exclusive rules', () => { + const input = couponInput(); + expect(couponSchema.parse(input).code).toBe(input.code.toUpperCase()); + for (const change of [ + { endsAt: input.startsAt }, + { perUserLimit: 11 }, + { percentBps: 10001 }, + { amount: '2.00' }, + { currency: 'XXX' }, + { code: '