Compare commits

...

2 Commits

15 changed files with 702 additions and 1 deletions

View File

@ -32,6 +32,7 @@ Identity: see [API contract](docs/identity-api.md) and [setup/release guide](doc
Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md).
Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized.
Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled.
Phase 2A adds organization-scoped [supplier and material master data](docs/procurement-api.md). Purchase orders, receipts, QC and production remain later milestones.
### Swagger UI

View File

@ -14,3 +14,4 @@ Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in pr
Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger.
Phase 1D appends checkout tables, reservation ownership, immutable snapshot guards and deferred order/line reconciliation. Earlier migrations are unchanged.
Phase 1E adds pricing policies, immutable final-price snapshots and commerce events with leased delivery state in two further timestamped migrations. All eleven preceding migrations remain unchanged.
Phase 2A appends supplier, material and supplier-material compatibility tables. It grants procurement permissions to existing system roles; custom roles must be updated through the RBAC API.

16
docs/procurement-api.md Normal file
View File

@ -0,0 +1,16 @@
# Phase 2A: suppliers and materials
This phase introduces private, organization-scoped supplier and material master data. It does not create purchase orders, receipts, invoices, stock movements, production batches, or documents.
| Method | Route | Permission | Purpose |
| -------- | -------------------------------------------- | ------------------ | --------------------------------------------------------------- |
| GET | /suppliers | procurement.read | Page through suppliers; filter with `active` and `search` |
| POST/PUT | /suppliers, /suppliers/:id | procurement.manage | Create or replace supplier records |
| GET | /materials | procurement.read | Page through materials; filter with `active` and `search` |
| POST/PUT | /materials, /materials/:id | procurement.manage | Create or replace materials |
| GET | /suppliers/:supplierId/materials | procurement.read | View the supplier's compatible materials |
| PUT | /suppliers/:supplierId/materials/:materialId | procurement.manage | Create or replace compatibility, commercial lead time and quote |
Supplier and material codes are uppercase, organization-unique identifiers. Materials use a fixed kind and unit to avoid ambiguous procurement and BOM quantities. Compatibility records hold a supplier SKU, lead time, minimum quantity, price, currency, and active status. A future purchase order must snapshot these values rather than rely on a later edit.
All endpoints require a session and enforce a permission again inside write transactions. Missing suppliers and materials produce distinct scoped errors. Create, update, and compatibility actions are audit-recorded without arbitrary payloads. The migration is append-only and grants the new permissions only to existing system roles; custom roles require an explicit update.

View File

@ -11,5 +11,6 @@
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e"
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e",
"20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1"
}

View File

@ -0,0 +1,59 @@
CREATE TYPE "MaterialKind" AS ENUM ('WAX', 'FRAGRANCE', 'WICK', 'DYE', 'VESSEL', 'PACKAGING', 'LABEL', 'OTHER');
CREATE TYPE "MaterialUnit" AS ENUM ('GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE');
CREATE TABLE "suppliers" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"code" VARCHAR(32) NOT NULL,
"contact_name" VARCHAR(160) NOT NULL,
"email" VARCHAR(254) NOT NULL,
"phone" VARCHAR(24) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "materials" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"code" VARCHAR(32) NOT NULL,
"kind" "MaterialKind" NOT NULL,
"unit" "MaterialUnit" NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "materials_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "supplier_materials" (
"supplier_id" UUID NOT NULL,
"material_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"supplier_sku" VARCHAR(64) NOT NULL,
"lead_time_days" INTEGER NOT NULL,
"min_order_quantity" DECIMAL(12,3) NOT NULL,
"unit_price" DECIMAL(12,2) NOT NULL,
"currency" CHAR(3) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "supplier_materials_pkey" PRIMARY KEY ("supplier_id", "material_id")
);
CREATE UNIQUE INDEX "suppliers_organization_id_code_key" ON "suppliers"("organization_id", "code");
CREATE UNIQUE INDEX "suppliers_id_organization_id_key" ON "suppliers"("id", "organization_id");
CREATE INDEX "suppliers_organization_id_active_name_id_idx" ON "suppliers"("organization_id", "active", "name", "id");
CREATE UNIQUE INDEX "materials_organization_id_code_key" ON "materials"("organization_id", "code");
CREATE UNIQUE INDEX "materials_id_organization_id_key" ON "materials"("id", "organization_id");
CREATE INDEX "materials_organization_id_active_kind_name_id_idx" ON "materials"("organization_id", "active", "kind", "name", "id");
CREATE INDEX "supplier_materials_material_id_organization_id_active_idx" ON "supplier_materials"("material_id", "organization_id", "active");
ALTER TABLE "suppliers" ADD CONSTRAINT "suppliers_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "materials" ADD CONSTRAINT "materials_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_supplier_id_organization_id_fkey" FOREIGN KEY ("supplier_id", "organization_id") REFERENCES "suppliers"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_material_id_organization_id_fkey" FOREIGN KEY ("material_id", "organization_id") REFERENCES "materials"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_bounds" CHECK ("lead_time_days" BETWEEN 0 AND 365 AND "min_order_quantity" > 0 AND "unit_price" >= 0 AND "currency" IN ('INR', 'USD', 'EUR', 'GBP'));
UPDATE "roles" SET "permissions" = ARRAY(SELECT DISTINCT permission FROM unnest("permissions" || ARRAY['procurement.read', 'procurement.manage']::text[]) AS permission ORDER BY permission) WHERE "is_system" = true;

77
prisma/procurement.prisma Normal file
View File

@ -0,0 +1,77 @@
enum MaterialKind {
WAX
FRAGRANCE
WICK
DYE
VESSEL
PACKAGING
LABEL
OTHER
}
enum MaterialUnit {
GRAM
KILOGRAM
MILLILITRE
LITRE
PIECE
METRE
}
model Supplier {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
code String @db.VarChar(32)
contactName String @map("contact_name") @db.VarChar(160)
email String @db.VarChar(254)
phone String @db.VarChar(24)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
materials SupplierMaterial[]
@@unique([organizationId, code])
@@unique([id, organizationId])
@@index([organizationId, active, name, id])
@@map("suppliers")
}
model Material {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
code String @db.VarChar(32)
kind MaterialKind
unit MaterialUnit
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
suppliers SupplierMaterial[]
@@unique([organizationId, code])
@@unique([id, organizationId])
@@index([organizationId, active, kind, name, id])
@@map("materials")
}
model SupplierMaterial {
supplierId String @map("supplier_id") @db.Uuid
materialId String @map("material_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
supplierSku String @map("supplier_sku") @db.VarChar(64)
leadTimeDays Int @map("lead_time_days")
minOrderQuantity Decimal @map("min_order_quantity") @db.Decimal(12, 3)
unitPrice Decimal @map("unit_price") @db.Decimal(12, 2)
currency String @db.Char(3)
active Boolean @default(true)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
supplier Supplier @relation(fields: [supplierId, organizationId], references: [id, organizationId], onDelete: Restrict)
material Material @relation(fields: [materialId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@id([supplierId, materialId])
@@index([materialId, organizationId, active])
@@map("supplier_materials")
}

View File

@ -24,6 +24,8 @@ model Organization {
warehouses Warehouse[]
coupons Coupon[]
pricingPolicies PricingPolicy[]
suppliers Supplier[]
materials Material[]
@@map("organizations")
}
model User {

View File

@ -3,6 +3,7 @@ import { OperationsModule } from './operations/operations.module';
import { CatalogModule } from './catalog/catalog.module';
import { AddressesModule } from './addresses/addresses.module';
import { InventoryModule } from './inventory/inventory.module';
import { ProcurementModule } from './procurement/procurement.module';
import { Module } from '@nestjs/common';
import { EnvironmentModule } from './config/environment.module';
import { IdentityModule } from './identity/identity.module';
@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module';
CatalogModule,
AddressesModule,
InventoryModule,
ProcurementModule,
CheckoutModule,
OperationsModule,
],

View File

@ -81,4 +81,19 @@ export const COMMERCE_ERRORS = {
'Product variant is not available for reservation',
'Non-sellable variant reservation rejected',
],
SUPPLIER_NOT_FOUND: [
404,
'Supplier not found',
'Scoped supplier lookup failed',
],
MATERIAL_NOT_FOUND: [
404,
'Material not found',
'Scoped material lookup failed',
],
SUPPLIER_MATERIAL_NOT_FOUND: [
404,
'Supplier material is unavailable',
'Scoped supplier-material compatibility lookup failed',
],
} as const;

View File

@ -20,5 +20,7 @@ export const PERMISSIONS = [
'inventory.adjust',
'inventory.reserve',
'inventory.commit',
'procurement.read',
'procurement.manage',
] as const;
export type Permission = (typeof PERMISSIONS)[number];

View File

@ -0,0 +1,105 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import {
CurrentPrincipal,
RequirePermission,
} from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { ProcurementStore } from './procurement.store';
import {
materialSchema,
procurementQuery,
supplierMaterialSchema,
supplierSchema,
type MaterialInput,
type ProcurementQuery,
type SupplierInput,
type SupplierMaterialInput,
} from './procurement.schemas';
@Controller()
export class ProcurementController {
constructor(private readonly procurement: ProcurementStore) {}
@Get('suppliers')
@RequirePermission('procurement.read')
listSuppliers(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery,
) {
return this.procurement.listSuppliers(actor, query);
}
@Post('suppliers')
@RequirePermission('procurement.manage')
createSupplier(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(supplierSchema)) input: SupplierInput,
) {
return this.procurement.saveSupplier(actor, input);
}
@Put('suppliers/:id')
@RequirePermission('procurement.manage')
updateSupplier(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(supplierSchema)) input: SupplierInput,
) {
return this.procurement.saveSupplier(actor, input, id);
}
@Get('suppliers/:supplierId/materials')
@RequirePermission('procurement.read')
listSupplierMaterials(
@CurrentPrincipal() actor: Principal,
@Param('supplierId', ParseUUIDPipe) supplierId: string,
) {
return this.procurement.listSupplierMaterials(actor, supplierId);
}
@Put('suppliers/:supplierId/materials/:materialId')
@RequirePermission('procurement.manage')
saveSupplierMaterial(
@CurrentPrincipal() actor: Principal,
@Param('supplierId', ParseUUIDPipe) supplierId: string,
@Param('materialId', ParseUUIDPipe) materialId: string,
@Body(new SchemaPipe(supplierMaterialSchema)) input: SupplierMaterialInput,
) {
return this.procurement.saveSupplierMaterial(
actor,
supplierId,
materialId,
input,
);
}
@Get('materials')
@RequirePermission('procurement.read')
listMaterials(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery,
) {
return this.procurement.listMaterials(actor, query);
}
@Post('materials')
@RequirePermission('procurement.manage')
createMaterial(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(materialSchema)) input: MaterialInput,
) {
return this.procurement.saveMaterial(actor, input);
}
@Put('materials/:id')
@RequirePermission('procurement.manage')
updateMaterial(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(materialSchema)) input: MaterialInput,
) {
return this.procurement.saveMaterial(actor, input, id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { ProcurementController } from './procurement.controller';
import { ProcurementStore } from './procurement.store';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [ProcurementStore],
controllers: [ProcurementController],
})
export class ProcurementModule {}

View File

@ -0,0 +1,76 @@
import { z } from 'zod';
import { CURRENCIES } from '../common/currency';
import { text } from '../common/input';
import { pageSchema } from '../identity/identity.schemas';
const code = z
.string()
.trim()
.toUpperCase()
.max(32)
.regex(/^[A-Z0-9][A-Z0-9_-]*$/);
const decimal = (scale: number) =>
z
.string()
.regex(/^(0|[1-9]\d{0,8})\.\d+$/)
.refine((value) => value.split('.').at(-1)?.length === scale);
export const supplierSchema = z
.object({
name: text(160),
code,
contactName: text(160),
email: z
.email()
.max(254)
.transform((value) => value.toLowerCase()),
phone: z
.string()
.trim()
.max(24)
.regex(/^\+?[0-9 ()-]{7,24}$/),
active: z.boolean().default(true),
})
.strict();
export const materialSchema = z
.object({
name: text(160),
code,
kind: z.enum([
'WAX',
'FRAGRANCE',
'WICK',
'DYE',
'VESSEL',
'PACKAGING',
'LABEL',
'OTHER',
]),
unit: z.enum(['GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE']),
active: z.boolean().default(true),
})
.strict();
export const supplierMaterialSchema = z
.object({
supplierSku: z
.string()
.trim()
.max(64)
.regex(/^[^<>\u0000-\u001F\u007F]*$/),
leadTimeDays: z.number().int().min(0).max(365),
minOrderQuantity: decimal(3).refine((value) => value !== '0.000'),
unitPrice: decimal(2),
currency: z.enum(CURRENCIES).default('INR'),
active: z.boolean().default(true),
})
.strict();
export const procurementQuery = pageSchema
.extend({
active: z.coerce.boolean().optional(),
search: text(100).optional(),
})
.strict();
export type SupplierInput = z.infer<typeof supplierSchema>;
export type MaterialInput = z.infer<typeof materialSchema>;
export type SupplierMaterialInput = z.infer<typeof supplierMaterialSchema>;
export type ProcurementQuery = z.infer<typeof procurementQuery>;

View File

@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import type {
MaterialInput,
ProcurementQuery,
SupplierInput,
SupplierMaterialInput,
} from './procurement.schemas';
@Injectable()
export class ProcurementStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
listSuppliers(actor: Principal, query: ProcurementQuery) {
return this.db.supplier.findMany({
where: {
organizationId: actor.organizationId,
...(query.active === undefined ? {} : { active: query.active }),
...(query.search
? { name: { contains: query.search, mode: 'insensitive' } }
: {}),
},
orderBy: [{ name: 'asc' }, { id: 'asc' }],
take: query.limit,
skip: query.offset,
});
}
listMaterials(actor: Principal, query: ProcurementQuery) {
return this.db.material.findMany({
where: {
organizationId: actor.organizationId,
...(query.active === undefined ? {} : { active: query.active }),
...(query.search
? { name: { contains: query.search, mode: 'insensitive' } }
: {}),
},
orderBy: [{ name: 'asc' }, { id: 'asc' }],
take: query.limit,
skip: query.offset,
});
}
saveSupplier(actor: Principal, input: SupplierInput, id?: string) {
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
if (
id &&
!(await tx.supplier.findFirst({
where: { id, organizationId: actor.organizationId },
}))
)
throw new AppError('SUPPLIER_NOT_FOUND');
const supplier = id
? await tx.supplier.update({ where: { id }, data: input })
: await tx.supplier.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'supplier.updated' : 'supplier.created',
supplier.id,
);
return supplier;
});
}
saveMaterial(actor: Principal, input: MaterialInput, id?: string) {
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
if (
id &&
!(await tx.material.findFirst({
where: { id, organizationId: actor.organizationId },
}))
)
throw new AppError('MATERIAL_NOT_FOUND');
const material = id
? await tx.material.update({ where: { id }, data: input })
: await tx.material.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'material.updated' : 'material.created',
material.id,
);
return material;
});
}
saveSupplierMaterial(
actor: Principal,
supplierId: string,
materialId: string,
input: SupplierMaterialInput,
) {
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
if (
!(await tx.supplier.findFirst({
where: { id: supplierId, organizationId: actor.organizationId },
}))
)
throw new AppError('SUPPLIER_NOT_FOUND');
if (
!(await tx.material.findFirst({
where: { id: materialId, organizationId: actor.organizationId },
}))
)
throw new AppError('MATERIAL_NOT_FOUND');
const relation = await tx.supplierMaterial.upsert({
where: { supplierId_materialId: { supplierId, materialId } },
create: {
...input,
supplierId,
materialId,
organizationId: actor.organizationId,
},
update: input,
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'supplier_material.saved',
supplierId,
);
return relation;
});
}
async listSupplierMaterials(actor: Principal, supplierId: string) {
if (
!(await this.db.supplier.findFirst({
where: { id: supplierId, organizationId: actor.organizationId },
}))
)
throw new AppError('SUPPLIER_NOT_FOUND');
return this.db.supplierMaterial.findMany({
where: { supplierId, organizationId: actor.organizationId },
include: { material: true },
orderBy: { material: { name: 'asc' } },
});
}
}

184
test/procurement.spec.ts Normal file
View File

@ -0,0 +1,184 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { secondActor } from './helpers/commerce';
describe('supplier and material API', () => {
let ctx: IdentityApp;
const auth = { type: 'bearer' as const };
const supplierInput = {
name: 'Candle Supply Co',
code: 'CANDLE-SUPPLY',
contactName: 'Asha Shah',
email: 'asha@example.com',
phone: '+919876543210',
};
const materialInput = {
name: 'Soy Wax',
code: 'SOY-WAX',
kind: 'WAX',
unit: 'KILOGRAM',
};
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('creates scoped master data and a supplier-material quote', async () => {
const supplier = await ctx
.api()
.post('/api/v1/suppliers')
.auth(ctx.token, auth)
.send(supplierInput)
.expect(201);
const material = await ctx
.api()
.post('/api/v1/materials')
.auth(ctx.token, auth)
.send(materialInput)
.expect(201);
const relation = await ctx
.api()
.put(
`/api/v1/suppliers/${supplier.body.id}/materials/${material.body.id}`,
)
.auth(ctx.token, auth)
.send({
supplierSku: 'SW-25',
leadTimeDays: 14,
minOrderQuantity: '25.000',
unitPrice: '320.00',
})
.expect(200);
expect(relation.body).toMatchObject({
supplierId: supplier.body.id,
materialId: material.body.id,
currency: 'INR',
active: true,
});
const listed = await ctx
.api()
.get(`/api/v1/suppliers/${supplier.body.id}/materials`)
.auth(ctx.token, auth)
.expect(200);
expect(listed.body[0].material).toMatchObject({
id: material.body.id,
name: 'Soy Wax',
});
await ctx
.api()
.put(`/api/v1/suppliers/${supplier.body.id}`)
.auth(ctx.token, auth)
.send({ ...supplierInput, active: false })
.expect(200);
await ctx
.api()
.put(`/api/v1/materials/${material.body.id}`)
.auth(ctx.token, auth)
.send({ ...materialInput, name: 'Refined Soy Wax' })
.expect(200);
const activeOnly = await ctx
.api()
.get('/api/v1/suppliers?active=true')
.auth(ctx.token, auth)
.expect(200);
expect(activeOnly.body).toHaveLength(0);
expect(
await ctx.db.auditEvent.count({
where: {
organizationId: ctx.owner.organizationId,
action: 'supplier_material.saved',
},
}),
).toBe(1);
});
it('enforces permissions, organization scope and strict validation', async () => {
const reader = await secondActor(ctx, true, ['procurement.read']);
await ctx
.api()
.get('/api/v1/suppliers')
.auth(reader.token, auth)
.expect(200);
await ctx
.api()
.post('/api/v1/suppliers')
.auth(reader.token, auth)
.send(supplierInput)
.expect(403);
const outsider = await secondActor(ctx, false, ['procurement.manage']);
await ctx
.api()
.put(`/api/v1/suppliers/${randomUUID()}`)
.auth(outsider.token, auth)
.send(supplierInput)
.expect(404);
const invalid = await ctx
.api()
.post('/api/v1/materials')
.auth(ctx.token, auth)
.send({ ...materialInput, code: '<unsafe>' })
.expect(400);
expect(invalid.body.code).toBe('REQUEST_INVALID');
const duplicateInput = {
...materialInput,
code: `SOY-${randomUUID().slice(0, 8)}`,
};
await ctx
.api()
.post('/api/v1/materials')
.auth(ctx.token, auth)
.send(duplicateInput)
.expect(201);
const duplicate = await ctx
.api()
.post('/api/v1/materials')
.auth(ctx.token, auth)
.send(duplicateInput)
.expect(409);
expect(duplicate.body.code).toBe('RECORD_CONFLICT');
});
it('returns distinct missing-resource errors for compatibility mutations', async () => {
const missingSupplier = await ctx
.api()
.put(`/api/v1/suppliers/${randomUUID()}/materials/${randomUUID()}`)
.auth(ctx.token, auth)
.send({
supplierSku: 'X',
leadTimeDays: 0,
minOrderQuantity: '1.000',
unitPrice: '0.00',
})
.expect(404);
expect(missingSupplier.body.code).toBe('SUPPLIER_NOT_FOUND');
const missingList = await ctx
.api()
.get(`/api/v1/suppliers/${randomUUID()}/materials`)
.auth(ctx.token, auth)
.expect(404);
expect(missingList.body.code).toBe('SUPPLIER_NOT_FOUND');
const supplier = await ctx
.api()
.post('/api/v1/suppliers')
.auth(ctx.token, auth)
.send({ ...supplierInput, code: 'S-' + randomUUID().slice(0, 8) })
.expect(201);
const missingMaterial = await ctx
.api()
.put(`/api/v1/suppliers/${supplier.body.id}/materials/${randomUUID()}`)
.auth(ctx.token, auth)
.send({
supplierSku: 'X',
leadTimeDays: 0,
minOrderQuantity: '1.000',
unitPrice: '0.00',
})
.expect(404);
expect(missingMaterial.body.code).toBe('MATERIAL_NOT_FOUND');
});
});