feat(pricing): snapshot configurable tax and shipping policies at checkout

This commit is contained in:
mihir 2026-09-11 16:59:57 +05:30
parent dcda859fb8
commit 5e5f6c1faa
14 changed files with 515 additions and 10 deletions

View File

@ -1,4 +1,5 @@
import { CheckoutModule } from './checkout/checkout.module'; import { CheckoutModule } from './checkout/checkout.module';
import { OperationsModule } from './operations/operations.module';
import { CatalogModule } from './catalog/catalog.module'; import { CatalogModule } from './catalog/catalog.module';
import { AddressesModule } from './addresses/addresses.module'; import { AddressesModule } from './addresses/addresses.module';
import { InventoryModule } from './inventory/inventory.module'; import { InventoryModule } from './inventory/inventory.module';
@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module';
AddressesModule, AddressesModule,
InventoryModule, InventoryModule,
CheckoutModule, CheckoutModule,
OperationsModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -8,6 +8,7 @@ import type { CheckoutInput } from './checkout.schemas';
import { checkoutSnapshot } from './checkout-snapshot'; import { checkoutSnapshot } from './checkout-snapshot';
import { holdOrderStock } from './stock-allocation'; import { holdOrderStock } from './stock-allocation';
import { orderView } from './order-view'; import { orderView } from './order-view';
import { snapshotPrice } from '../pricing/snapshot-price';
@Injectable() @Injectable()
export class CheckoutStore { export class CheckoutStore {
@ -29,7 +30,7 @@ export class CheckoutStore {
idempotencyKey: input.idempotencyKey, idempotencyKey: input.idempotencyKey,
}, },
}, },
include: { lines: true }, include: { lines: true, pricing: true },
}); });
if (previous) { if (previous) {
assertReplay(previous.requestHash, requestHash); assertReplay(previous.requestHash, requestHash);
@ -66,6 +67,7 @@ export class CheckoutStore {
include: { lines: true }, include: { lines: true },
}); });
await holdOrderStock(tx, actor, order.id, expiresAt, lines); await holdOrderStock(tx, actor, order.id, expiresAt, lines);
const pricing = await snapshotPrice(tx, order);
await tx.cartLine.deleteMany({ where: { cartId } }); await tx.cartLine.deleteMany({ where: { cartId } });
await tx.cart.update({ await tx.cart.update({
where: { id: cartId }, where: { id: cartId },
@ -78,7 +80,7 @@ export class CheckoutStore {
'order.created', 'order.created',
order.id, order.id,
); );
return orderView(order); return orderView({ ...order, pricing });
}); });
} }
} }

View File

@ -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<Order, 'status' | 'expiresAt'>) { export function orderStatus(order: Pick<Order, 'status' | 'expiresAt'>) {
return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date() return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date()
? 'EXPIRED' ? 'EXPIRED'
: order.status; : order.status;
} }
export function orderView(order: Order & { lines: OrderLine[] }) { export function orderView(
order: Order & { lines: OrderLine[]; pricing?: OrderPricing | null },
) {
return { return {
id: order.id, id: order.id,
status: orderStatus(order), status: orderStatus(order),
@ -12,10 +18,18 @@ export function orderView(order: Order & { lines: OrderLine[] }) {
subtotal: order.subtotal, subtotal: order.subtotal,
discount: order.discount, discount: order.discount,
merchandiseTotal: order.merchandiseTotal, merchandiseTotal: order.merchandiseTotal,
pricingStatus: 'UNFINALIZED', pricingStatus: order.pricing ? 'FINALIZED' : 'UNFINALIZED',
taxTotal: null, taxTotal: order.pricing?.taxTotal ?? null,
shippingTotal: null, shippingTotal: order.pricing?.shippingNet ?? null,
payableTotal: 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, paymentAvailable: false,
address: order.addressSnapshot, address: order.addressSnapshot,
coupon: order.couponSnapshot, coupon: order.couponSnapshot,

View File

@ -50,7 +50,7 @@ export class OrderStore {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...(!staff ? { userId: actor.userId } : {}), ...(!staff ? { userId: actor.userId } : {}),
}, },
include: { lines: { orderBy: { variantId: 'asc' } } }, include: { lines: { orderBy: { variantId: 'asc' } }, pricing: true },
}); });
if (!order) throw new AppError('ORDER_NOT_FOUND'); if (!order) throw new AppError('ORDER_NOT_FOUND');
return orderView(order); return orderView(order);
@ -68,6 +68,7 @@ export class OrderStore {
}, },
include: { include: {
lines: true, lines: true,
pricing: true,
reservations: { orderBy: { stockItemId: 'asc' } }, reservations: { orderBy: { stockItemId: 'asc' } },
}, },
}); });
@ -82,7 +83,7 @@ export class OrderStore {
const updated = await tx.order.update({ const updated = await tx.order.update({
where: { id }, where: { id },
data: { status: 'CANCELLED' }, data: { status: 'CANCELLED' },
include: { lines: true }, include: { lines: true, pricing: true },
}); });
await recordAudit( await recordAudit(
tx, tx,

View File

@ -1,4 +1,7 @@
export const PERMISSIONS = [ export const PERMISSIONS = [
'pricing.manage',
'operations.read',
'notifications.retry',
'coupons.manage', 'coupons.manage',
'orders.read', 'orders.read',
'orders.manage', 'orders.manage',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

35
test/helpers/pricing.ts Normal file
View File

@ -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<string, unknown> = {},
) {
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;
}

View File

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

131
test/pricing.spec.ts Normal file
View File

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