feat/commerce-operations #4
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'>) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
export const PERMISSIONS = [
|
||||
'pricing.manage',
|
||||
'operations.read',
|
||||
'notifications.retry',
|
||||
'coupons.manage',
|
||||
'orders.read',
|
||||
'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