feat/checkout-orders #3
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { decimal, minor } from './money';
|
||||||
|
import { priceCoupon } from '../coupons/coupon-policy';
|
||||||
|
import type { CheckoutInput } from './checkout.schemas';
|
||||||
|
|
||||||
|
export async function checkoutSnapshot(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
actor: Principal,
|
||||||
|
input: CheckoutInput,
|
||||||
|
) {
|
||||||
|
const cart = await tx.cart.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_organizationId: {
|
||||||
|
userId: actor.userId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
lines: {
|
||||||
|
include: { variant: { include: { product: true } } },
|
||||||
|
orderBy: { variantId: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!cart || cart.lines.length === 0) throw new AppError('CART_EMPTY');
|
||||||
|
if (cart.version !== input.cartVersion) throw new AppError('CART_CHANGED');
|
||||||
|
const address = await tx.address.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.addressId,
|
||||||
|
|
||||||
|
userId: actor.userId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!address) throw new AppError('ADDRESS_NOT_FOUND');
|
||||||
|
const currency = cart.lines[0]!.variant.currency;
|
||||||
|
const lines = cart.lines.map(({ variant, quantity }) => {
|
||||||
|
if (!variant.active || variant.product.status !== 'PUBLISHED')
|
||||||
|
throw new AppError('CART_ITEM_UNAVAILABLE');
|
||||||
|
if (variant.currency !== currency) throw new AppError('CART_CURRENCY');
|
||||||
|
return {
|
||||||
|
variantId: variant.id,
|
||||||
|
sku: variant.sku,
|
||||||
|
productName: variant.product.name,
|
||||||
|
variantName: variant.name,
|
||||||
|
quantity,
|
||||||
|
unitPrice: variant.price,
|
||||||
|
lineTotal: decimal(minor(variant.price.toString()) * BigInt(quantity)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const subtotal = lines.reduce((sum, line) => sum + minor(line.lineTotal), 0n);
|
||||||
|
const { coupon, discount } = await priceCoupon(
|
||||||
|
tx,
|
||||||
|
actor,
|
||||||
|
input.couponCode,
|
||||||
|
currency,
|
||||||
|
subtotal,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cartId: cart.id,
|
||||||
|
lines,
|
||||||
|
currency,
|
||||||
|
subtotal: decimal(subtotal),
|
||||||
|
discount: decimal(discount),
|
||||||
|
merchandiseTotal: decimal(subtotal - discount),
|
||||||
|
couponId: coupon?.id,
|
||||||
|
couponSnapshot: coupon
|
||||||
|
? {
|
||||||
|
code: coupon.code,
|
||||||
|
kind: coupon.kind,
|
||||||
|
amount: coupon.amount?.toString() ?? null,
|
||||||
|
percentBps: coupon.percentBps,
|
||||||
|
minimumSubtotal: coupon.minimumSubtotal.toString(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
addressSnapshot: {
|
||||||
|
recipient: address.recipient,
|
||||||
|
line1: address.line1,
|
||||||
|
line2: address.line2,
|
||||||
|
city: address.city,
|
||||||
|
region: address.region,
|
||||||
|
postalCode: address.postalCode,
|
||||||
|
countryCode: address.countryCode,
|
||||||
|
phone: address.phone,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,21 @@ 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 { CheckoutStore } from './checkout.store';
|
||||||
|
import { OrderStore } from './order.store';
|
||||||
|
import { OrdersController } from './orders.controller';
|
||||||
|
import { OrderAdminController } from './order-admin.controller';
|
||||||
import { CouponStore } from '../coupons/coupon.store';
|
import { CouponStore } from '../coupons/coupon.store';
|
||||||
import { CouponsController } from '../coupons/coupons.controller';
|
import { CouponsController } from '../coupons/coupons.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, IdentityModule],
|
imports: [DatabaseModule, IdentityModule],
|
||||||
controllers: [CartController, CouponsController],
|
controllers: [
|
||||||
providers: [CartStore, CouponStore],
|
CartController,
|
||||||
|
OrdersController,
|
||||||
|
OrderAdminController,
|
||||||
|
CouponsController,
|
||||||
|
],
|
||||||
|
providers: [CartStore, CheckoutStore, OrderStore, CouponStore],
|
||||||
})
|
})
|
||||||
export class CheckoutModule {}
|
export class CheckoutModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
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 { assertReplay, commandHash } from '../inventory/inventory.policy';
|
||||||
|
import type { CheckoutInput } from './checkout.schemas';
|
||||||
|
import { checkoutSnapshot } from './checkout-snapshot';
|
||||||
|
import { holdOrderStock } from './stock-allocation';
|
||||||
|
import { orderView } from './order-view';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CheckoutStore {
|
||||||
|
constructor(private readonly access: AccessStore) {}
|
||||||
|
create(actor: Principal, input: CheckoutInput) {
|
||||||
|
const requestHash = commandHash(
|
||||||
|
input.cartVersion,
|
||||||
|
input.addressId,
|
||||||
|
input.couponCode ?? '',
|
||||||
|
);
|
||||||
|
// The organization lock also serializes catalog, coupon and cart changes.
|
||||||
|
// Stock locks below coordinate with independent inventory transactions.
|
||||||
|
return this.access.mutate(actor, null, async (tx) => {
|
||||||
|
const previous = await tx.order.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_organizationId_idempotencyKey: {
|
||||||
|
userId: actor.userId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
if (previous) {
|
||||||
|
assertReplay(previous.requestHash, requestHash);
|
||||||
|
return orderView(previous);
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
if (
|
||||||
|
(await tx.order.count({
|
||||||
|
where: {
|
||||||
|
userId: actor.userId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
},
|
||||||
|
})) >= 10
|
||||||
|
)
|
||||||
|
throw new AppError('ORDER_LIMIT');
|
||||||
|
const { cartId, lines, ...snapshot } = await checkoutSnapshot(
|
||||||
|
tx,
|
||||||
|
actor,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
const expiresAt = new Date(now.getTime() + 15 * 60000);
|
||||||
|
const order = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
...snapshot,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
userId: actor.userId,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
requestHash,
|
||||||
|
expiresAt,
|
||||||
|
lines: { create: lines },
|
||||||
|
},
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
await holdOrderStock(tx, actor, order.id, expiresAt, lines);
|
||||||
|
await tx.cartLine.deleteMany({ where: { cartId } });
|
||||||
|
await tx.cart.update({
|
||||||
|
where: { id: cartId },
|
||||||
|
data: { version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
'order.created',
|
||||||
|
order.id,
|
||||||
|
);
|
||||||
|
return orderView(order);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
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 { OrderStore } from './order.store';
|
||||||
|
|
||||||
|
@Controller('admin/orders')
|
||||||
|
export class OrderAdminController {
|
||||||
|
constructor(private readonly orders: OrderStore) {}
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('orders.read')
|
||||||
|
list(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Query(new SchemaPipe(pageSchema)) page: PageInput,
|
||||||
|
) {
|
||||||
|
return this.orders.list(actor, page, true);
|
||||||
|
}
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('orders.read')
|
||||||
|
get(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.orders.get(actor, id, true);
|
||||||
|
}
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@RequirePermission('orders.manage')
|
||||||
|
cancel(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.orders.cancel(actor, id, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Order, OrderLine } 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[] }) {
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
status: orderStatus(order),
|
||||||
|
currency: order.currency,
|
||||||
|
subtotal: order.subtotal,
|
||||||
|
discount: order.discount,
|
||||||
|
merchandiseTotal: order.merchandiseTotal,
|
||||||
|
pricingStatus: 'UNFINALIZED',
|
||||||
|
taxTotal: null,
|
||||||
|
shippingTotal: null,
|
||||||
|
payableTotal: null,
|
||||||
|
paymentAvailable: false,
|
||||||
|
address: order.addressSnapshot,
|
||||||
|
coupon: order.couponSnapshot,
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
expiresAt: order.expiresAt,
|
||||||
|
lines: order.lines.map((line) => ({
|
||||||
|
variantId: line.variantId,
|
||||||
|
sku: line.sku,
|
||||||
|
productName: line.productName,
|
||||||
|
variantName: line.variantName,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
lineTotal: line.lineTotal,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
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 { lockStock } from '../inventory/stock-lock';
|
||||||
|
import { orderView, orderStatus } from './order-view';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrderStore {
|
||||||
|
constructor(
|
||||||
|
private readonly db: DatabaseService,
|
||||||
|
private readonly access: AccessStore,
|
||||||
|
) {}
|
||||||
|
list(
|
||||||
|
actor: Principal,
|
||||||
|
page: { limit: number; offset: number },
|
||||||
|
staff = false,
|
||||||
|
) {
|
||||||
|
return this.db.order
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...(!staff ? { userId: actor.userId } : {}),
|
||||||
|
},
|
||||||
|
take: page.limit,
|
||||||
|
skip: page.offset,
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
currency: true,
|
||||||
|
merchandiseTotal: true,
|
||||||
|
createdAt: true,
|
||||||
|
expiresAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((orders) =>
|
||||||
|
orders.map((order) => ({
|
||||||
|
...order,
|
||||||
|
status: orderStatus(order),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async get(actor: Principal, id: string, staff = false) {
|
||||||
|
const order = await this.db.order.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...(!staff ? { userId: actor.userId } : {}),
|
||||||
|
},
|
||||||
|
include: { lines: { orderBy: { variantId: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!order) throw new AppError('ORDER_NOT_FOUND');
|
||||||
|
return orderView(order);
|
||||||
|
}
|
||||||
|
cancel(actor: Principal, id: string, staff = false) {
|
||||||
|
return this.access.mutate(
|
||||||
|
actor,
|
||||||
|
staff ? 'orders.manage' : null,
|
||||||
|
async (tx) => {
|
||||||
|
const order = await tx.order.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...(!staff ? { userId: actor.userId } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
lines: true,
|
||||||
|
reservations: { orderBy: { stockItemId: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) throw new AppError('ORDER_NOT_FOUND');
|
||||||
|
if (order.status === 'CANCELLED') return orderView(order);
|
||||||
|
for (const reservation of order.reservations)
|
||||||
|
await lockStock(tx, actor.organizationId, reservation.stockItemId);
|
||||||
|
await tx.stockReservation.updateMany({
|
||||||
|
where: { orderId: id, status: 'ACTIVE' },
|
||||||
|
data: { status: 'RELEASED' },
|
||||||
|
});
|
||||||
|
const updated = await tx.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
'order.cancelled',
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return orderView(updated);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentPrincipal } 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 { checkoutSchema, type CheckoutInput } from './checkout.schemas';
|
||||||
|
import { CheckoutStore } from './checkout.store';
|
||||||
|
import { OrderStore } from './order.store';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class OrdersController {
|
||||||
|
constructor(
|
||||||
|
private readonly checkout: CheckoutStore,
|
||||||
|
private readonly orders: OrderStore,
|
||||||
|
) {}
|
||||||
|
@Post('checkout')
|
||||||
|
create(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Body(new SchemaPipe(checkoutSchema)) input: CheckoutInput,
|
||||||
|
) {
|
||||||
|
return this.checkout.create(actor, input);
|
||||||
|
}
|
||||||
|
@Get('orders')
|
||||||
|
list(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Query(new SchemaPipe(pageSchema)) page: PageInput,
|
||||||
|
) {
|
||||||
|
return this.orders.list(actor, page);
|
||||||
|
}
|
||||||
|
@Get('orders/:id')
|
||||||
|
get(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.orders.get(actor, id);
|
||||||
|
}
|
||||||
|
@Post('orders/:id/cancel')
|
||||||
|
cancel(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.orders.cancel(actor, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { lockStock, reservedQuantity } from '../inventory/stock-lock';
|
||||||
|
import { commandHash } from '../inventory/inventory.policy';
|
||||||
|
|
||||||
|
export async function holdOrderStock(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
actor: Principal,
|
||||||
|
orderId: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
lines: { variantId: string; quantity: number }[],
|
||||||
|
) {
|
||||||
|
const stocks = await tx.stockItem.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
variantId: { in: lines.map((line) => line.variantId) },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
take: 201,
|
||||||
|
});
|
||||||
|
if (stocks.length > 200) throw new AppError('STOCK_ALLOCATION_LIMIT');
|
||||||
|
const now = new Date();
|
||||||
|
const remaining = new Map(
|
||||||
|
lines.map((line) => [line.variantId, line.quantity]),
|
||||||
|
);
|
||||||
|
for (const candidate of stocks) {
|
||||||
|
const stock = await lockStock(tx, actor.organizationId, candidate.id);
|
||||||
|
const needed = remaining.get(stock.variantId)!;
|
||||||
|
if (!needed) continue;
|
||||||
|
const available =
|
||||||
|
stock.onHand - (await reservedQuantity(tx, stock.id, now));
|
||||||
|
const quantity = Math.min(needed, available);
|
||||||
|
if (quantity <= 0) continue;
|
||||||
|
await tx.stockReservation.create({
|
||||||
|
data: {
|
||||||
|
stockItemId: stock.id,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
userId: actor.userId,
|
||||||
|
orderId,
|
||||||
|
quantity,
|
||||||
|
expiresAt,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
requestHash: commandHash(orderId, stock.id, quantity),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
remaining.set(stock.variantId, needed - quantity);
|
||||||
|
}
|
||||||
|
if ([...remaining.values()].some((quantity) => quantity > 0))
|
||||||
|
throw new AppError('STOCK_INSUFFICIENT');
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ export class ReservationTransitionStore {
|
||||||
};
|
};
|
||||||
const existing = await tx.stockReservation.findFirst({ where: lookup });
|
const existing = await tx.stockReservation.findFirst({ where: lookup });
|
||||||
if (!existing) throw new AppError('RESERVATION_NOT_FOUND');
|
if (!existing) throw new AppError('RESERVATION_NOT_FOUND');
|
||||||
|
if (existing.orderId) throw new AppError('ORDER_RESERVATION_MANAGED');
|
||||||
const stock = await lockStock(
|
const stock = await lockStock(
|
||||||
tx,
|
tx,
|
||||||
actor.organizationId,
|
actor.organizationId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { checkoutFixture, couponInput } from './helpers/checkout';
|
||||||
|
import { secondActor, addressInput } from './helpers/commerce';
|
||||||
|
|
||||||
|
const native = process.env.TEST_DATABASE_URL ? describe : describe.skip;
|
||||||
|
native('native PostgreSQL checkout concurrency', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
it('creates one order for two simultaneous identical requests', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
const responses = await Promise.all(
|
||||||
|
[1, 2].map(() =>
|
||||||
|
ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(responses.map((response) => response.status)).toEqual([201, 201]);
|
||||||
|
expect(responses[0]!.body.id).toBe(responses[1]!.body.id);
|
||||||
|
expect(
|
||||||
|
await ctx.db.stockReservation.count({
|
||||||
|
where: { userId: f.actor.userId },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
it('lets only one competing checkout claim the final stock and coupon use', async () => {
|
||||||
|
const f = await checkoutFixture(ctx, 2);
|
||||||
|
const other = await secondActor(ctx);
|
||||||
|
const address = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/addresses')
|
||||||
|
.auth(other.token, { type: 'bearer' })
|
||||||
|
.send(addressInput)
|
||||||
|
.expect(201);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put('/api/v1/cart/lines/' + f.variant.id)
|
||||||
|
.auth(other.token, { type: 'bearer' })
|
||||||
|
.send({ quantity: 2, version: 0 })
|
||||||
|
.expect(200);
|
||||||
|
const coupon = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/coupons')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ ...couponInput(), maxUses: 1 })
|
||||||
|
.expect(201);
|
||||||
|
const commands = [
|
||||||
|
{ actor: f.actor, input: { ...f.input, couponCode: coupon.body.code } },
|
||||||
|
{
|
||||||
|
actor: other,
|
||||||
|
input: {
|
||||||
|
...f.input,
|
||||||
|
addressId: address.body.id,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
couponCode: coupon.body.code,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const responses = await Promise.all(
|
||||||
|
commands.map(({ actor, input }) =>
|
||||||
|
ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(actor.token, { type: 'bearer' })
|
||||||
|
.send(input),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(responses.map((response) => response.status).sort()).toEqual([
|
||||||
|
201, 409,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
await ctx.db.order.count({ where: { couponId: coupon.body.id } }),
|
||||||
|
).toBe(1);
|
||||||
|
const reserved = await ctx.db.stockReservation.aggregate({
|
||||||
|
where: { stockItemId: f.stock.id },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
expect(reserved._sum.quantity).toBe(2);
|
||||||
|
});
|
||||||
|
it('coordinates checkout with a standalone inventory reservation', async () => {
|
||||||
|
const f = await checkoutFixture(ctx, 2);
|
||||||
|
const responses = await Promise.all([
|
||||||
|
ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input),
|
||||||
|
ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/inventory/reservations')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({
|
||||||
|
stockItemId: f.stock.id,
|
||||||
|
quantity: 2,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(responses.map((response) => response.status).sort()).toEqual([
|
||||||
|
201, 409,
|
||||||
|
]);
|
||||||
|
const held = await ctx.db.stockReservation.aggregate({
|
||||||
|
where: { stockItemId: f.stock.id },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
expect(held._sum.quantity).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { CartStore } from '../src/checkout/cart.store';
|
||||||
|
import { CheckoutStore } from '../src/checkout/checkout.store';
|
||||||
|
import { holdOrderStock } from '../src/checkout/stock-allocation';
|
||||||
|
import { orderView } from '../src/checkout/order-view';
|
||||||
|
import type { AccessStore } from '../src/identity/access.store';
|
||||||
|
import type { Principal } from '../src/identity/identity.types';
|
||||||
|
import { Prisma, type Order } from '../src/generated/prisma/client';
|
||||||
|
|
||||||
|
const actor = { userId: 'user', organizationId: 'org' } as Principal;
|
||||||
|
function accessFor(tx: unknown) {
|
||||||
|
return {
|
||||||
|
mutate: (
|
||||||
|
_actor: unknown,
|
||||||
|
_permission: unknown,
|
||||||
|
work: (tx: unknown) => unknown,
|
||||||
|
) => work(tx),
|
||||||
|
} as AccessStore;
|
||||||
|
}
|
||||||
|
describe('checkout resource limits and expiration', () => {
|
||||||
|
it('rejects a 21st cart line before writing', async () => {
|
||||||
|
const tx = {
|
||||||
|
cart: { upsert: jest.fn().mockResolvedValue({ id: 'cart', version: 1 }) },
|
||||||
|
productVariant: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ currency: 'INR' }),
|
||||||
|
},
|
||||||
|
cartLine: {
|
||||||
|
findMany: jest.fn().mockResolvedValue(
|
||||||
|
Array.from({ length: 20 }, (_, index) => ({
|
||||||
|
variantId: String(index),
|
||||||
|
variant: { currency: 'INR' },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
upsert: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
new CartStore(accessFor(tx)).set(actor, 'new', 1, 1),
|
||||||
|
).rejects.toMatchObject({ code: 'CART_LIMIT' });
|
||||||
|
expect(tx.cartLine.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('rejects an eleventh active order before creating an order or stock hold', async () => {
|
||||||
|
const tx = {
|
||||||
|
order: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
count: jest.fn().mockResolvedValue(10),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
new CheckoutStore(accessFor(tx)).create(actor, {
|
||||||
|
cartVersion: 1,
|
||||||
|
addressId: 'address',
|
||||||
|
idempotencyKey: 'key',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'ORDER_LIMIT' });
|
||||||
|
expect(tx.order.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('bounds stock allocation work before acquiring locks', async () => {
|
||||||
|
const tx = {
|
||||||
|
stockItem: { findMany: jest.fn().mockResolvedValue(Array(201).fill({})) },
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
holdOrderStock(
|
||||||
|
tx as unknown as Prisma.TransactionClient,
|
||||||
|
actor,
|
||||||
|
'order',
|
||||||
|
new Date(),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'STOCK_ALLOCATION_LIMIT' });
|
||||||
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('exposes expiry without pretending an unfinalized order is payable', () => {
|
||||||
|
const order: Order & { lines: [] } = {
|
||||||
|
id: 'order',
|
||||||
|
organizationId: 'org',
|
||||||
|
userId: 'user',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
currency: 'INR',
|
||||||
|
subtotal: new Prisma.Decimal(1),
|
||||||
|
discount: new Prisma.Decimal(0),
|
||||||
|
merchandiseTotal: new Prisma.Decimal(1),
|
||||||
|
addressSnapshot: {},
|
||||||
|
couponSnapshot: null,
|
||||||
|
couponId: null,
|
||||||
|
idempotencyKey: 'key',
|
||||||
|
requestHash: 'hash',
|
||||||
|
createdAt: new Date(0),
|
||||||
|
expiresAt: new Date(1),
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
expect(orderView(order)).toMatchObject({
|
||||||
|
status: 'EXPIRED',
|
||||||
|
payableTotal: null,
|
||||||
|
paymentAvailable: false,
|
||||||
|
});
|
||||||
|
expect(orderView({ ...order, status: 'CANCELLED' }).status).toBe(
|
||||||
|
'CANCELLED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { checkoutFixture } from './helpers/checkout';
|
||||||
|
import * as audit from '../src/identity/audit';
|
||||||
|
|
||||||
|
describe('checkout allocation and final-write rollback', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
it('rolls back order, holds and cart clearing when the audit write fails', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
jest
|
||||||
|
.spyOn(audit, 'recordAudit')
|
||||||
|
.mockRejectedValueOnce(new Error('Synthetic audit failure'));
|
||||||
|
const result = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(500);
|
||||||
|
expect(result.body.code).toBe('INTERNAL_FAILURE');
|
||||||
|
expect(JSON.stringify(result.body)).not.toContain('Synthetic');
|
||||||
|
expect(
|
||||||
|
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await ctx.db.stockReservation.count({
|
||||||
|
where: { userId: f.actor.userId },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
const cart = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/cart')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(cart.body.version).toBe(1);
|
||||||
|
expect(cart.body.lines).toHaveLength(1);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
it('splits a line across warehouses without consuming on-hand stock', async () => {
|
||||||
|
const f = await checkoutFixture(ctx, 1);
|
||||||
|
const warehouse = await ctx.db.warehouse.create({
|
||||||
|
data: { organizationId: f.actor.organizationId, name: randomUUID() },
|
||||||
|
});
|
||||||
|
const stock = await ctx.db.stockItem.create({
|
||||||
|
data: {
|
||||||
|
organizationId: f.actor.organizationId,
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
variantId: f.variant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/inventory/adjustments')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({
|
||||||
|
stockItemId: stock.id,
|
||||||
|
delta: 1,
|
||||||
|
reason: 'Opening balance',
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const result = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(201);
|
||||||
|
const holds = await ctx.db.stockReservation.findMany({
|
||||||
|
where: { orderId: result.body.id },
|
||||||
|
});
|
||||||
|
expect(holds).toHaveLength(2);
|
||||||
|
expect(holds.map((hold) => hold.quantity)).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
it('revalidates product availability and currency when checking out a saved cart', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
await ctx.db.productVariant.update({
|
||||||
|
where: { id: f.variant.id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
const response = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(409);
|
||||||
|
expect(response.body.code).toBe('CART_ITEM_UNAVAILABLE');
|
||||||
|
expect(
|
||||||
|
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { checkoutFixture, couponInput } from './helpers/checkout';
|
||||||
|
import { secondActor, seedStock, addressInput } from './helpers/commerce';
|
||||||
|
|
||||||
|
describe('atomic checkout and order snapshots', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
it('snapshots server prices and address, holds stock, clears cart and replays safely', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
const created = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(201);
|
||||||
|
expect(created.body).toMatchObject({
|
||||||
|
subtotal: '998',
|
||||||
|
discount: '0',
|
||||||
|
merchandiseTotal: '998',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
pricingStatus: 'UNFINALIZED',
|
||||||
|
payableTotal: null,
|
||||||
|
paymentAvailable: false,
|
||||||
|
});
|
||||||
|
expect(created.body.requestHash).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
await ctx.db.stockReservation.count({
|
||||||
|
where: { orderId: created.body.id },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
(await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } }))
|
||||||
|
.onHand,
|
||||||
|
).toBe(10);
|
||||||
|
await ctx.db.productVariant.update({
|
||||||
|
where: { id: f.variant.id },
|
||||||
|
data: { price: '1.00', name: 'Changed' },
|
||||||
|
});
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put('/api/v1/addresses/' + f.address.id)
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...addressInput, recipient: 'Changed' })
|
||||||
|
.expect(200);
|
||||||
|
const replay = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(201);
|
||||||
|
expect(replay.body).toEqual(created.body);
|
||||||
|
expect(replay.body.address.recipient).toBe(addressInput.recipient);
|
||||||
|
const cart = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/cart')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(cart.body).toEqual({ version: 2, lines: [] });
|
||||||
|
const conflict = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...f.input, cartVersion: 2 })
|
||||||
|
.expect(409);
|
||||||
|
expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT');
|
||||||
|
});
|
||||||
|
it('rolls back orders and partial holds on a stock shortage, preserving cart', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
const unavailable = await seedStock(ctx, 0);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put('/api/v1/cart/lines/' + unavailable.variant.id)
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ quantity: 1, version: 1 })
|
||||||
|
.expect(200);
|
||||||
|
const response = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...f.input, cartVersion: 2 })
|
||||||
|
.expect(409);
|
||||||
|
expect(response.body.code).toBe('STOCK_INSUFFICIENT');
|
||||||
|
expect(
|
||||||
|
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await ctx.db.stockReservation.count({
|
||||||
|
where: { userId: f.actor.userId },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await ctx.db.cartLine.count({
|
||||||
|
where: { cart: { userId: f.actor.userId } },
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
it('validates live cart version, private address and coupon eligibility before writes', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
for (const [change, code] of [
|
||||||
|
[{ cartVersion: 0 }, 'CART_CHANGED'],
|
||||||
|
[{ addressId: randomUUID() }, 'ADDRESS_NOT_FOUND'],
|
||||||
|
[{ couponCode: 'MISSING' }, 'COUPON_INELIGIBLE'],
|
||||||
|
] as const) {
|
||||||
|
const response = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...f.input, ...change })
|
||||||
|
.expect(code === 'ADDRESS_NOT_FOUND' ? 404 : 409);
|
||||||
|
expect(response.body.code).toBe(code);
|
||||||
|
}
|
||||||
|
const other = await secondActor(ctx);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(other.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(409);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...f.input, subtotal: '0.01' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('applies a coupon exactly once and releases its usage on cancellation', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
const coupon = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/coupons')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send(couponInput())
|
||||||
|
.expect(201);
|
||||||
|
const input = { ...f.input, couponCode: coupon.body.code };
|
||||||
|
const order = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(input)
|
||||||
|
.expect(201);
|
||||||
|
expect(order.body).toMatchObject({
|
||||||
|
subtotal: '998',
|
||||||
|
discount: '99.8',
|
||||||
|
merchandiseTotal: '898.2',
|
||||||
|
});
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put('/api/v1/cart/lines/' + f.variant.id)
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ quantity: 1, version: 2 })
|
||||||
|
.expect(200);
|
||||||
|
const next = { ...input, cartVersion: 3, idempotencyKey: randomUUID() };
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(next)
|
||||||
|
.expect(409);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/orders/' + order.body.id + '/cancel')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.expect(201);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(next)
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { checkoutFixture, couponInput } from './helpers/checkout';
|
||||||
|
import { secondActor } from './helpers/commerce';
|
||||||
|
|
||||||
|
describe('coupon administration', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
it('requires permissions, scopes reads and deactivates immutable coupon rules', async () => {
|
||||||
|
const customer = await secondActor(ctx);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/coupons')
|
||||||
|
.auth(customer.token, { type: 'bearer' })
|
||||||
|
.send(couponInput())
|
||||||
|
.expect(403);
|
||||||
|
const created = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/coupons')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send(couponInput())
|
||||||
|
.expect(201);
|
||||||
|
const list = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/coupons')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
list.body.some((row: { id: string }) => row.id === created.body.id),
|
||||||
|
).toBe(true);
|
||||||
|
const foreign = await secondActor(ctx, false, ['coupons.manage']);
|
||||||
|
const empty = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/coupons')
|
||||||
|
.auth(foreign.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(empty.body).toEqual([]);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.patch('/api/v1/coupons/' + created.body.id + '/status')
|
||||||
|
.auth(foreign.token, { type: 'bearer' })
|
||||||
|
.send({ active: false })
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.patch('/api/v1/coupons/' + created.body.id + '/status')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ active: false })
|
||||||
|
.expect(200);
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(
|
||||||
|
`UPDATE coupons SET percent_bps = 5000 WHERE id = '${created.body.id}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...f.input, couponCode: created.body.code })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
it('caps fixed discounts at subtotal and rejects duplicate normalized codes', async () => {
|
||||||
|
const input = couponInput();
|
||||||
|
const { percentBps: _percent, ...common } = input;
|
||||||
|
const fixed = { ...common, kind: 'FIXED', amount: '9999.00' };
|
||||||
|
const coupon = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/coupons')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send(fixed)
|
||||||
|
.expect(201);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/coupons')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ ...fixed, code: fixed.code.toLowerCase() })
|
||||||
|
.expect(409);
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
const order = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send({ ...f.input, couponCode: coupon.body.code })
|
||||||
|
.expect(201);
|
||||||
|
expect(order.body.merchandiseTotal).toBe('0');
|
||||||
|
expect(order.body.paymentAvailable).toBe(false);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.patch('/api/v1/coupons/' + randomUUID() + '/status')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ active: false })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { checkoutFixture } from './helpers/checkout';
|
||||||
|
import { secondActor } from './helpers/commerce';
|
||||||
|
|
||||||
|
describe('order ownership and cancellation', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
it('isolates customers and organizations while allowing scoped staff reads', async () => {
|
||||||
|
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);
|
||||||
|
const other = await secondActor(ctx);
|
||||||
|
const foreign = await secondActor(ctx, false, [
|
||||||
|
'orders.read',
|
||||||
|
'orders.manage',
|
||||||
|
]);
|
||||||
|
for (const actor of [other, foreign]) {
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/orders/' + order.body.id)
|
||||||
|
.auth(actor.token, { type: 'bearer' })
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/orders/' + order.body.id + '/cancel')
|
||||||
|
.auth(actor.token, { type: 'bearer' })
|
||||||
|
.expect(404);
|
||||||
|
}
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/admin/orders/' + order.body.id)
|
||||||
|
.auth(other.token, { type: 'bearer' })
|
||||||
|
.expect(403);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/admin/orders/' + order.body.id)
|
||||||
|
.auth(foreign.token, { type: 'bearer' })
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/admin/orders/' + order.body.id + '/cancel')
|
||||||
|
.auth(foreign.token, { type: 'bearer' })
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/admin/orders/' + order.body.id)
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
const own = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/orders')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(own.body.map((row: { id: string }) => row.id)).toEqual([
|
||||||
|
order.body.id,
|
||||||
|
]);
|
||||||
|
const others = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/orders')
|
||||||
|
.auth(other.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(others.body).toEqual([]);
|
||||||
|
const staff = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/admin/orders?limit=1')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(200);
|
||||||
|
expect(staff.body).toHaveLength(1);
|
||||||
|
expect(staff.body[0].address).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('cancels once, releases holds and blocks standalone reservation transitions', async () => {
|
||||||
|
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);
|
||||||
|
const hold = await ctx.db.stockReservation.findFirstOrThrow({
|
||||||
|
where: { orderId: order.body.id },
|
||||||
|
});
|
||||||
|
const denied = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/inventory/reservations/' + hold.id + '/commit')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(409);
|
||||||
|
expect(denied.body.code).toBe('ORDER_RESERVATION_MANAGED');
|
||||||
|
const result = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/admin/orders/' + order.body.id + '/cancel')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(201);
|
||||||
|
expect(result.body.status).toBe('CANCELLED');
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/orders/' + order.body.id + '/cancel')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.expect(201);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await ctx.db.stockReservation.findUniqueOrThrow({
|
||||||
|
where: { id: hold.id },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe('RELEASED');
|
||||||
|
expect(
|
||||||
|
await ctx.db.auditEvent.count({
|
||||||
|
where: { targetId: order.body.id, action: 'order.cancelled' },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
(await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } }))
|
||||||
|
.onHand,
|
||||||
|
).toBe(10);
|
||||||
|
});
|
||||||
|
it('enforces immutable snapshots and line arithmetic through SQL', async () => {
|
||||||
|
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);
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(
|
||||||
|
`UPDATE orders SET subtotal = 1 WHERE id = '${order.body.id}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(`DELETE FROM orders WHERE id = '${order.body.id}'`),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(
|
||||||
|
`UPDATE order_lines SET quantity = 10 WHERE order_id = '${order.body.id}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(
|
||||||
|
`DELETE FROM order_lines WHERE order_id = '${order.body.id}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(
|
||||||
|
`UPDATE stock_reservations SET order_id = NULL WHERE order_id = '${order.body.id}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(
|
||||||
|
`DELETE FROM stock_reservations WHERE order_id = '${order.body.id}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('rejects a later line insertion that would change a committed order snapshot', async () => {
|
||||||
|
const f = await checkoutFixture(ctx);
|
||||||
|
const result = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/checkout')
|
||||||
|
.auth(f.actor.token, { type: 'bearer' })
|
||||||
|
.send(f.input)
|
||||||
|
.expect(201);
|
||||||
|
const variant = await ctx.db.productVariant.create({
|
||||||
|
data: {
|
||||||
|
organizationId: f.actor.organizationId,
|
||||||
|
productId: f.product.id,
|
||||||
|
sku: result.body.id,
|
||||||
|
name: 'Extra line',
|
||||||
|
price: '1.00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
ctx.executeSql(`INSERT INTO order_lines
|
||||||
|
(id, order_id, organization_id, variant_id, sku, product_name, variant_name, quantity, unit_price, line_total)
|
||||||
|
VALUES (gen_random_uuid(), '${result.body.id}', '${f.actor.organizationId}', '${variant.id}', 'EXTRA', 'Extra', 'Extra', 1, 1, 1)`),
|
||||||
|
).rejects.toThrow('Order subtotal does not match lines');
|
||||||
|
expect(
|
||||||
|
await ctx.db.orderLine.count({ where: { orderId: result.body.id } }),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue