feat/commerce-operations #4
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue