feat(coupons): add exact discounts and scoped eligibility rules
This commit is contained in:
parent
9ac40531b1
commit
aacfe6c171
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
export const PERMISSIONS = [
|
||||
'coupons.manage',
|
||||
'orders.read',
|
||||
'orders.manage',
|
||||
'users.read',
|
||||
'users.create',
|
||||
'users.approve',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue