@kirjapro/mcp-server 0.7.0 → 0.8.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/CHANGELOG.md +96 -0
- package/README.md +46 -4
- package/dist/index.js +1278 -611
- package/package.json +1 -1
- package/server.json +5 -1
- package/tool-fingerprints.json +52 -52
- package/dist/accounts.d.ts +0 -40
- package/dist/accounts.js +0 -45
- package/dist/auth.d.ts +0 -17
- package/dist/auth.js +0 -245
- package/dist/compliance/rates.d.ts +0 -53
- package/dist/compliance/rates.js +0 -57
- package/dist/database.types.d.ts +0 -6525
- package/dist/database.types.js +0 -20
- package/dist/index.d.ts +0 -2
- package/dist/lib/incomeStatementSynthesis.d.ts +0 -47
- package/dist/lib/incomeStatementSynthesis.js +0 -140
- package/dist/resources/company.d.ts +0 -2
- package/dist/resources/company.js +0 -75
- package/dist/resources/compliance.d.ts +0 -2
- package/dist/resources/compliance.js +0 -338
- package/dist/resources/index.d.ts +0 -2
- package/dist/resources/index.js +0 -10
- package/dist/resources/schema.d.ts +0 -2
- package/dist/resources/schema.js +0 -63
- package/dist/resources/security.d.ts +0 -2
- package/dist/resources/security.js +0 -33
- package/dist/schemas.d.ts +0 -12
- package/dist/schemas.js +0 -12
- package/dist/tools/accounts.d.ts +0 -2
- package/dist/tools/accounts.js +0 -48
- package/dist/tools/banking.d.ts +0 -2
- package/dist/tools/banking.js +0 -196
- package/dist/tools/bookings.d.ts +0 -2
- package/dist/tools/bookings.js +0 -294
- package/dist/tools/customers.d.ts +0 -2
- package/dist/tools/customers.js +0 -320
- package/dist/tools/financialNotes.d.ts +0 -2
- package/dist/tools/financialNotes.js +0 -255
- package/dist/tools/fingerprints.d.ts +0 -6
- package/dist/tools/fingerprints.js +0 -28
- package/dist/tools/fiscal-years.d.ts +0 -2
- package/dist/tools/fiscal-years.js +0 -35
- package/dist/tools/index.d.ts +0 -9
- package/dist/tools/index.js +0 -105
- package/dist/tools/invoices.d.ts +0 -2
- package/dist/tools/invoices.js +0 -489
- package/dist/tools/offers.d.ts +0 -2
- package/dist/tools/offers.js +0 -443
- package/dist/tools/received-invoices.d.ts +0 -2
- package/dist/tools/received-invoices.js +0 -134
- package/dist/tools/recurring-invoices.d.ts +0 -2
- package/dist/tools/recurring-invoices.js +0 -348
- package/dist/tools/reports.d.ts +0 -2
- package/dist/tools/reports.js +0 -869
- package/dist/tools/templates.d.ts +0 -89
- package/dist/tools/templates.js +0 -636
- package/dist/tools/transactions.d.ts +0 -2
- package/dist/tools/transactions.js +0 -597
- package/dist/tools/validation.d.ts +0 -26
- package/dist/tools/validation.js +0 -127
- package/dist/types.d.ts +0 -388
- package/dist/types.js +0 -34
- package/dist/utils.d.ts +0 -276
- package/dist/utils.js +0 -637
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -3
package/dist/tools/invoices.js
DELETED
|
@@ -1,489 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { getSupabase, getCompanyId } from "../auth.js";
|
|
3
|
-
import { addDaysToIsoDate, clampPagination, helsinkiDateString, paginate, toErrorContent, toDryRunResponse, toResult, sanitizeDbError, roundCents, tagCustomer, withIdempotency, IDEMPOTENCY_KEY_SCHEMA, ISO_DATE_SCHEMA, DRY_RUN_SCHEMA, READ_ONLY_ANNOTATIONS, WRITE_ANNOTATIONS, DESTRUCTIVE_ANNOTATIONS, } from "../utils.js";
|
|
4
|
-
import { VALID_TRANSITIONS } from "../types.js";
|
|
5
|
-
/**
|
|
6
|
-
* Generate Finnish reference number (viitenumero) with 7-3-1 check digit.
|
|
7
|
-
*/
|
|
8
|
-
function generateReferenceNumber(baseNumber) {
|
|
9
|
-
const digits = baseNumber.replace(/\D/g, "");
|
|
10
|
-
if (digits.length < 1 || digits.length > 19) {
|
|
11
|
-
throw new Error("Base number must be 1-19 digits");
|
|
12
|
-
}
|
|
13
|
-
const weights = [7, 3, 1];
|
|
14
|
-
let sum = 0;
|
|
15
|
-
for (let i = digits.length - 1, w = 0; i >= 0; i--, w++) {
|
|
16
|
-
sum += parseInt(digits[i]) * weights[w % 3];
|
|
17
|
-
}
|
|
18
|
-
const checkDigit = (10 - (sum % 10)) % 10;
|
|
19
|
-
return digits + checkDigit;
|
|
20
|
-
}
|
|
21
|
-
const LIST_INVOICES_ROW_SHAPE = {
|
|
22
|
-
id: z.string().uuid(),
|
|
23
|
-
invoice_number: z.string(),
|
|
24
|
-
reference_number: z.string().nullable(),
|
|
25
|
-
invoice_date: z.string(),
|
|
26
|
-
due_date: z.string().nullable(),
|
|
27
|
-
status: z.string(),
|
|
28
|
-
subtotal: z.number(),
|
|
29
|
-
vat_total: z.number(),
|
|
30
|
-
total: z.number(),
|
|
31
|
-
notes: z.string().nullable(),
|
|
32
|
-
customers: z
|
|
33
|
-
.object({
|
|
34
|
-
name: z.string().nullable(),
|
|
35
|
-
business_id: z.string().nullable(),
|
|
36
|
-
})
|
|
37
|
-
.nullable(),
|
|
38
|
-
};
|
|
39
|
-
const GET_INVOICE_OUTPUT_SHAPE = {
|
|
40
|
-
id: z.string().uuid(),
|
|
41
|
-
invoice_number: z.string(),
|
|
42
|
-
reference_number: z.string().nullable(),
|
|
43
|
-
invoice_date: z.string(),
|
|
44
|
-
due_date: z.string().nullable(),
|
|
45
|
-
status: z.string(),
|
|
46
|
-
subtotal: z.number(),
|
|
47
|
-
vat_total: z.number(),
|
|
48
|
-
total: z.number(),
|
|
49
|
-
notes: z.string().nullable(),
|
|
50
|
-
created_at: z.string().nullable(),
|
|
51
|
-
// `.optional()` is explicit since Zod 4 — bare `z.unknown()` was implicitly
|
|
52
|
-
// key-optional in Zod 3 but is now required. These three are joined relations
|
|
53
|
-
// that may be absent when the row is fetched without the corresponding
|
|
54
|
-
// .select() include.
|
|
55
|
-
customers: z.unknown().optional(),
|
|
56
|
-
invoice_lines: z.unknown().optional(),
|
|
57
|
-
invoice_payments: z.unknown().optional(),
|
|
58
|
-
vat_breakdown: z.array(z.object({
|
|
59
|
-
vat_rate: z.number(),
|
|
60
|
-
subtotal: z.number(),
|
|
61
|
-
vat_amount: z.number(),
|
|
62
|
-
total: z.number(),
|
|
63
|
-
})),
|
|
64
|
-
paid_total: z.number(),
|
|
65
|
-
remaining_balance: z.number(),
|
|
66
|
-
};
|
|
67
|
-
export function registerInvoiceTools(server) {
|
|
68
|
-
// ---- READ: List Invoices ----
|
|
69
|
-
server.registerTool("kirjapro_list_invoices", {
|
|
70
|
-
description: "List invoices with customer names. Filter by status (draft, sent, paid, overdue, cancelled).",
|
|
71
|
-
inputSchema: {
|
|
72
|
-
status: z
|
|
73
|
-
.enum(["draft", "sent", "paid", "partial", "overdue", "cancelled"])
|
|
74
|
-
.optional()
|
|
75
|
-
.describe("Filter by invoice status"),
|
|
76
|
-
limit: z
|
|
77
|
-
.number()
|
|
78
|
-
.min(1)
|
|
79
|
-
.max(200)
|
|
80
|
-
.default(50)
|
|
81
|
-
.describe("Max results (default 50)"),
|
|
82
|
-
offset: z.number().min(0).default(0).describe("Skip first N results"),
|
|
83
|
-
},
|
|
84
|
-
outputSchema: {
|
|
85
|
-
results: z.array(z.object(LIST_INVOICES_ROW_SHAPE).passthrough()),
|
|
86
|
-
total_count: z.number(),
|
|
87
|
-
has_more: z.boolean(),
|
|
88
|
-
next_offset: z.number(),
|
|
89
|
-
},
|
|
90
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
91
|
-
}, async (params) => {
|
|
92
|
-
const supabase = getSupabase();
|
|
93
|
-
const companyId = getCompanyId();
|
|
94
|
-
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
95
|
-
let query = supabase
|
|
96
|
-
.from("invoices")
|
|
97
|
-
.select(`
|
|
98
|
-
id, invoice_number, reference_number, invoice_date, due_date,
|
|
99
|
-
status, subtotal, vat_total, total, notes,
|
|
100
|
-
customers ( name, business_id )
|
|
101
|
-
`, { count: "exact" })
|
|
102
|
-
.eq("company_id", companyId)
|
|
103
|
-
.order("invoice_date", { ascending: false })
|
|
104
|
-
.order("id", { ascending: false });
|
|
105
|
-
if (params.status) {
|
|
106
|
-
query = query.eq("status", params.status);
|
|
107
|
-
}
|
|
108
|
-
query = query.range(offset, offset + limit - 1);
|
|
109
|
-
const { data, count, error } = await query;
|
|
110
|
-
if (error) {
|
|
111
|
-
return toErrorContent(sanitizeDbError("Failed to list invoices", error));
|
|
112
|
-
}
|
|
113
|
-
const tagged = (data || []).map((row) => ({
|
|
114
|
-
...row,
|
|
115
|
-
customers: tagCustomer(row.customers),
|
|
116
|
-
}));
|
|
117
|
-
return toResult(paginate(tagged, count || 0, offset, limit));
|
|
118
|
-
});
|
|
119
|
-
// ---- WRITE: Create Invoice ----
|
|
120
|
-
server.registerTool("kirjapro_create_invoice", {
|
|
121
|
-
description: "Create a new sales invoice with line items. Automatically generates invoice number, reference number (viitenumero), and calculates VAT. Returns the complete invoice.",
|
|
122
|
-
inputSchema: {
|
|
123
|
-
customerId: z.string().uuid().describe("Customer UUID"),
|
|
124
|
-
invoiceDate: ISO_DATE_SCHEMA
|
|
125
|
-
.describe("Invoice date (YYYY-MM-DD)"),
|
|
126
|
-
paymentTermDays: z
|
|
127
|
-
.number()
|
|
128
|
-
.min(0)
|
|
129
|
-
.max(365)
|
|
130
|
-
.default(14)
|
|
131
|
-
.describe("Payment term in days (default 14)"),
|
|
132
|
-
notes: z.string().optional().describe("Notes printed on the invoice"),
|
|
133
|
-
lines: z
|
|
134
|
-
.array(z.object({
|
|
135
|
-
description: z.string().min(1).describe("Line item description"),
|
|
136
|
-
quantity: z.number().min(0).describe("Quantity"),
|
|
137
|
-
unit: z.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
|
|
138
|
-
unitPrice: z
|
|
139
|
-
.number()
|
|
140
|
-
.finite()
|
|
141
|
-
.min(-1_000_000_000)
|
|
142
|
-
.max(1_000_000_000)
|
|
143
|
-
.describe("Unit price in EUR (excl. VAT). Can be negative for credit lines."),
|
|
144
|
-
vatRate: z
|
|
145
|
-
.enum(["25.5", "13.5", "10", "0"])
|
|
146
|
-
.default("25.5")
|
|
147
|
-
.describe("VAT rate"),
|
|
148
|
-
}))
|
|
149
|
-
.min(1)
|
|
150
|
-
.describe("Invoice line items"),
|
|
151
|
-
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
152
|
-
},
|
|
153
|
-
annotations: WRITE_ANNOTATIONS,
|
|
154
|
-
}, async (params) => {
|
|
155
|
-
const supabase = getSupabase();
|
|
156
|
-
const companyId = getCompanyId();
|
|
157
|
-
return withIdempotency(supabase, companyId, "kirjapro_create_invoice", params.idempotencyKey, params, async () => {
|
|
158
|
-
// Verify customer exists
|
|
159
|
-
const { data: customer, error: custError } = await supabase
|
|
160
|
-
.from("customers")
|
|
161
|
-
.select("id, name")
|
|
162
|
-
.eq("id", params.customerId)
|
|
163
|
-
.eq("company_id", companyId)
|
|
164
|
-
.single();
|
|
165
|
-
if (custError || !customer) {
|
|
166
|
-
return toErrorContent(`Customer ${params.customerId} not found. ` +
|
|
167
|
-
`Use kirjapro_list_customers to find customer IDs.`);
|
|
168
|
-
}
|
|
169
|
-
// Get next invoice number atomically (advisory-locked)
|
|
170
|
-
const { data: invoiceNumber, error: numError } = await supabase.rpc("get_next_invoice_number", {
|
|
171
|
-
p_company_id: companyId,
|
|
172
|
-
});
|
|
173
|
-
if (numError || !invoiceNumber) {
|
|
174
|
-
return toErrorContent(numError ? sanitizeDbError("Failed to generate invoice number", numError) : "Failed to generate invoice number.");
|
|
175
|
-
}
|
|
176
|
-
const referenceNumber = generateReferenceNumber(invoiceNumber.replace(/\D/g, ""));
|
|
177
|
-
const dueDateStr = addDaysToIsoDate(params.invoiceDate, params.paymentTermDays);
|
|
178
|
-
// Calculate line totals
|
|
179
|
-
const calculatedLines = params.lines.map((line, i) => {
|
|
180
|
-
const vatRate = parseFloat(line.vatRate);
|
|
181
|
-
const subtotal = roundCents(line.quantity * line.unitPrice);
|
|
182
|
-
const vatAmount = roundCents(subtotal * (vatRate / 100));
|
|
183
|
-
const total = roundCents(subtotal + vatAmount);
|
|
184
|
-
return {
|
|
185
|
-
line_number: i + 1,
|
|
186
|
-
description: line.description,
|
|
187
|
-
quantity: line.quantity,
|
|
188
|
-
unit: line.unit,
|
|
189
|
-
unit_price: line.unitPrice,
|
|
190
|
-
vat_rate: vatRate,
|
|
191
|
-
subtotal,
|
|
192
|
-
vat_amount: vatAmount,
|
|
193
|
-
total,
|
|
194
|
-
};
|
|
195
|
-
});
|
|
196
|
-
const subtotal = roundCents(calculatedLines.reduce((s, l) => s + l.subtotal, 0));
|
|
197
|
-
const vatTotal = roundCents(calculatedLines.reduce((s, l) => s + l.vat_amount, 0));
|
|
198
|
-
const total = roundCents(subtotal + vatTotal);
|
|
199
|
-
const { data: invoiceId, error: invError } = await supabase.rpc("create_invoice_with_lines", {
|
|
200
|
-
p_company_id: companyId,
|
|
201
|
-
p_customer_id: params.customerId,
|
|
202
|
-
p_invoice_number: invoiceNumber,
|
|
203
|
-
p_reference_number: referenceNumber,
|
|
204
|
-
p_invoice_date: params.invoiceDate,
|
|
205
|
-
p_due_date: dueDateStr,
|
|
206
|
-
p_status: "draft",
|
|
207
|
-
p_subtotal: subtotal,
|
|
208
|
-
p_vat_total: vatTotal,
|
|
209
|
-
p_total: total,
|
|
210
|
-
p_notes: params.notes || "",
|
|
211
|
-
p_lines: calculatedLines,
|
|
212
|
-
});
|
|
213
|
-
if (invError || !invoiceId) {
|
|
214
|
-
return toErrorContent(invError ? sanitizeDbError("Failed to create invoice", invError) : "Failed to create invoice.");
|
|
215
|
-
}
|
|
216
|
-
const { data: invoice, error: fetchError } = await supabase
|
|
217
|
-
.from("invoices")
|
|
218
|
-
.select("id, invoice_number, reference_number, invoice_date, due_date, status, subtotal, vat_total, total, notes, created_at")
|
|
219
|
-
.eq("id", invoiceId)
|
|
220
|
-
.eq("company_id", companyId)
|
|
221
|
-
.single();
|
|
222
|
-
if (fetchError || !invoice) {
|
|
223
|
-
return toErrorContent(fetchError ? sanitizeDbError("Invoice created but fetch failed", fetchError) : "Invoice created but could not be retrieved.");
|
|
224
|
-
}
|
|
225
|
-
return toResult({
|
|
226
|
-
message: `Invoice ${invoiceNumber} created for ${customer.name}`,
|
|
227
|
-
invoice: {
|
|
228
|
-
...invoice,
|
|
229
|
-
customer_name: customer.name,
|
|
230
|
-
lines: calculatedLines,
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
// ---- WRITE: Record Payment ----
|
|
236
|
-
server.registerTool("kirjapro_record_payment", {
|
|
237
|
-
description: 'Record a payment against an invoice. Automatically updates invoice status to "paid" when fully paid, or keeps "sent" if partially paid. Returns payment details and updated invoice status.',
|
|
238
|
-
inputSchema: {
|
|
239
|
-
invoiceId: z.string().uuid().describe("Invoice UUID"),
|
|
240
|
-
paymentDate: ISO_DATE_SCHEMA
|
|
241
|
-
.describe("Payment date (YYYY-MM-DD)"),
|
|
242
|
-
amount: z
|
|
243
|
-
.number()
|
|
244
|
-
.finite()
|
|
245
|
-
.min(0.01)
|
|
246
|
-
.max(1_000_000_000)
|
|
247
|
-
.describe("Payment amount in EUR"),
|
|
248
|
-
paymentMethod: z
|
|
249
|
-
.string()
|
|
250
|
-
.default("bank_transfer")
|
|
251
|
-
.describe("Payment method (bank_transfer, cash, card)"),
|
|
252
|
-
reference: z.string().optional().describe("Payment reference"),
|
|
253
|
-
notes: z.string().optional().describe("Payment notes"),
|
|
254
|
-
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
255
|
-
},
|
|
256
|
-
annotations: WRITE_ANNOTATIONS,
|
|
257
|
-
}, async (params) => {
|
|
258
|
-
const supabase = getSupabase();
|
|
259
|
-
const companyId = getCompanyId();
|
|
260
|
-
return withIdempotency(supabase, companyId, "kirjapro_record_payment", params.idempotencyKey, params, async () => {
|
|
261
|
-
// Fetch invoice with existing payments
|
|
262
|
-
const { data: invoice, error: invError } = await supabase
|
|
263
|
-
.from("invoices")
|
|
264
|
-
.select(`
|
|
265
|
-
id, invoice_number, total, status,
|
|
266
|
-
invoice_payments ( amount )
|
|
267
|
-
`)
|
|
268
|
-
.eq("id", params.invoiceId)
|
|
269
|
-
.eq("company_id", companyId)
|
|
270
|
-
.single();
|
|
271
|
-
if (invError || !invoice) {
|
|
272
|
-
return toErrorContent(`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`);
|
|
273
|
-
}
|
|
274
|
-
if (invoice.status === "paid") {
|
|
275
|
-
return toErrorContent(`Invoice ${invoice.invoice_number} is already fully paid.`);
|
|
276
|
-
}
|
|
277
|
-
if (invoice.status === "cancelled") {
|
|
278
|
-
return toErrorContent(`Invoice ${invoice.invoice_number} is cancelled and cannot accept payments.`);
|
|
279
|
-
}
|
|
280
|
-
const paidSoFar = roundCents((invoice.invoice_payments || []).reduce((s, p) => s + (p.amount || 0), 0));
|
|
281
|
-
const remaining = roundCents(invoice.total - paidSoFar);
|
|
282
|
-
if (params.amount > remaining + 0.01) {
|
|
283
|
-
return toErrorContent(`Payment amount ${params.amount.toFixed(2)} exceeds remaining balance ${remaining.toFixed(2)} EUR ` +
|
|
284
|
-
`on invoice ${invoice.invoice_number} (total: ${invoice.total.toFixed(2)}, paid: ${paidSoFar.toFixed(2)}). ` +
|
|
285
|
-
`Reduce the payment amount to ${remaining.toFixed(2)} or less.`);
|
|
286
|
-
}
|
|
287
|
-
const { data: paymentResult, error: payError } = await supabase.rpc("record_invoice_payment", {
|
|
288
|
-
p_company_id: companyId,
|
|
289
|
-
p_invoice_id: params.invoiceId,
|
|
290
|
-
p_payment_date: params.paymentDate,
|
|
291
|
-
p_amount: params.amount,
|
|
292
|
-
p_payment_method: params.paymentMethod,
|
|
293
|
-
p_reference: params.reference || undefined,
|
|
294
|
-
p_notes: params.notes || undefined,
|
|
295
|
-
});
|
|
296
|
-
if (payError || !paymentResult) {
|
|
297
|
-
return toErrorContent(payError ? sanitizeDbError("Failed to record payment", payError) : "Failed to record payment.");
|
|
298
|
-
}
|
|
299
|
-
const result = paymentResult;
|
|
300
|
-
return toResult({
|
|
301
|
-
message: `Payment of ${params.amount.toFixed(2)} EUR recorded on invoice ${invoice.invoice_number}`,
|
|
302
|
-
payment_id: result.payment_id,
|
|
303
|
-
invoice_status: result.invoice_status,
|
|
304
|
-
remaining_balance: result.remaining_balance,
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
// ---- READ: Get Invoice Detail ----
|
|
309
|
-
server.registerTool("kirjapro_get_invoice", {
|
|
310
|
-
description: "Get a single invoice with full detail: customer info, line items, payments, VAT breakdown, and remaining balance.",
|
|
311
|
-
inputSchema: {
|
|
312
|
-
invoiceId: z.string().uuid().describe("Invoice UUID"),
|
|
313
|
-
},
|
|
314
|
-
outputSchema: GET_INVOICE_OUTPUT_SHAPE,
|
|
315
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
316
|
-
}, async (params) => {
|
|
317
|
-
const supabase = getSupabase();
|
|
318
|
-
const companyId = getCompanyId();
|
|
319
|
-
const { data: invoice, error } = await supabase
|
|
320
|
-
.from("invoices")
|
|
321
|
-
.select(`
|
|
322
|
-
id, invoice_number, reference_number, invoice_date, due_date,
|
|
323
|
-
status, subtotal, vat_total, total, notes, created_at,
|
|
324
|
-
customers ( id, name, business_id, email, address, postal_code, city ),
|
|
325
|
-
invoice_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
|
|
326
|
-
invoice_payments ( id, payment_date, amount, payment_method, reference )
|
|
327
|
-
`)
|
|
328
|
-
.eq("id", params.invoiceId)
|
|
329
|
-
.eq("company_id", companyId)
|
|
330
|
-
.single();
|
|
331
|
-
if (error || !invoice) {
|
|
332
|
-
return toErrorContent(`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`);
|
|
333
|
-
}
|
|
334
|
-
// Calculate VAT breakdown by rate
|
|
335
|
-
const vatMap = new Map();
|
|
336
|
-
for (const line of invoice
|
|
337
|
-
.invoice_lines || []) {
|
|
338
|
-
const existing = vatMap.get(line.vat_rate) || {
|
|
339
|
-
vat_rate: line.vat_rate,
|
|
340
|
-
subtotal: 0,
|
|
341
|
-
vat_amount: 0,
|
|
342
|
-
total: 0,
|
|
343
|
-
};
|
|
344
|
-
existing.subtotal += line.subtotal || 0;
|
|
345
|
-
existing.vat_amount += line.vat_amount || 0;
|
|
346
|
-
existing.total += line.total || 0;
|
|
347
|
-
vatMap.set(line.vat_rate, existing);
|
|
348
|
-
}
|
|
349
|
-
const vatBreakdown = [...vatMap.values()].map((v) => ({
|
|
350
|
-
...v,
|
|
351
|
-
subtotal: roundCents(v.subtotal),
|
|
352
|
-
vat_amount: roundCents(v.vat_amount),
|
|
353
|
-
total: roundCents(v.total),
|
|
354
|
-
}));
|
|
355
|
-
// Calculate payment totals
|
|
356
|
-
const payments = invoice.invoice_payments || [];
|
|
357
|
-
const paidTotal = roundCents(payments.reduce((s, p) => s + (p.amount || 0), 0));
|
|
358
|
-
const remainingBalance = roundCents(invoice.total - paidTotal);
|
|
359
|
-
return toResult({
|
|
360
|
-
...invoice,
|
|
361
|
-
customers: tagCustomer(invoice
|
|
362
|
-
.customers),
|
|
363
|
-
vat_breakdown: vatBreakdown,
|
|
364
|
-
paid_total: paidTotal,
|
|
365
|
-
remaining_balance: remainingBalance,
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
// ---- WRITE: Update Invoice Status ----
|
|
369
|
-
server.registerTool("kirjapro_update_invoice_status", {
|
|
370
|
-
description: 'Update invoice status with state machine validation. Valid transitions: draft→sent/cancelled, sent→paid/partial/overdue/cancelled/draft, partial→paid/overdue/cancelled, overdue→paid/partial/cancelled/sent, cancelled→draft. "paid" is terminal.',
|
|
371
|
-
inputSchema: {
|
|
372
|
-
invoiceId: z.string().uuid().describe("Invoice UUID"),
|
|
373
|
-
status: z
|
|
374
|
-
.enum(["draft", "sent", "paid", "partial", "overdue", "cancelled"])
|
|
375
|
-
.describe("New status"),
|
|
376
|
-
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
377
|
-
dryRun: DRY_RUN_SCHEMA,
|
|
378
|
-
},
|
|
379
|
-
annotations: WRITE_ANNOTATIONS,
|
|
380
|
-
}, async (params) => {
|
|
381
|
-
const supabase = getSupabase();
|
|
382
|
-
const companyId = getCompanyId();
|
|
383
|
-
const { data: invoice, error: fetchError } = await supabase
|
|
384
|
-
.from("invoices")
|
|
385
|
-
.select("id, invoice_number, status")
|
|
386
|
-
.eq("id", params.invoiceId)
|
|
387
|
-
.eq("company_id", companyId)
|
|
388
|
-
.single();
|
|
389
|
-
if (fetchError || !invoice) {
|
|
390
|
-
return toErrorContent(`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`);
|
|
391
|
-
}
|
|
392
|
-
const currentStatus = invoice.status;
|
|
393
|
-
const newStatus = params.status;
|
|
394
|
-
if (currentStatus === newStatus) {
|
|
395
|
-
return toErrorContent(`Invoice ${invoice.invoice_number} is already in status "${currentStatus}".`);
|
|
396
|
-
}
|
|
397
|
-
const validNext = VALID_TRANSITIONS[currentStatus] || [];
|
|
398
|
-
if (!validNext.includes(newStatus)) {
|
|
399
|
-
return toErrorContent(`Cannot transition invoice ${invoice.invoice_number} from "${currentStatus}" to "${newStatus}". ` +
|
|
400
|
-
`Valid next statuses: ${validNext.length > 0 ? validNext.join(", ") : "(none — terminal status)"}.`);
|
|
401
|
-
}
|
|
402
|
-
if (params.dryRun) {
|
|
403
|
-
return toDryRunResponse("kirjapro_update_invoice_status", {
|
|
404
|
-
action: "update_invoice_status",
|
|
405
|
-
invoice_number: invoice.invoice_number,
|
|
406
|
-
previous_status: currentStatus,
|
|
407
|
-
new_status: newStatus,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
return withIdempotency(supabase, companyId, "kirjapro_update_invoice_status", params.idempotencyKey, params, async () => {
|
|
411
|
-
const updateFields = { status: newStatus };
|
|
412
|
-
if (newStatus === "paid") {
|
|
413
|
-
updateFields.paid_date = helsinkiDateString();
|
|
414
|
-
}
|
|
415
|
-
// TOCTOU guard: include current status in the WHERE clause so a
|
|
416
|
-
// concurrent webhook/cron/MCP call that flipped the status between
|
|
417
|
-
// our SELECT and this UPDATE causes the UPDATE to match zero rows
|
|
418
|
-
// instead of silently overwriting the concurrent change. `.select()`
|
|
419
|
-
// makes PostgREST return the updated row(s) so we can detect this.
|
|
420
|
-
const { data: updated, error: updateError } = await supabase
|
|
421
|
-
.from("invoices")
|
|
422
|
-
.update(updateFields)
|
|
423
|
-
.eq("id", params.invoiceId)
|
|
424
|
-
.eq("company_id", companyId)
|
|
425
|
-
.eq("status", currentStatus)
|
|
426
|
-
.select("id")
|
|
427
|
-
.maybeSingle();
|
|
428
|
-
if (updateError) {
|
|
429
|
-
return toErrorContent(sanitizeDbError("Failed to update invoice status", updateError));
|
|
430
|
-
}
|
|
431
|
-
if (!updated) {
|
|
432
|
-
return toErrorContent(`Invoice ${invoice.invoice_number} status changed concurrently (was "${currentStatus}" when read). ` +
|
|
433
|
-
`Re-read with kirjapro_get_invoice and decide whether to retry.`);
|
|
434
|
-
}
|
|
435
|
-
return toResult({
|
|
436
|
-
message: `Invoice ${invoice.invoice_number} status changed from "${currentStatus}" to "${newStatus}"`,
|
|
437
|
-
invoice_number: invoice.invoice_number,
|
|
438
|
-
previous_status: currentStatus,
|
|
439
|
-
new_status: newStatus,
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
// ---- WRITE: Delete Draft Invoice ----
|
|
444
|
-
server.registerTool("kirjapro_delete_invoice", {
|
|
445
|
-
description: 'Delete a draft invoice. Only invoices with status "draft" can be deleted — sent or paid invoices must be cancelled first (Finnish audit trail requirement).',
|
|
446
|
-
inputSchema: {
|
|
447
|
-
invoiceId: z.string().uuid().describe("Invoice UUID"),
|
|
448
|
-
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
449
|
-
dryRun: DRY_RUN_SCHEMA,
|
|
450
|
-
},
|
|
451
|
-
annotations: DESTRUCTIVE_ANNOTATIONS,
|
|
452
|
-
}, async (params) => {
|
|
453
|
-
const supabase = getSupabase();
|
|
454
|
-
const companyId = getCompanyId();
|
|
455
|
-
const { data: invoice, error: fetchError } = await supabase
|
|
456
|
-
.from("invoices")
|
|
457
|
-
.select("id, invoice_number, status")
|
|
458
|
-
.eq("id", params.invoiceId)
|
|
459
|
-
.eq("company_id", companyId)
|
|
460
|
-
.single();
|
|
461
|
-
if (fetchError || !invoice) {
|
|
462
|
-
return toErrorContent(`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`);
|
|
463
|
-
}
|
|
464
|
-
if (invoice.status !== "draft") {
|
|
465
|
-
return toErrorContent(`Cannot delete invoice ${invoice.invoice_number} — status is "${invoice.status}". ` +
|
|
466
|
-
`Only draft invoices can be deleted. Use kirjapro_update_invoice_status to cancel it first.`);
|
|
467
|
-
}
|
|
468
|
-
if (params.dryRun) {
|
|
469
|
-
return toDryRunResponse("kirjapro_delete_invoice", {
|
|
470
|
-
action: "delete_invoice",
|
|
471
|
-
invoice_number: invoice.invoice_number,
|
|
472
|
-
note: "Will cascade-delete invoice_payments and invoice_lines rows for this invoice.",
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
return withIdempotency(supabase, companyId, "kirjapro_delete_invoice", params.idempotencyKey, params, async () => {
|
|
476
|
-
const { error: rpcError } = await supabase.rpc("delete_draft_invoice_atomic", {
|
|
477
|
-
p_company_id: companyId,
|
|
478
|
-
p_invoice_id: params.invoiceId,
|
|
479
|
-
});
|
|
480
|
-
if (rpcError) {
|
|
481
|
-
return toErrorContent(sanitizeDbError("Failed to delete invoice", rpcError));
|
|
482
|
-
}
|
|
483
|
-
return toResult({
|
|
484
|
-
message: `Draft invoice ${invoice.invoice_number} deleted`,
|
|
485
|
-
invoice_number: invoice.invoice_number,
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
}
|
package/dist/tools/offers.d.ts
DELETED