feat/checkout-orders #3
|
|
@ -3,9 +3,11 @@ import { DatabaseModule } from '../database/database.module';
|
||||||
import { IdentityModule } from '../identity/identity.module';
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
import { CartStore } from './cart.store';
|
import { CartStore } from './cart.store';
|
||||||
import { CartController } from './cart.controller';
|
import { CartController } from './cart.controller';
|
||||||
|
import { CouponStore } from '../coupons/coupon.store';
|
||||||
|
import { CouponsController } from '../coupons/coupons.controller';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, IdentityModule],
|
imports: [DatabaseModule, IdentityModule],
|
||||||
controllers: [CartController],
|
controllers: [CartController, CouponsController],
|
||||||
providers: [CartStore],
|
providers: [CartStore, CouponStore],
|
||||||
})
|
})
|
||||||
export class CheckoutModule {}
|
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 = [
|
export const PERMISSIONS = [
|
||||||
|
'coupons.manage',
|
||||||
|
'orders.read',
|
||||||
|
'orders.manage',
|
||||||
'users.read',
|
'users.read',
|
||||||
'users.create',
|
'users.create',
|
||||||
'users.approve',
|
'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