diff --git a/src/app.module.ts b/src/app.module.ts index 8710228..80aa6cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { CheckoutModule } from './checkout/checkout.module'; +import { OperationsModule } from './operations/operations.module'; import { CatalogModule } from './catalog/catalog.module'; import { AddressesModule } from './addresses/addresses.module'; import { InventoryModule } from './inventory/inventory.module'; @@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module'; AddressesModule, InventoryModule, CheckoutModule, + OperationsModule, ], }) export class AppModule {} diff --git a/src/checkout/checkout.store.ts b/src/checkout/checkout.store.ts index 90593fb..4890caf 100644 --- a/src/checkout/checkout.store.ts +++ b/src/checkout/checkout.store.ts @@ -8,6 +8,7 @@ import type { CheckoutInput } from './checkout.schemas'; import { checkoutSnapshot } from './checkout-snapshot'; import { holdOrderStock } from './stock-allocation'; import { orderView } from './order-view'; +import { snapshotPrice } from '../pricing/snapshot-price'; @Injectable() export class CheckoutStore { @@ -29,7 +30,7 @@ export class CheckoutStore { idempotencyKey: input.idempotencyKey, }, }, - include: { lines: true }, + include: { lines: true, pricing: true }, }); if (previous) { assertReplay(previous.requestHash, requestHash); @@ -66,6 +67,7 @@ export class CheckoutStore { include: { lines: true }, }); await holdOrderStock(tx, actor, order.id, expiresAt, lines); + const pricing = await snapshotPrice(tx, order); await tx.cartLine.deleteMany({ where: { cartId } }); await tx.cart.update({ where: { id: cartId }, @@ -78,7 +80,7 @@ export class CheckoutStore { 'order.created', order.id, ); - return orderView(order); + return orderView({ ...order, pricing }); }); } } diff --git a/src/checkout/order-view.ts b/src/checkout/order-view.ts index 5ed7d3e..73d451f 100644 --- a/src/checkout/order-view.ts +++ b/src/checkout/order-view.ts @@ -1,10 +1,16 @@ -import type { Order, OrderLine } from '../generated/prisma/client'; +import type { + Order, + OrderLine, + OrderPricing, +} from '../generated/prisma/client'; export function orderStatus(order: Pick) { return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date() ? 'EXPIRED' : order.status; } -export function orderView(order: Order & { lines: OrderLine[] }) { +export function orderView( + order: Order & { lines: OrderLine[]; pricing?: OrderPricing | null }, +) { return { id: order.id, status: orderStatus(order), @@ -12,10 +18,18 @@ export function orderView(order: Order & { lines: OrderLine[] }) { subtotal: order.subtotal, discount: order.discount, merchandiseTotal: order.merchandiseTotal, - pricingStatus: 'UNFINALIZED', - taxTotal: null, - shippingTotal: null, - payableTotal: null, + pricingStatus: order.pricing ? 'FINALIZED' : 'UNFINALIZED', + taxTotal: order.pricing?.taxTotal ?? null, + shippingTotal: order.pricing?.shippingNet ?? null, + payableTotal: order.pricing?.payableTotal ?? null, + pricing: order.pricing + ? { + policyId: order.pricing.policyId, + policy: order.pricing.policySnapshot, + merchandiseTax: order.pricing.merchandiseTax, + shippingTax: order.pricing.shippingTax, + } + : null, paymentAvailable: false, address: order.addressSnapshot, coupon: order.couponSnapshot, diff --git a/src/checkout/order.store.ts b/src/checkout/order.store.ts index d880975..48e7cec 100644 --- a/src/checkout/order.store.ts +++ b/src/checkout/order.store.ts @@ -50,7 +50,7 @@ export class OrderStore { organizationId: actor.organizationId, ...(!staff ? { userId: actor.userId } : {}), }, - include: { lines: { orderBy: { variantId: 'asc' } } }, + include: { lines: { orderBy: { variantId: 'asc' } }, pricing: true }, }); if (!order) throw new AppError('ORDER_NOT_FOUND'); return orderView(order); @@ -68,6 +68,7 @@ export class OrderStore { }, include: { lines: true, + pricing: true, reservations: { orderBy: { stockItemId: 'asc' } }, }, }); @@ -82,7 +83,7 @@ export class OrderStore { const updated = await tx.order.update({ where: { id }, data: { status: 'CANCELLED' }, - include: { lines: true }, + include: { lines: true, pricing: true }, }); await recordAudit( tx, diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts index febcbd0..62a63c1 100644 --- a/src/identity/permissions.ts +++ b/src/identity/permissions.ts @@ -1,4 +1,7 @@ export const PERMISSIONS = [ + 'pricing.manage', + 'operations.read', + 'notifications.retry', 'coupons.manage', 'orders.read', 'orders.manage', diff --git a/src/operations/operations.module.ts b/src/operations/operations.module.ts new file mode 100644 index 0000000..9638100 --- /dev/null +++ b/src/operations/operations.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { IdentityModule } from '../identity/identity.module'; +import { PricingController } from '../pricing/pricing.controller'; +import { PricingStore } from '../pricing/pricing.store'; +@Module({ + imports: [DatabaseModule, IdentityModule], + controllers: [PricingController], + providers: [PricingStore], +}) +export class OperationsModule {} diff --git a/src/pricing/pricing.controller.ts b/src/pricing/pricing.controller.ts new file mode 100644 index 0000000..c650042 --- /dev/null +++ b/src/pricing/pricing.controller.ts @@ -0,0 +1,50 @@ +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, type PageInput } from '../identity/identity.schemas'; +import { + pricingSchema, + pricingStatusSchema, + type PricingInput, +} from './pricing.schema'; +import { PricingStore } from './pricing.store'; +@Controller('admin/pricing-policies') +@RequirePermission('pricing.manage') +export class PricingController { + constructor(private readonly pricing: PricingStore) {} + @Get() + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.pricing.list(actor, page); + } + @Post() + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(pricingSchema)) input: PricingInput, + ) { + return this.pricing.create(actor, input); + } + @Patch(':id/status') + status( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(pricingStatusSchema)) input: { active: boolean }, + ) { + return this.pricing.status(actor, id, input.active); + } +} diff --git a/src/pricing/pricing.policy.ts b/src/pricing/pricing.policy.ts new file mode 100644 index 0000000..3b1ba80 --- /dev/null +++ b/src/pricing/pricing.policy.ts @@ -0,0 +1,42 @@ +import { decimal, minor } from '../checkout/money'; +export type PriceRule = { + taxMode: 'INCLUSIVE' | 'EXCLUSIVE'; + merchandiseTaxBps: number; + shippingTaxBps: number; + shippingFee: string; + freeShippingMinimum: string | null; +}; +function roundRatio(amount: bigint, numerator: number, denominator: number) { + return ( + (amount * BigInt(numerator) + BigInt(denominator) / 2n) / + BigInt(denominator) + ); +} +export function calculatePrice(merchandiseTotal: string, rule: PriceRule) { + const merchandise = minor(merchandiseTotal); + const taxDivisor = + rule.taxMode === 'INCLUSIVE' ? 10000 + rule.merchandiseTaxBps : 10000; + const merchandiseTax = roundRatio( + merchandise, + rule.merchandiseTaxBps, + taxDivisor, + ); + const shippingNet = + rule.freeShippingMinimum !== null && + merchandise >= minor(rule.freeShippingMinimum) + ? 0n + : minor(rule.shippingFee); + const shippingTax = roundRatio(shippingNet, rule.shippingTaxBps, 10000); + const payable = + merchandise + + shippingNet + + shippingTax + + (rule.taxMode === 'EXCLUSIVE' ? merchandiseTax : 0n); + return { + merchandiseTax: decimal(merchandiseTax), + shippingNet: decimal(shippingNet), + shippingTax: decimal(shippingTax), + taxTotal: decimal(merchandiseTax + shippingTax), + payableTotal: decimal(payable), + }; +} diff --git a/src/pricing/pricing.schema.ts b/src/pricing/pricing.schema.ts new file mode 100644 index 0000000..dbf98ac --- /dev/null +++ b/src/pricing/pricing.schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { text } from '../common/input'; +import { CURRENCIES } from '../common/currency'; +const money = z.string().regex(/^(0|[1-9]\d{0,9})\.\d{2}$/); +export const pricingSchema = z + .object({ + name: text(100), + currency: z.enum(CURRENCIES), + countryCode: z + .string() + .trim() + .toUpperCase() + .regex(/^[A-Z]{2}$/), + region: text(100, 0) + .transform((value) => value.toUpperCase()) + .default(''), + taxMode: z.enum(['INCLUSIVE', 'EXCLUSIVE']), + merchandiseTaxBps: z.number().int().min(0).max(10000), + shippingFee: money, + shippingTaxBps: z.number().int().min(0).max(10000), + freeShippingMinimum: money.nullable().default(null), + }) + .strict(); +export const pricingStatusSchema = z.object({ active: z.boolean() }).strict(); +export type PricingInput = z.infer; diff --git a/src/pricing/pricing.store.ts b/src/pricing/pricing.store.ts new file mode 100644 index 0000000..ffc5c42 --- /dev/null +++ b/src/pricing/pricing.store.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { AccessStore } from '../identity/access.store'; +import { DatabaseService } from '../database/database.service'; +import type { Principal } from '../identity/identity.types'; +import { recordAudit } from '../identity/audit'; +import { AppError } from '../common/errors/app-error'; +import type { PageInput } from '../identity/identity.schemas'; +import type { PricingInput } from './pricing.schema'; +@Injectable() +export class PricingStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list(actor: Principal, page: PageInput) { + return this.db.pricingPolicy.findMany({ + where: { organizationId: actor.organizationId }, + take: page.limit, + skip: page.offset, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + } + create(actor: Principal, input: PricingInput) { + return this.access.mutate(actor, 'pricing.manage', async (tx) => { + const policy = await tx.pricingPolicy.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'pricing.created', + policy.id, + ); + return policy; + }); + } + status(actor: Principal, id: string, active: boolean) { + return this.access.mutate(actor, 'pricing.manage', async (tx) => { + const row = await tx.pricingPolicy.findFirst({ + where: { id, organizationId: actor.organizationId }, + }); + if (!row) throw new AppError('PRICING_POLICY_NOT_FOUND'); + if (active) + await tx.pricingPolicy.updateMany({ + where: { + organizationId: actor.organizationId, + currency: row.currency, + countryCode: row.countryCode, + region: row.region, + active: true, + }, + data: { active: false }, + }); + const updated = await tx.pricingPolicy.update({ + where: { id }, + data: { active }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'pricing.status.changed', + id, + ); + return updated; + }); + } +} diff --git a/src/pricing/snapshot-price.ts b/src/pricing/snapshot-price.ts new file mode 100644 index 0000000..f09cda4 --- /dev/null +++ b/src/pricing/snapshot-price.ts @@ -0,0 +1,45 @@ +import type { Order, Prisma } from '../generated/prisma/client'; +import { calculatePrice } from './pricing.policy'; + +export async function snapshotPrice( + tx: Prisma.TransactionClient, + order: Order, +) { + const address = order.addressSnapshot as { + countryCode: string; + region: string; + }; + const policy = await tx.pricingPolicy.findFirst({ + where: { + organizationId: order.organizationId, + currency: order.currency, + active: true, + countryCode: address.countryCode, + region: { in: ['', address.region.toUpperCase()] }, + }, + orderBy: { region: 'desc' }, + }); + if (!policy) return null; + const rule = { + taxMode: policy.taxMode, + merchandiseTaxBps: policy.merchandiseTaxBps, + shippingFee: policy.shippingFee.toString(), + shippingTaxBps: policy.shippingTaxBps, + freeShippingMinimum: policy.freeShippingMinimum?.toString() ?? null, + }; + return tx.orderPricing.create({ + data: { + orderId: order.id, + organizationId: order.organizationId, + policyId: policy.id, + policySnapshot: { + ...rule, + name: policy.name, + countryCode: policy.countryCode, + region: policy.region, + currency: policy.currency, + }, + ...calculatePrice(order.merchandiseTotal.toString(), rule), + }, + }); +} diff --git a/test/helpers/pricing.ts b/test/helpers/pricing.ts new file mode 100644 index 0000000..7f7e838 --- /dev/null +++ b/test/helpers/pricing.ts @@ -0,0 +1,35 @@ +import { randomUUID } from 'node:crypto'; +import type { IdentityApp } from './identity-app'; +export function pricingInput() { + // Synthetic test rules; these are not Mani Candles tax or shipping settings. + return { + name: 'Test-' + randomUUID(), + currency: 'INR', + countryCode: 'IN', + region: '', + taxMode: 'EXCLUSIVE', + merchandiseTaxBps: 1800, + shippingFee: '50.00', + shippingTaxBps: 1800, + freeShippingMinimum: null, + }; +} +export async function activatePricing( + ctx: IdentityApp, + changes: Record = {}, +) { + const policy = await ctx + .api() + .post('/api/v1/admin/pricing-policies') + .auth(ctx.token, { type: 'bearer' }) + .send({ ...pricingInput(), ...changes }) + .expect(201); + expect(policy.body.active).toBe(false); + await ctx + .api() + .patch('/api/v1/admin/pricing-policies/' + policy.body.id + '/status') + .auth(ctx.token, { type: 'bearer' }) + .send({ active: true }) + .expect(200); + return policy.body; +} diff --git a/test/pricing-policy.spec.ts b/test/pricing-policy.spec.ts new file mode 100644 index 0000000..bdf0e48 --- /dev/null +++ b/test/pricing-policy.spec.ts @@ -0,0 +1,75 @@ +import { calculatePrice, type PriceRule } from '../src/pricing/pricing.policy'; +import { pricingSchema } from '../src/pricing/pricing.schema'; +const rule: PriceRule = { + taxMode: 'EXCLUSIVE', + merchandiseTaxBps: 1800, + shippingFee: '50.00', + shippingTaxBps: 1800, + freeShippingMinimum: null, +}; +describe('configurable pricing calculations', () => { + it('calculates exclusive merchandise tax and shipping tax exactly', () => { + expect(calculatePrice('100.00', rule)).toEqual({ + merchandiseTax: '18.00', + shippingNet: '50.00', + shippingTax: '9.00', + taxTotal: '27.00', + payableTotal: '177.00', + }); + }); + it('extracts inclusive tax without adding it twice and handles odd basis points', () => { + expect( + calculatePrice('118.00', { ...rule, taxMode: 'INCLUSIVE' }), + ).toMatchObject({ merchandiseTax: '18.00', payableTotal: '177.00' }); + expect( + calculatePrice('100.01', { + ...rule, + taxMode: 'INCLUSIVE', + merchandiseTaxBps: 1, + }), + ).toMatchObject({ merchandiseTax: '0.01' }); + }); + it('applies free shipping at the discounted merchandise threshold and rounds small values', () => { + const free = { ...rule, freeShippingMinimum: '100.00' }; + expect(calculatePrice('100.00', free)).toMatchObject({ + shippingNet: '0.00', + shippingTax: '0.00', + payableTotal: '118.00', + }); + expect(calculatePrice('99.99', free).shippingNet).toBe('50.00'); + expect( + calculatePrice('0.01', { + ...rule, + merchandiseTaxBps: 5000, + shippingFee: '0.00', + }).merchandiseTax, + ).toBe('0.01'); + expect( + calculatePrice('0', { ...free, freeShippingMinimum: '0.00' }) + .payableTotal, + ).toBe('0.00'); + }); + it('requires explicit bounded rules and rejects active or unknown fields on creation', () => { + const input = { + ...rule, + name: 'Test policy', + currency: 'INR', + countryCode: 'in', + }; + expect(pricingSchema.parse(input)).toMatchObject({ + countryCode: 'IN', + region: '', + }); + for (const change of [ + { merchandiseTaxBps: -1 }, + { shippingTaxBps: 10001 }, + { shippingFee: '-1.00' }, + { active: true }, + { countryCode: 'IND' }, + { currency: 'XXX' }, + ]) + expect(pricingSchema.safeParse({ ...input, ...change }).success).toBe( + false, + ); + }); +}); diff --git a/test/pricing.spec.ts b/test/pricing.spec.ts new file mode 100644 index 0000000..ec08686 --- /dev/null +++ b/test/pricing.spec.ts @@ -0,0 +1,131 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { checkoutFixture } from './helpers/checkout'; +import { activatePricing, pricingInput } from './helpers/pricing'; +import { secondActor } from './helpers/commerce'; +describe('versioned pricing snapshots', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + await ctx.db.pricingPolicy.updateMany({ data: { active: false } }); + }); + it('finalizes configured totals and preserves the exact policy on retries and reads', async () => { + const policy = await activatePricing(ctx); + const f = await checkoutFixture(ctx); + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + expect(order.body).toMatchObject({ + pricingStatus: 'FINALIZED', + taxTotal: '188.64', + shippingTotal: '50', + payableTotal: '1236.64', + paymentAvailable: false, + pricing: { policyId: policy.id }, + }); + await activatePricing(ctx, { merchandiseTaxBps: 0, shippingFee: '0.00' }); + const replay = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + expect(replay.body).toEqual(order.body); + const detail = await ctx + .api() + .get('/api/v1/orders/' + order.body.id) + .auth(f.actor.token, { type: 'bearer' }) + .expect(200); + expect(detail.body).toEqual(order.body); + await expect( + ctx.executeSql( + `UPDATE order_pricing SET payable_total = 0 WHERE order_id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + await expect( + ctx.executeSql( + `DELETE FROM order_pricing WHERE order_id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + await expect( + ctx.executeSql( + `UPDATE pricing_policies SET shipping_fee = 0 WHERE id = '${policy.id}'`, + ), + ).rejects.toThrow(); + }); + it('prefers a region-specific policy and never reprices earlier unconfigured orders', async () => { + const f = await checkoutFixture(ctx); + const old = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + expect(old.body.pricingStatus).toBe('UNFINALIZED'); + await activatePricing(ctx); + const regional = await activatePricing(ctx, { + region: 'maharashtra', + merchandiseTaxBps: 0, + shippingFee: '0.00', + }); + const next = await checkoutFixture(ctx); + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(next.actor.token, { type: 'bearer' }) + .send(next.input) + .expect(201); + expect(order.body.pricing.policyId).toBe(regional.id); + expect(order.body.payableTotal).toBe('998'); + const unchanged = await ctx + .api() + .get('/api/v1/orders/' + old.body.id) + .auth(f.actor.token, { type: 'bearer' }) + .expect(200); + expect(unchanged.body.pricingStatus).toBe('UNFINALIZED'); + }); + it('enforces permissions and organization scoping for policy administration', async () => { + const customer = await secondActor(ctx); + await ctx + .api() + .post('/api/v1/admin/pricing-policies') + .auth(customer.token, { type: 'bearer' }) + .send(pricingInput()) + .expect(403); + const policy = await activatePricing(ctx); + const other = await secondActor(ctx, false, ['pricing.manage']); + const list = await ctx + .api() + .get('/api/v1/admin/pricing-policies') + .auth(other.token, { type: 'bearer' }) + .expect(200); + expect(list.body).toEqual([]); + await ctx + .api() + .patch('/api/v1/admin/pricing-policies/' + policy.id + '/status') + .auth(other.token, { type: 'bearer' }) + .send({ active: false }) + .expect(404); + await ctx + .api() + .patch('/api/v1/admin/pricing-policies/' + randomUUID() + '/status') + .auth(ctx.token, { type: 'bearer' }) + .send({ active: false }) + .expect(404); + await ctx + .api() + .patch('/api/v1/admin/pricing-policies/' + policy.id + '/status') + .auth(ctx.token, { type: 'bearer' }) + .send({ active: false }) + .expect(200); + }); +});