feat(coupons): add exact discounts and scoped eligibility rules

This commit is contained in:
mihir 2026-09-11 00:28:45 +05:30
parent 9ac40531b1
commit aacfe6c171
8 changed files with 330 additions and 2 deletions

View File

@ -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 {}

26
src/checkout/money.ts Normal file
View File

@ -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;
}

View File

@ -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,
}),
};
}

View File

@ -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<typeof couponSchema>;

View File

@ -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;
});
}
}

View File

@ -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);
}
}

View File

@ -1,4 +1,7 @@
export const PERMISSIONS = [
'coupons.manage',
'orders.read',
'orders.manage',
'users.read',
'users.create',
'users.approve',

View File

@ -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: '<script>' },
])
expect(couponSchema.safeParse({ ...input, ...change }).success).toBe(
false,
);
const { percentBps: _percent, ...fixed } = input;
expect(
couponSchema.safeParse({ ...fixed, kind: 'FIXED', amount: '2.00' })
.success,
).toBe(true);
expect(
couponSchema.safeParse({ ...fixed, kind: 'FIXED', amount: '0.00' })
.success,
).toBe(false);
});
it('rejects every coupon eligibility boundary', () => {
const now = new Date();
const coupon = {
...couponInput(),
id: 'id',
organizationId: 'org',
kind: 'PERCENT',
startsAt: new Date(now.getTime() - 1000),
endsAt: new Date(now.getTime() + 1000),
active: true,
amount: null,
minimumSubtotal: new Prisma.Decimal(100),
} as Coupon;
expect(() => assertCoupon(coupon, 'INR', 10000n, now, 0, 0)).not.toThrow();
for (const invalid of [
null,
{ ...coupon, active: false },
{ ...coupon, currency: 'USD' },
{ ...coupon, startsAt: new Date(now.getTime() + 1) },
{ ...coupon, endsAt: now },
])
expect(() => assertCoupon(invalid, 'INR', 10000n, now, 0, 0)).toThrow();
expect(() => assertCoupon(coupon, 'INR', 9999n, now, 0, 0)).toThrow();
expect(() => assertCoupon(coupon, 'INR', 10000n, now, 10, 0)).toThrow();
expect(() => assertCoupon(coupon, 'INR', 10000n, now, 0, 1)).toThrow();
});
it('bounds carts and rejects client totals and unknown fields', () => {
for (const quantity of [0, -1, 101, 0.5])
expect(cartLineSchema.safeParse({ quantity, version: 0 }).success).toBe(
false,
);
expect(cartLineSchema.safeParse({ quantity: 1, version: -1 }).success).toBe(
false,
);
expect(
checkoutSchema.safeParse({ cartVersion: 0, total: '0.00' }).success,
).toBe(false);
});
});