@humanagencyp/erp-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +51 -0
- package/README.md +148 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +787 -0
- package/package.json +51 -0
- package/src/db.ts +248 -0
- package/src/index.ts +302 -0
- package/src/money.ts +43 -0
- package/src/tools/customers.ts +50 -0
- package/src/tools/items.ts +36 -0
- package/src/tools/orders.ts +123 -0
- package/src/tools/quotes.ts +227 -0
- package/test/erp.test.ts +518 -0
- package/tsconfig.json +15 -0
package/test/erp.test.ts
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway binds its bounds (value_max, discount_max, ...) to the
|
|
3
|
+
* DECLARED value/discount_pct/currency on a tool call — it never inspects
|
|
4
|
+
* line items. So the connector's own recompute-and-refuse logic IS the
|
|
5
|
+
* enforcement boundary for the sales profile; a bug here means a bound is
|
|
6
|
+
* decorative. Per doc/engineering.md rule 4 ("test refusals harder than
|
|
7
|
+
* successes"), most of this file is refusal cases, one per property the
|
|
8
|
+
* connector must protect, plus a single happy path proving the whole
|
|
9
|
+
* quote -> send -> order flow (incl. stock reservation) actually works.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { rmSync } from "fs";
|
|
15
|
+
import { createDb, type Db } from "../src/db.js";
|
|
16
|
+
import { list_items, get_item } from "../src/tools/items.js";
|
|
17
|
+
import { find_customers, get_customer } from "../src/tools/customers.js";
|
|
18
|
+
import { create_quote, update_quote, send_quote, get_quote } from "../src/tools/quotes.js";
|
|
19
|
+
import { convert_quote_to_order, get_order } from "../src/tools/orders.js";
|
|
20
|
+
|
|
21
|
+
let dbPath: string;
|
|
22
|
+
let db: Db;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
dbPath = join(tmpdir(), `erp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
26
|
+
rmSync(dbPath, { force: true });
|
|
27
|
+
process.env.DATABASE_URL = dbPath;
|
|
28
|
+
db = await createDb();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await db.close();
|
|
33
|
+
rmSync(dbPath, { force: true });
|
|
34
|
+
delete process.env.DATABASE_URL;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// item-1 WIDGET-100 @ 25.00, stock 500
|
|
38
|
+
// cust-1 Nordkap Manufacturing GmbH, EUR, credit_limit 50000, open_balance 12000
|
|
39
|
+
// cust-5 Solstice Equipment Co, EUR, credit_limit 5000, open_balance 4800
|
|
40
|
+
const ITEM_100 = "item-1"; // 25.00 EUR
|
|
41
|
+
const ITEM_200 = "item-2"; // 45.00 EUR
|
|
42
|
+
const CUST_ROOMY = "cust-1"; // plenty of credit
|
|
43
|
+
const CUST_TIGHT = "cust-5"; // credit_limit 5000, open_balance 4800
|
|
44
|
+
|
|
45
|
+
describe("seed data", () => {
|
|
46
|
+
it("seeds a deterministic demo dataset on an empty DB", async () => {
|
|
47
|
+
const items = await list_items(db, {});
|
|
48
|
+
const customers = await find_customers(db, {});
|
|
49
|
+
expect(items.length).toBe(8);
|
|
50
|
+
expect(customers.length).toBe(5);
|
|
51
|
+
const widget = await get_item(db, { id: ITEM_100 });
|
|
52
|
+
expect(widget).toMatchObject({ sku: "WIDGET-100", list_price: 25, stock: 500 });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("create_quote — declared value must equal the recomputed net total", () => {
|
|
57
|
+
it("refuses when declared value is higher than the recomputed total", async () => {
|
|
58
|
+
// 2 * 25.00 = 50.00, but the caller declares 60
|
|
59
|
+
const result = await create_quote(db, {
|
|
60
|
+
customer_id: CUST_ROOMY,
|
|
61
|
+
lines: [{ item_id: ITEM_100, qty: 2 }],
|
|
62
|
+
discount_pct: 0,
|
|
63
|
+
value: 60,
|
|
64
|
+
currency: "EUR",
|
|
65
|
+
}).catch((e) => e);
|
|
66
|
+
expect(result).toBeInstanceOf(Error);
|
|
67
|
+
expect((result as Error).message).toMatch(/value/);
|
|
68
|
+
expect((result as Error).message).toContain("60");
|
|
69
|
+
expect((result as Error).message).toContain("50");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("refuses when declared value is lower than the recomputed total (agent under-declaring to dodge value_max)", async () => {
|
|
73
|
+
const result = await create_quote(db, {
|
|
74
|
+
customer_id: CUST_ROOMY,
|
|
75
|
+
lines: [{ item_id: ITEM_100, qty: 10 }], // 250.00
|
|
76
|
+
discount_pct: 0,
|
|
77
|
+
value: 1,
|
|
78
|
+
currency: "EUR",
|
|
79
|
+
}).catch((e) => e);
|
|
80
|
+
expect(result).toBeInstanceOf(Error);
|
|
81
|
+
expect((result as Error).message).toContain("1");
|
|
82
|
+
expect((result as Error).message).toContain("250");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("accepts a value within the 0.01 rounding tolerance", async () => {
|
|
86
|
+
const quote = await create_quote(db, {
|
|
87
|
+
customer_id: CUST_ROOMY,
|
|
88
|
+
lines: [{ item_id: ITEM_100, qty: 3 }], // 75.00
|
|
89
|
+
discount_pct: 0,
|
|
90
|
+
value: 75.005,
|
|
91
|
+
currency: "EUR",
|
|
92
|
+
});
|
|
93
|
+
expect(quote.net_total).toBe(75);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("applies discount_pct in the recomputation", async () => {
|
|
97
|
+
// 4 * 25.00 = 100.00, 10% off = 90.00
|
|
98
|
+
const quote = await create_quote(db, {
|
|
99
|
+
customer_id: CUST_ROOMY,
|
|
100
|
+
lines: [{ item_id: ITEM_100, qty: 4 }],
|
|
101
|
+
discount_pct: 10,
|
|
102
|
+
value: 90,
|
|
103
|
+
currency: "EUR",
|
|
104
|
+
});
|
|
105
|
+
expect(quote.net_total).toBe(90);
|
|
106
|
+
expect(quote.discount_pct).toBe(10);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("create_quote — discount_pct bounds", () => {
|
|
111
|
+
it("refuses a negative discount", async () => {
|
|
112
|
+
const result = await create_quote(db, {
|
|
113
|
+
customer_id: CUST_ROOMY,
|
|
114
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
115
|
+
discount_pct: -5,
|
|
116
|
+
value: 25,
|
|
117
|
+
currency: "EUR",
|
|
118
|
+
}).catch((e) => e);
|
|
119
|
+
expect(result).toBeInstanceOf(Error);
|
|
120
|
+
expect((result as Error).message).toMatch(/discount_pct/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("refuses a discount above 100", async () => {
|
|
124
|
+
const result = await create_quote(db, {
|
|
125
|
+
customer_id: CUST_ROOMY,
|
|
126
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
127
|
+
discount_pct: 150,
|
|
128
|
+
value: 25,
|
|
129
|
+
currency: "EUR",
|
|
130
|
+
}).catch((e) => e);
|
|
131
|
+
expect(result).toBeInstanceOf(Error);
|
|
132
|
+
expect((result as Error).message).toMatch(/discount_pct/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("create_quote — currency must match the customer's currency", () => {
|
|
137
|
+
it("refuses a currency that does not match the customer", async () => {
|
|
138
|
+
const result = await create_quote(db, {
|
|
139
|
+
customer_id: CUST_ROOMY, // EUR customer
|
|
140
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
141
|
+
discount_pct: 0,
|
|
142
|
+
value: 25,
|
|
143
|
+
currency: "USD",
|
|
144
|
+
}).catch((e) => e);
|
|
145
|
+
expect(result).toBeInstanceOf(Error);
|
|
146
|
+
expect((result as Error).message).toMatch(/currency/);
|
|
147
|
+
expect((result as Error).message).toContain("USD");
|
|
148
|
+
expect((result as Error).message).toContain("EUR");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("create_quote — unknown ids and bad quantities are refused", () => {
|
|
153
|
+
it("refuses an unknown customer_id", async () => {
|
|
154
|
+
const result = await create_quote(db, {
|
|
155
|
+
customer_id: "cust-does-not-exist",
|
|
156
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
157
|
+
discount_pct: 0,
|
|
158
|
+
value: 25,
|
|
159
|
+
currency: "EUR",
|
|
160
|
+
}).catch((e) => e);
|
|
161
|
+
expect(result).toBeInstanceOf(Error);
|
|
162
|
+
expect((result as Error).message).toMatch(/Unknown customer/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("refuses an unknown item_id", async () => {
|
|
166
|
+
const result = await create_quote(db, {
|
|
167
|
+
customer_id: CUST_ROOMY,
|
|
168
|
+
lines: [{ item_id: "item-does-not-exist", qty: 1 }],
|
|
169
|
+
discount_pct: 0,
|
|
170
|
+
value: 25,
|
|
171
|
+
currency: "EUR",
|
|
172
|
+
}).catch((e) => e);
|
|
173
|
+
expect(result).toBeInstanceOf(Error);
|
|
174
|
+
expect((result as Error).message).toMatch(/Unknown item/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("refuses a zero quantity", async () => {
|
|
178
|
+
const result = await create_quote(db, {
|
|
179
|
+
customer_id: CUST_ROOMY,
|
|
180
|
+
lines: [{ item_id: ITEM_100, qty: 0 }],
|
|
181
|
+
discount_pct: 0,
|
|
182
|
+
value: 0,
|
|
183
|
+
currency: "EUR",
|
|
184
|
+
}).catch((e) => e);
|
|
185
|
+
expect(result).toBeInstanceOf(Error);
|
|
186
|
+
expect((result as Error).message).toMatch(/qty/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("refuses a fractional quantity", async () => {
|
|
190
|
+
const result = await create_quote(db, {
|
|
191
|
+
customer_id: CUST_ROOMY,
|
|
192
|
+
lines: [{ item_id: ITEM_100, qty: 1.5 }],
|
|
193
|
+
discount_pct: 0,
|
|
194
|
+
value: 37.5,
|
|
195
|
+
currency: "EUR",
|
|
196
|
+
}).catch((e) => e);
|
|
197
|
+
expect(result).toBeInstanceOf(Error);
|
|
198
|
+
expect((result as Error).message).toMatch(/qty/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses an empty lines array", async () => {
|
|
202
|
+
const result = await create_quote(db, {
|
|
203
|
+
customer_id: CUST_ROOMY,
|
|
204
|
+
lines: [],
|
|
205
|
+
discount_pct: 0,
|
|
206
|
+
value: 0,
|
|
207
|
+
currency: "EUR",
|
|
208
|
+
}).catch((e) => e);
|
|
209
|
+
expect(result).toBeInstanceOf(Error);
|
|
210
|
+
expect((result as Error).message).toMatch(/lines/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("update_quote — state machine: only drafts can be updated", () => {
|
|
215
|
+
it("refuses to update a quote that has already been sent", async () => {
|
|
216
|
+
const quote = await create_quote(db, {
|
|
217
|
+
customer_id: CUST_ROOMY,
|
|
218
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
219
|
+
discount_pct: 0,
|
|
220
|
+
value: 25,
|
|
221
|
+
currency: "EUR",
|
|
222
|
+
});
|
|
223
|
+
await send_quote(db, { id: quote.id, value: 25, discount_pct: 0, currency: "EUR" });
|
|
224
|
+
|
|
225
|
+
const result = await update_quote(db, {
|
|
226
|
+
id: quote.id,
|
|
227
|
+
discount_pct: 0,
|
|
228
|
+
value: 25,
|
|
229
|
+
currency: "EUR",
|
|
230
|
+
notes: "trying to edit a sent quote",
|
|
231
|
+
}).catch((e) => e);
|
|
232
|
+
expect(result).toBeInstanceOf(Error);
|
|
233
|
+
expect((result as Error).message).toMatch(/only draft quotes can be updated/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("allows re-pricing a draft when lines change", async () => {
|
|
237
|
+
const quote = await create_quote(db, {
|
|
238
|
+
customer_id: CUST_ROOMY,
|
|
239
|
+
lines: [{ item_id: ITEM_100, qty: 1 }], // 25.00
|
|
240
|
+
discount_pct: 0,
|
|
241
|
+
value: 25,
|
|
242
|
+
currency: "EUR",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const updated = await update_quote(db, {
|
|
246
|
+
id: quote.id,
|
|
247
|
+
lines: [{ item_id: ITEM_200, qty: 2 }], // 90.00
|
|
248
|
+
discount_pct: 0,
|
|
249
|
+
value: 90,
|
|
250
|
+
currency: "EUR",
|
|
251
|
+
});
|
|
252
|
+
expect(updated.net_total).toBe(90);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("send_quote — state machine and frozen discount", () => {
|
|
257
|
+
it("refuses to send an already-sent quote", async () => {
|
|
258
|
+
const quote = await create_quote(db, {
|
|
259
|
+
customer_id: CUST_ROOMY,
|
|
260
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
261
|
+
discount_pct: 0,
|
|
262
|
+
value: 25,
|
|
263
|
+
currency: "EUR",
|
|
264
|
+
});
|
|
265
|
+
await send_quote(db, { id: quote.id, value: 25, discount_pct: 0, currency: "EUR" });
|
|
266
|
+
|
|
267
|
+
const result = await send_quote(db, { id: quote.id, value: 25, discount_pct: 0, currency: "EUR" }).catch(
|
|
268
|
+
(e) => e
|
|
269
|
+
);
|
|
270
|
+
expect(result).toBeInstanceOf(Error);
|
|
271
|
+
expect((result as Error).message).toMatch(/only draft quotes can be sent/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("refuses a discount_pct that differs from the quote's stored discount", async () => {
|
|
275
|
+
const quote = await create_quote(db, {
|
|
276
|
+
customer_id: CUST_ROOMY,
|
|
277
|
+
lines: [{ item_id: ITEM_100, qty: 4 }], // 100.00
|
|
278
|
+
discount_pct: 10, // 90.00
|
|
279
|
+
value: 90,
|
|
280
|
+
currency: "EUR",
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Caller tries to sneak in a bigger discount at send time than what was
|
|
284
|
+
// quoted — this must not silently take effect.
|
|
285
|
+
const result = await send_quote(db, { id: quote.id, value: 80, discount_pct: 20, currency: "EUR" }).catch(
|
|
286
|
+
(e) => e
|
|
287
|
+
);
|
|
288
|
+
expect(result).toBeInstanceOf(Error);
|
|
289
|
+
expect((result as Error).message).toMatch(/discount_pct/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("refuses a currency that differs from the quote's stored currency", async () => {
|
|
293
|
+
const quote = await create_quote(db, {
|
|
294
|
+
customer_id: CUST_ROOMY,
|
|
295
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
296
|
+
discount_pct: 0,
|
|
297
|
+
value: 25,
|
|
298
|
+
currency: "EUR",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const result = await send_quote(db, { id: quote.id, value: 25, discount_pct: 0, currency: "USD" }).catch(
|
|
302
|
+
(e) => e
|
|
303
|
+
);
|
|
304
|
+
expect(result).toBeInstanceOf(Error);
|
|
305
|
+
expect((result as Error).message).toMatch(/currency/);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("convert_quote_to_order — state machine", () => {
|
|
310
|
+
it("refuses to convert a quote still in draft (must be sent first)", async () => {
|
|
311
|
+
const quote = await create_quote(db, {
|
|
312
|
+
customer_id: CUST_ROOMY,
|
|
313
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
314
|
+
discount_pct: 0,
|
|
315
|
+
value: 25,
|
|
316
|
+
currency: "EUR",
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const result = await convert_quote_to_order(db, {
|
|
320
|
+
id: quote.id,
|
|
321
|
+
value: 25,
|
|
322
|
+
discount_pct: 0,
|
|
323
|
+
currency: "EUR",
|
|
324
|
+
}).catch((e) => e);
|
|
325
|
+
expect(result).toBeInstanceOf(Error);
|
|
326
|
+
expect((result as Error).message).toMatch(/only sent quotes can be converted/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("refuses double conversion of the same quote", async () => {
|
|
330
|
+
const quote = await create_quote(db, {
|
|
331
|
+
customer_id: CUST_ROOMY,
|
|
332
|
+
lines: [{ item_id: ITEM_100, qty: 1 }],
|
|
333
|
+
discount_pct: 0,
|
|
334
|
+
value: 25,
|
|
335
|
+
currency: "EUR",
|
|
336
|
+
});
|
|
337
|
+
await send_quote(db, { id: quote.id, value: 25, discount_pct: 0, currency: "EUR" });
|
|
338
|
+
await convert_quote_to_order(db, { id: quote.id, value: 25, discount_pct: 0, currency: "EUR" });
|
|
339
|
+
|
|
340
|
+
const result = await convert_quote_to_order(db, {
|
|
341
|
+
id: quote.id,
|
|
342
|
+
value: 25,
|
|
343
|
+
discount_pct: 0,
|
|
344
|
+
currency: "EUR",
|
|
345
|
+
}).catch((e) => e);
|
|
346
|
+
expect(result).toBeInstanceOf(Error);
|
|
347
|
+
expect((result as Error).message).toMatch(/only sent quotes can be converted/);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("convert_quote_to_order — credit limit", () => {
|
|
352
|
+
it("refuses when open_balance + net_total would exceed the customer's credit limit", async () => {
|
|
353
|
+
// cust-5: credit_limit 5000, open_balance 4800 — 300 more tips it over.
|
|
354
|
+
const quote = await create_quote(db, {
|
|
355
|
+
customer_id: CUST_TIGHT,
|
|
356
|
+
lines: [{ item_id: ITEM_200, qty: 10 }], // 450.00 (< available credit)
|
|
357
|
+
discount_pct: 0,
|
|
358
|
+
value: 450,
|
|
359
|
+
currency: "EUR",
|
|
360
|
+
});
|
|
361
|
+
await send_quote(db, { id: quote.id, value: 450, discount_pct: 0, currency: "EUR" });
|
|
362
|
+
|
|
363
|
+
const result = await convert_quote_to_order(db, {
|
|
364
|
+
id: quote.id,
|
|
365
|
+
value: 450,
|
|
366
|
+
discount_pct: 0,
|
|
367
|
+
currency: "EUR",
|
|
368
|
+
}).catch((e) => e);
|
|
369
|
+
expect(result).toBeInstanceOf(Error);
|
|
370
|
+
expect((result as Error).message).toMatch(/credit limit/);
|
|
371
|
+
expect((result as Error).message).toContain("4800");
|
|
372
|
+
expect((result as Error).message).toContain("5000");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("allows conversion that stays within the credit limit", async () => {
|
|
376
|
+
const quote = await create_quote(db, {
|
|
377
|
+
customer_id: CUST_TIGHT,
|
|
378
|
+
lines: [{ item_id: ITEM_100, qty: 4 }], // 100.00, well within 200 available
|
|
379
|
+
discount_pct: 0,
|
|
380
|
+
value: 100,
|
|
381
|
+
currency: "EUR",
|
|
382
|
+
});
|
|
383
|
+
await send_quote(db, { id: quote.id, value: 100, discount_pct: 0, currency: "EUR" });
|
|
384
|
+
|
|
385
|
+
const order = await convert_quote_to_order(db, {
|
|
386
|
+
id: quote.id,
|
|
387
|
+
value: 100,
|
|
388
|
+
discount_pct: 0,
|
|
389
|
+
currency: "EUR",
|
|
390
|
+
});
|
|
391
|
+
expect(order.status).toBe("confirmed");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("does not reserve stock when the credit check refuses the conversion", async () => {
|
|
395
|
+
const before = await get_item(db, { id: ITEM_200 });
|
|
396
|
+
const quote = await create_quote(db, {
|
|
397
|
+
customer_id: CUST_TIGHT,
|
|
398
|
+
lines: [{ item_id: ITEM_200, qty: 10 }], // exceeds credit limit
|
|
399
|
+
discount_pct: 0,
|
|
400
|
+
value: 450,
|
|
401
|
+
currency: "EUR",
|
|
402
|
+
});
|
|
403
|
+
await send_quote(db, { id: quote.id, value: 450, discount_pct: 0, currency: "EUR" });
|
|
404
|
+
await convert_quote_to_order(db, { id: quote.id, value: 450, discount_pct: 0, currency: "EUR" }).catch(
|
|
405
|
+
(e) => e
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const after = await get_item(db, { id: ITEM_200 });
|
|
409
|
+
expect(after.stock).toBe(before.stock);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("convert_quote_to_order — stock", () => {
|
|
414
|
+
it("refuses when a line exceeds available stock", async () => {
|
|
415
|
+
// item-4 GEAR-B2 has stock 60
|
|
416
|
+
const quote = await create_quote(db, {
|
|
417
|
+
customer_id: CUST_ROOMY,
|
|
418
|
+
lines: [{ item_id: "item-4", qty: 61 }],
|
|
419
|
+
discount_pct: 0,
|
|
420
|
+
value: 150 * 61,
|
|
421
|
+
currency: "EUR",
|
|
422
|
+
});
|
|
423
|
+
await send_quote(db, { id: quote.id, value: 150 * 61, discount_pct: 0, currency: "EUR" });
|
|
424
|
+
|
|
425
|
+
const result = await convert_quote_to_order(db, {
|
|
426
|
+
id: quote.id,
|
|
427
|
+
value: 150 * 61,
|
|
428
|
+
discount_pct: 0,
|
|
429
|
+
currency: "EUR",
|
|
430
|
+
}).catch((e) => e);
|
|
431
|
+
expect(result).toBeInstanceOf(Error);
|
|
432
|
+
expect((result as Error).message).toMatch(/insufficient stock/);
|
|
433
|
+
expect((result as Error).message).toContain("GEAR-B2");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("refuses the whole order when only one of several lines lacks stock — no partial reservation", async () => {
|
|
437
|
+
const quote = await create_quote(db, {
|
|
438
|
+
customer_id: CUST_ROOMY,
|
|
439
|
+
lines: [
|
|
440
|
+
{ item_id: ITEM_100, qty: 5 }, // plenty of stock
|
|
441
|
+
{ item_id: "item-4", qty: 61 }, // exceeds stock (60)
|
|
442
|
+
],
|
|
443
|
+
discount_pct: 0,
|
|
444
|
+
value: 25 * 5 + 150 * 61,
|
|
445
|
+
currency: "EUR",
|
|
446
|
+
});
|
|
447
|
+
await send_quote(db, { id: quote.id, value: 25 * 5 + 150 * 61, discount_pct: 0, currency: "EUR" });
|
|
448
|
+
|
|
449
|
+
const widgetBefore = await get_item(db, { id: ITEM_100 });
|
|
450
|
+
const result = await convert_quote_to_order(db, {
|
|
451
|
+
id: quote.id,
|
|
452
|
+
value: 25 * 5 + 150 * 61,
|
|
453
|
+
discount_pct: 0,
|
|
454
|
+
currency: "EUR",
|
|
455
|
+
}).catch((e) => e);
|
|
456
|
+
expect(result).toBeInstanceOf(Error);
|
|
457
|
+
|
|
458
|
+
const widgetAfter = await get_item(db, { id: ITEM_100 });
|
|
459
|
+
expect(widgetAfter.stock).toBe(widgetBefore.stock); // the in-stock line was NOT reserved either
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("happy path — quote -> send -> convert reserves stock", () => {
|
|
464
|
+
it("carries a document through its full lifecycle", async () => {
|
|
465
|
+
const widgetBefore = await get_item(db, { id: ITEM_100 });
|
|
466
|
+
|
|
467
|
+
const quote = await create_quote(db, {
|
|
468
|
+
customer_id: CUST_ROOMY,
|
|
469
|
+
lines: [{ item_id: ITEM_100, qty: 5 }], // 125.00
|
|
470
|
+
discount_pct: 0,
|
|
471
|
+
value: 125,
|
|
472
|
+
currency: "EUR",
|
|
473
|
+
notes: "Q3 restock",
|
|
474
|
+
});
|
|
475
|
+
expect(quote.status).toBe("draft");
|
|
476
|
+
expect(quote.net_total).toBe(125);
|
|
477
|
+
|
|
478
|
+
const sent = await send_quote(db, { id: quote.id, value: 125, discount_pct: 0, currency: "EUR" });
|
|
479
|
+
expect(sent.status).toBe("sent");
|
|
480
|
+
expect(sent.sent_at).toBeTruthy();
|
|
481
|
+
|
|
482
|
+
const order = await convert_quote_to_order(db, {
|
|
483
|
+
id: quote.id,
|
|
484
|
+
value: 125,
|
|
485
|
+
discount_pct: 0,
|
|
486
|
+
currency: "EUR",
|
|
487
|
+
requested_delivery: "2026-11-01",
|
|
488
|
+
});
|
|
489
|
+
expect(order.status).toBe("confirmed");
|
|
490
|
+
expect(order.net_total).toBe(125);
|
|
491
|
+
expect(order.lines).toHaveLength(1);
|
|
492
|
+
expect(order.lines[0]).toMatchObject({ item_id: ITEM_100, qty: 5 });
|
|
493
|
+
|
|
494
|
+
const widgetAfter = await get_item(db, { id: ITEM_100 });
|
|
495
|
+
expect(widgetAfter.stock).toBe(widgetBefore.stock - 5);
|
|
496
|
+
|
|
497
|
+
const quoteAfter = await get_quote(db, { id: quote.id });
|
|
498
|
+
expect(quoteAfter.status).toBe("converted");
|
|
499
|
+
|
|
500
|
+
const orderBack = await get_order(db, { id: order.id });
|
|
501
|
+
expect(orderBack.number).toMatch(/^O-\d{4}$/);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("reads", () => {
|
|
506
|
+
it("get_customer reports available_credit derived from credit_limit and open_balance", async () => {
|
|
507
|
+
const customer = await get_customer(db, { id: CUST_TIGHT });
|
|
508
|
+
expect(customer.credit_limit).toBe(5000);
|
|
509
|
+
expect(customer.open_balance).toBe(4800);
|
|
510
|
+
expect(customer.available_credit).toBe(200);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("get_item refuses an unknown id instead of returning undefined", async () => {
|
|
514
|
+
const result = await get_item(db, { id: "does-not-exist" }).catch((e) => e);
|
|
515
|
+
expect(result).toBeInstanceOf(Error);
|
|
516
|
+
expect((result as Error).message).toMatch(/Unknown item/);
|
|
517
|
+
});
|
|
518
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"resolveJsonModule": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|