@kirjapro/mcp-server 0.2.0 → 0.5.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 +46 -0
- package/README.md +17 -3
- package/dist/index.js +2310 -382
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -20891,6 +20891,7 @@ function scheduleTokenRefresh(apiKey, expiresIn) {
|
|
|
20891
20891
|
console.error("[kirjapro-mcp] Refreshing API key token...");
|
|
20892
20892
|
const auth = await authenticateWithApiKey(apiKey);
|
|
20893
20893
|
supabase = createClientFromAuth(auth);
|
|
20894
|
+
companyId = auth.company_id;
|
|
20894
20895
|
console.error("[kirjapro-mcp] Token refreshed successfully");
|
|
20895
20896
|
} catch (error2) {
|
|
20896
20897
|
console.error("[kirjapro-mcp] Token refresh failed:", error2.message);
|
|
@@ -20925,7 +20926,8 @@ async function initLegacyAuth() {
|
|
|
20925
20926
|
if (!kirjaproCompanyId) {
|
|
20926
20927
|
throw new Error("KIRJAPRO_COMPANY_ID environment variable is required.");
|
|
20927
20928
|
}
|
|
20928
|
-
|
|
20929
|
+
const anonKey = process.env.SUPABASE_ANON_KEY || supabaseAccessToken;
|
|
20930
|
+
supabase = createClient(supabaseUrl, anonKey, {
|
|
20929
20931
|
auth: {
|
|
20930
20932
|
persistSession: false,
|
|
20931
20933
|
autoRefreshToken: false
|
|
@@ -21018,7 +21020,7 @@ function toTextContent(data) {
|
|
|
21018
21020
|
content: [
|
|
21019
21021
|
{
|
|
21020
21022
|
type: "text",
|
|
21021
|
-
text: JSON.stringify(data
|
|
21023
|
+
text: JSON.stringify(data)
|
|
21022
21024
|
}
|
|
21023
21025
|
]
|
|
21024
21026
|
};
|
|
@@ -21029,12 +21031,79 @@ function toErrorContent(message) {
|
|
|
21029
21031
|
isError: true
|
|
21030
21032
|
};
|
|
21031
21033
|
}
|
|
21034
|
+
var SAFE_ERROR_MAP = {
|
|
21035
|
+
PGRST116: "Record not found.",
|
|
21036
|
+
PGRST301: "Request timed out. Try a narrower query.",
|
|
21037
|
+
"23505": "A record with this value already exists (duplicate).",
|
|
21038
|
+
"23503": "Referenced record does not exist.",
|
|
21039
|
+
"23514": "Value violates a constraint. Check your input.",
|
|
21040
|
+
"42501": "Permission denied.",
|
|
21041
|
+
"42P01": "Data not available."
|
|
21042
|
+
};
|
|
21043
|
+
function sanitizeDbError(context, error2) {
|
|
21044
|
+
console.error(`[kirjapro-mcp] ${context}:`, {
|
|
21045
|
+
code: error2.code,
|
|
21046
|
+
message: error2.message,
|
|
21047
|
+
details: error2.details,
|
|
21048
|
+
hint: error2.hint
|
|
21049
|
+
});
|
|
21050
|
+
if (error2.code && SAFE_ERROR_MAP[error2.code]) {
|
|
21051
|
+
return `${context}: ${SAFE_ERROR_MAP[error2.code]}`;
|
|
21052
|
+
}
|
|
21053
|
+
if (error2.message) {
|
|
21054
|
+
const stripped = error2.message.replace(/relation "[^"]*"/g, "table").replace(/constraint "[^"]*"/g, "constraint").replace(/column "[^"]*"/g, "column").replace(/schema "[^"]*"/g, "schema").replace(/function "[^"]*"/g, "function");
|
|
21055
|
+
return `${context}: ${stripped}`;
|
|
21056
|
+
}
|
|
21057
|
+
return `${context}. Please try again.`;
|
|
21058
|
+
}
|
|
21059
|
+
async function invokeEdgeFunction(supabase2, name, body) {
|
|
21060
|
+
const { data, error: error2 } = await supabase2.functions.invoke(name, { body });
|
|
21061
|
+
if (error2) {
|
|
21062
|
+
console.error(`[kirjapro-mcp] Edge function "${name}" error:`, error2.message);
|
|
21063
|
+
return { data: null, error: `Edge function "${name}" failed: ${error2.message}` };
|
|
21064
|
+
}
|
|
21065
|
+
return { data, error: null };
|
|
21066
|
+
}
|
|
21032
21067
|
function roundCents(amount) {
|
|
21033
21068
|
return Math.round(amount * 100) / 100;
|
|
21034
21069
|
}
|
|
21070
|
+
function addDaysToIsoDate(date3, days) {
|
|
21071
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date3);
|
|
21072
|
+
if (!match) {
|
|
21073
|
+
throw new Error(`Invalid ISO date: ${date3}`);
|
|
21074
|
+
}
|
|
21075
|
+
const year = Number.parseInt(match[1], 10);
|
|
21076
|
+
const month = Number.parseInt(match[2], 10);
|
|
21077
|
+
const day = Number.parseInt(match[3], 10);
|
|
21078
|
+
const utcDate = new Date(Date.UTC(year, month - 1, day));
|
|
21079
|
+
utcDate.setUTCDate(utcDate.getUTCDate() + days);
|
|
21080
|
+
return utcDate.toISOString().slice(0, 10);
|
|
21081
|
+
}
|
|
21082
|
+
function isUuid(value) {
|
|
21083
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
21084
|
+
value
|
|
21085
|
+
);
|
|
21086
|
+
}
|
|
21035
21087
|
function escapeLike(input) {
|
|
21036
21088
|
return input.replace(/[%_\\]/g, "\\$&");
|
|
21037
21089
|
}
|
|
21090
|
+
async function resolveOpenFiscalYear(supabase2, companyId2) {
|
|
21091
|
+
const { data, error: error2 } = await supabase2.from("fiscal_years").select("id").eq("company_id", companyId2).eq("is_closed", false);
|
|
21092
|
+
if (error2) {
|
|
21093
|
+
return toErrorContent(sanitizeDbError("Failed to query fiscal years", error2));
|
|
21094
|
+
}
|
|
21095
|
+
if (!data || data.length === 0) {
|
|
21096
|
+
return toErrorContent(
|
|
21097
|
+
"No open fiscal year found. Create one or specify fiscalYearId explicitly."
|
|
21098
|
+
);
|
|
21099
|
+
}
|
|
21100
|
+
if (data.length > 1) {
|
|
21101
|
+
return toErrorContent(
|
|
21102
|
+
`Multiple open fiscal years found (${data.length}). Specify fiscalYearId explicitly. Read kirjapro://fiscal-years to list them.`
|
|
21103
|
+
);
|
|
21104
|
+
}
|
|
21105
|
+
return { id: data[0].id };
|
|
21106
|
+
}
|
|
21038
21107
|
|
|
21039
21108
|
// src/tools/accounts.ts
|
|
21040
21109
|
function registerAccountTools(server2) {
|
|
@@ -21046,13 +21115,15 @@ function registerAccountTools(server2) {
|
|
|
21046
21115
|
offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
|
|
21047
21116
|
accountType: external_exports.enum(["asset", "liability", "equity", "revenue", "expense"]).optional().describe("Filter by account type"),
|
|
21048
21117
|
search: external_exports.string().optional().describe("Search by account name or number"),
|
|
21049
|
-
activeOnly: external_exports.boolean().default(true).describe("Only show active accounts")
|
|
21118
|
+
activeOnly: external_exports.boolean().default(true).describe("Only show active accounts"),
|
|
21119
|
+
includeIds: external_exports.boolean().default(false).describe("Include UUID id and parent_id fields (rarely needed)")
|
|
21050
21120
|
},
|
|
21051
21121
|
async (params) => {
|
|
21052
21122
|
const supabase2 = getSupabase();
|
|
21053
21123
|
const companyId2 = getCompanyId();
|
|
21054
21124
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21055
|
-
|
|
21125
|
+
const selectCols = params.includeIds ? "id, account_number, name, account_type, parent_id, is_active" : "account_number, name, account_type";
|
|
21126
|
+
let query = supabase2.from("accounts").select(selectCols, { count: "exact" }).eq("company_id", companyId2).order("account_number");
|
|
21056
21127
|
if (params.activeOnly) {
|
|
21057
21128
|
query = query.eq("is_active", true);
|
|
21058
21129
|
}
|
|
@@ -21066,7 +21137,7 @@ function registerAccountTools(server2) {
|
|
|
21066
21137
|
query = query.range(offset, offset + limit - 1);
|
|
21067
21138
|
const { data, count, error: error2 } = await query;
|
|
21068
21139
|
if (error2) {
|
|
21069
|
-
return toErrorContent(
|
|
21140
|
+
return toErrorContent(sanitizeDbError("Failed to list accounts", error2));
|
|
21070
21141
|
}
|
|
21071
21142
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
21072
21143
|
}
|
|
@@ -21079,7 +21150,7 @@ function registerTransactionTools(server2) {
|
|
|
21079
21150
|
"kirjapro_list_transactions",
|
|
21080
21151
|
"List accounting transactions (tositteet) with their journal entries for a fiscal year. Returns transaction date, description, and debit/credit entries with account details.",
|
|
21081
21152
|
{
|
|
21082
|
-
fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID
|
|
21153
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21083
21154
|
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
21084
21155
|
offset: external_exports.number().min(0).default(0).describe("Skip first N results")
|
|
21085
21156
|
},
|
|
@@ -21087,23 +21158,46 @@ function registerTransactionTools(server2) {
|
|
|
21087
21158
|
const supabase2 = getSupabase();
|
|
21088
21159
|
const companyId2 = getCompanyId();
|
|
21089
21160
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21090
|
-
|
|
21161
|
+
let fiscalYearId = params.fiscalYearId;
|
|
21162
|
+
if (!fiscalYearId) {
|
|
21163
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
21164
|
+
if ("isError" in resolved) return resolved;
|
|
21165
|
+
fiscalYearId = resolved.id;
|
|
21166
|
+
}
|
|
21167
|
+
const { data, count, error: error2 } = await supabase2.from("transactions").select(
|
|
21168
|
+
`
|
|
21091
21169
|
id, transaction_date, description, document_number, created_at,
|
|
21092
21170
|
journal_entries (
|
|
21093
21171
|
id, account_id, debit_amount, credit_amount,
|
|
21094
21172
|
accounts ( account_number, name )
|
|
21095
21173
|
)
|
|
21096
|
-
`,
|
|
21174
|
+
`,
|
|
21175
|
+
{ count: "exact" }
|
|
21176
|
+
).eq("company_id", companyId2).eq("fiscal_year_id", fiscalYearId).order("transaction_date", { ascending: false }).range(offset, offset + limit - 1);
|
|
21097
21177
|
if (error2) {
|
|
21098
|
-
return toErrorContent(
|
|
21178
|
+
return toErrorContent(sanitizeDbError("Failed to list transactions", error2));
|
|
21099
21179
|
}
|
|
21100
21180
|
const transactions = (data || []).map((t) => ({
|
|
21101
|
-
|
|
21181
|
+
id: t.id,
|
|
21182
|
+
transaction_date: t.transaction_date,
|
|
21183
|
+
description: t.description,
|
|
21184
|
+
document_number: t.document_number,
|
|
21185
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
21186
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
21187
|
+
debit: e.debit_amount || 0,
|
|
21188
|
+
credit: e.credit_amount || 0
|
|
21189
|
+
})),
|
|
21102
21190
|
total_debit: roundCents(
|
|
21103
|
-
(t.journal_entries || []).reduce(
|
|
21191
|
+
(t.journal_entries || []).reduce(
|
|
21192
|
+
(s, e) => s + (e.debit_amount || 0),
|
|
21193
|
+
0
|
|
21194
|
+
)
|
|
21104
21195
|
),
|
|
21105
21196
|
total_credit: roundCents(
|
|
21106
|
-
(t.journal_entries || []).reduce(
|
|
21197
|
+
(t.journal_entries || []).reduce(
|
|
21198
|
+
(s, e) => s + (e.credit_amount || 0),
|
|
21199
|
+
0
|
|
21200
|
+
)
|
|
21107
21201
|
)
|
|
21108
21202
|
}));
|
|
21109
21203
|
return toTextContent(paginate(transactions, count || 0, offset, limit));
|
|
@@ -21125,13 +21219,16 @@ function registerTransactionTools(server2) {
|
|
|
21125
21219
|
const supabase2 = getSupabase();
|
|
21126
21220
|
const companyId2 = getCompanyId();
|
|
21127
21221
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21128
|
-
let query = supabase2.from("transactions").select(
|
|
21222
|
+
let query = supabase2.from("transactions").select(
|
|
21223
|
+
`
|
|
21129
21224
|
id, transaction_date, description, document_number, created_at,
|
|
21130
21225
|
journal_entries (
|
|
21131
21226
|
id, debit_amount, credit_amount,
|
|
21132
21227
|
accounts ( account_number, name )
|
|
21133
21228
|
)
|
|
21134
|
-
`,
|
|
21229
|
+
`,
|
|
21230
|
+
{ count: "exact" }
|
|
21231
|
+
).eq("company_id", companyId2).order("transaction_date", { ascending: false });
|
|
21135
21232
|
if (params.query) {
|
|
21136
21233
|
query = query.ilike("description", `%${escapeLike(params.query)}%`);
|
|
21137
21234
|
}
|
|
@@ -21141,56 +21238,95 @@ function registerTransactionTools(server2) {
|
|
|
21141
21238
|
if (params.dateTo) {
|
|
21142
21239
|
query = query.lte("transaction_date", params.dateTo);
|
|
21143
21240
|
}
|
|
21144
|
-
|
|
21241
|
+
const hasAmountFilter = params.minAmount !== void 0 || params.maxAmount !== void 0;
|
|
21242
|
+
const AMOUNT_FILTER_CAP = 1e3;
|
|
21243
|
+
if (hasAmountFilter) {
|
|
21244
|
+
query = query.range(0, AMOUNT_FILTER_CAP - 1);
|
|
21245
|
+
} else {
|
|
21246
|
+
query = query.range(offset, offset + limit - 1);
|
|
21247
|
+
}
|
|
21145
21248
|
const { data, count, error: error2 } = await query;
|
|
21146
21249
|
if (error2) {
|
|
21147
|
-
return toErrorContent(
|
|
21148
|
-
|
|
21149
|
-
let results = (data || []).map((t) => {
|
|
21150
|
-
const totalDebit = roundCents(
|
|
21151
|
-
(t.journal_entries || []).reduce((s, e) => s + (e.debit_amount || 0), 0)
|
|
21152
|
-
);
|
|
21153
|
-
const totalCredit = roundCents(
|
|
21154
|
-
(t.journal_entries || []).reduce((s, e) => s + (e.credit_amount || 0), 0)
|
|
21250
|
+
return toErrorContent(
|
|
21251
|
+
"Failed to search transactions. Check your filters and try again."
|
|
21155
21252
|
);
|
|
21156
|
-
return { ...t, total_debit: totalDebit, total_credit: totalCredit };
|
|
21157
|
-
});
|
|
21158
|
-
if (params.minAmount !== void 0) {
|
|
21159
|
-
results = results.filter((t) => t.total_debit >= params.minAmount);
|
|
21160
21253
|
}
|
|
21161
|
-
|
|
21162
|
-
|
|
21254
|
+
let results = (data || []).map(
|
|
21255
|
+
(t) => {
|
|
21256
|
+
const totalDebit = roundCents(
|
|
21257
|
+
(t.journal_entries || []).reduce(
|
|
21258
|
+
(s, e) => s + (e.debit_amount || 0),
|
|
21259
|
+
0
|
|
21260
|
+
)
|
|
21261
|
+
);
|
|
21262
|
+
const totalCredit = roundCents(
|
|
21263
|
+
(t.journal_entries || []).reduce(
|
|
21264
|
+
(s, e) => s + (e.credit_amount || 0),
|
|
21265
|
+
0
|
|
21266
|
+
)
|
|
21267
|
+
);
|
|
21268
|
+
return {
|
|
21269
|
+
id: t.id,
|
|
21270
|
+
transaction_date: t.transaction_date,
|
|
21271
|
+
description: t.description,
|
|
21272
|
+
document_number: t.document_number,
|
|
21273
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
21274
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
21275
|
+
debit: e.debit_amount || 0,
|
|
21276
|
+
credit: e.credit_amount || 0
|
|
21277
|
+
})),
|
|
21278
|
+
total_debit: totalDebit,
|
|
21279
|
+
total_credit: totalCredit
|
|
21280
|
+
};
|
|
21281
|
+
}
|
|
21282
|
+
);
|
|
21283
|
+
if (hasAmountFilter) {
|
|
21284
|
+
if (params.minAmount !== void 0) {
|
|
21285
|
+
results = results.filter((t) => t.total_debit >= params.minAmount);
|
|
21286
|
+
}
|
|
21287
|
+
if (params.maxAmount !== void 0) {
|
|
21288
|
+
results = results.filter((t) => t.total_debit <= params.maxAmount);
|
|
21289
|
+
}
|
|
21290
|
+
const filteredTotal = results.length;
|
|
21291
|
+
const paginatedResults = results.slice(offset, offset + limit);
|
|
21292
|
+
const cappedWarning = (count || 0) >= AMOUNT_FILTER_CAP ? " (results capped at 1000 rows \u2014 narrow your date range for complete results)" : "";
|
|
21293
|
+
return toTextContent({
|
|
21294
|
+
...paginate(paginatedResults, filteredTotal, offset, limit),
|
|
21295
|
+
amount_filter_note: `Filtered client-side from ${count || 0} transactions${cappedWarning}`
|
|
21296
|
+
});
|
|
21163
21297
|
}
|
|
21164
|
-
|
|
21165
|
-
return toTextContent(paginate(
|
|
21166
|
-
results,
|
|
21167
|
-
hasAmountFilter ? results.length : count || 0,
|
|
21168
|
-
offset,
|
|
21169
|
-
limit
|
|
21170
|
-
));
|
|
21298
|
+
return toTextContent(paginate(results, count || 0, offset, limit));
|
|
21171
21299
|
}
|
|
21172
21300
|
);
|
|
21173
21301
|
server2.tool(
|
|
21174
21302
|
"kirjapro_create_transaction",
|
|
21175
|
-
|
|
21303
|
+
"Create a transaction (tosite) with journal entries. Entries must balance (debits = credits). Accepts account numbers resolved to UUIDs server-side. Prefer kirjapro_create_from_template for standard patterns.",
|
|
21176
21304
|
{
|
|
21177
|
-
fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID"),
|
|
21305
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21178
21306
|
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
21179
21307
|
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
21180
21308
|
documentNumber: external_exports.string().optional().describe("Document/receipt number"),
|
|
21181
|
-
entries: external_exports.array(
|
|
21182
|
-
|
|
21183
|
-
|
|
21184
|
-
|
|
21185
|
-
|
|
21309
|
+
entries: external_exports.array(
|
|
21310
|
+
external_exports.object({
|
|
21311
|
+
accountNumber: external_exports.string().describe('Account number (e.g. "4000") or UUID'),
|
|
21312
|
+
debitAmount: external_exports.number().min(0).default(0).describe("Debit amount in EUR"),
|
|
21313
|
+
creditAmount: external_exports.number().min(0).default(0).describe("Credit amount in EUR")
|
|
21314
|
+
})
|
|
21315
|
+
).min(2).describe("Journal entries (min 2, must balance)")
|
|
21186
21316
|
},
|
|
21187
21317
|
async (params) => {
|
|
21188
21318
|
const supabase2 = getSupabase();
|
|
21189
21319
|
const companyId2 = getCompanyId();
|
|
21190
|
-
|
|
21320
|
+
let fiscalYearId = params.fiscalYearId;
|
|
21321
|
+
if (!fiscalYearId) {
|
|
21322
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
21323
|
+
if ("isError" in resolved) return resolved;
|
|
21324
|
+
fiscalYearId = resolved.id;
|
|
21325
|
+
}
|
|
21326
|
+
const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
21191
21327
|
if (fyError || !fy) {
|
|
21192
21328
|
return toErrorContent(
|
|
21193
|
-
`Fiscal year ${
|
|
21329
|
+
`Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard with a valid fiscal year ID, or read the kirjapro://fiscal-years resource to list available fiscal years.`
|
|
21194
21330
|
);
|
|
21195
21331
|
}
|
|
21196
21332
|
if (fy.is_closed) {
|
|
@@ -21203,52 +21339,85 @@ function registerTransactionTools(server2) {
|
|
|
21203
21339
|
`Transaction date ${params.transactionDate} is outside fiscal year range (${fy.start_date} to ${fy.end_date}).`
|
|
21204
21340
|
);
|
|
21205
21341
|
}
|
|
21206
|
-
const
|
|
21207
|
-
const
|
|
21342
|
+
const accountIdentifiers = params.entries.map((e) => e.accountNumber);
|
|
21343
|
+
const uuidIdentifiers = [
|
|
21344
|
+
...new Set(
|
|
21345
|
+
accountIdentifiers.filter((identifier) => isUuid(identifier))
|
|
21346
|
+
)
|
|
21347
|
+
];
|
|
21348
|
+
const numberIdentifiers = [
|
|
21349
|
+
...new Set(
|
|
21350
|
+
accountIdentifiers.filter((identifier) => !isUuid(identifier))
|
|
21351
|
+
)
|
|
21352
|
+
];
|
|
21353
|
+
const [accountsByNumber, accountsById] = await Promise.all([
|
|
21354
|
+
numberIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", numberIdentifiers) : Promise.resolve({ data: [], error: null }),
|
|
21355
|
+
uuidIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("id", uuidIdentifiers) : Promise.resolve({ data: [], error: null })
|
|
21356
|
+
]);
|
|
21357
|
+
const accError = accountsByNumber.error || accountsById.error;
|
|
21208
21358
|
if (accError) {
|
|
21209
|
-
return toErrorContent(
|
|
21359
|
+
return toErrorContent(
|
|
21360
|
+
sanitizeDbError("Failed to resolve accounts", accError)
|
|
21361
|
+
);
|
|
21362
|
+
}
|
|
21363
|
+
const accounts = [
|
|
21364
|
+
...accountsByNumber.data || [],
|
|
21365
|
+
...accountsById.data || []
|
|
21366
|
+
];
|
|
21367
|
+
const accountMap = /* @__PURE__ */ new Map();
|
|
21368
|
+
for (const account of accounts) {
|
|
21369
|
+
accountMap.set(account.account_number, account.id);
|
|
21370
|
+
accountMap.set(account.id, account.id);
|
|
21210
21371
|
}
|
|
21211
|
-
const
|
|
21212
|
-
|
|
21372
|
+
const missingAccounts = accountIdentifiers.filter(
|
|
21373
|
+
(identifier) => !accountMap.has(identifier)
|
|
21374
|
+
);
|
|
21213
21375
|
if (missingAccounts.length > 0) {
|
|
21214
21376
|
return toErrorContent(
|
|
21215
|
-
`
|
|
21377
|
+
`Accounts not found: ${missingAccounts.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
|
|
21216
21378
|
);
|
|
21217
21379
|
}
|
|
21218
|
-
const totalDebit = roundCents(
|
|
21219
|
-
|
|
21380
|
+
const totalDebit = roundCents(
|
|
21381
|
+
params.entries.reduce((s, e) => s + e.debitAmount, 0)
|
|
21382
|
+
);
|
|
21383
|
+
const totalCredit = roundCents(
|
|
21384
|
+
params.entries.reduce((s, e) => s + e.creditAmount, 0)
|
|
21385
|
+
);
|
|
21220
21386
|
if (totalDebit !== totalCredit) {
|
|
21221
21387
|
return toErrorContent(
|
|
21222
21388
|
`Transaction is unbalanced: total debits (${totalDebit.toFixed(2)}) != total credits (${totalCredit.toFixed(2)}). Difference: ${Math.abs(totalDebit - totalCredit).toFixed(2)} EUR. Add a ${totalDebit > totalCredit ? "credit" : "debit"} entry of ${Math.abs(totalDebit - totalCredit).toFixed(2)} to balance.`
|
|
21223
21389
|
);
|
|
21224
21390
|
}
|
|
21225
|
-
const { data: transaction, error: txError } = await supabase2.from("transactions").insert({
|
|
21226
|
-
company_id: companyId2,
|
|
21227
|
-
fiscal_year_id: params.fiscalYearId,
|
|
21228
|
-
transaction_date: params.transactionDate,
|
|
21229
|
-
description: params.description,
|
|
21230
|
-
document_number: params.documentNumber || null
|
|
21231
|
-
}).select().single();
|
|
21232
|
-
if (txError) {
|
|
21233
|
-
return toErrorContent(`Failed to create transaction: ${txError.message}`);
|
|
21234
|
-
}
|
|
21235
21391
|
const journalEntries = params.entries.map((e) => ({
|
|
21236
|
-
transaction_id: transaction.id,
|
|
21237
21392
|
account_id: accountMap.get(e.accountNumber),
|
|
21238
21393
|
debit_amount: e.debitAmount,
|
|
21239
21394
|
credit_amount: e.creditAmount
|
|
21240
21395
|
}));
|
|
21241
|
-
const { error:
|
|
21242
|
-
|
|
21243
|
-
|
|
21396
|
+
const { data: transactionId, error: txError } = await supabase2.rpc(
|
|
21397
|
+
"create_transaction_with_entries",
|
|
21398
|
+
{
|
|
21399
|
+
p_company_id: companyId2,
|
|
21400
|
+
p_fiscal_year_id: fiscalYearId,
|
|
21401
|
+
p_transaction_date: params.transactionDate,
|
|
21402
|
+
p_description: params.description,
|
|
21403
|
+
p_document_number: params.documentNumber || "",
|
|
21404
|
+
p_entries: journalEntries
|
|
21405
|
+
}
|
|
21406
|
+
);
|
|
21407
|
+
if (txError || !transactionId) {
|
|
21408
|
+
return toErrorContent(
|
|
21409
|
+
txError ? sanitizeDbError("Failed to create transaction", txError) : "Failed to create transaction: no ID returned."
|
|
21410
|
+
);
|
|
21244
21411
|
}
|
|
21245
|
-
const { data: complete } = await supabase2.from("transactions").select(
|
|
21412
|
+
const { data: complete } = await supabase2.from("transactions").select(
|
|
21413
|
+
`
|
|
21246
21414
|
id, transaction_date, description, document_number, created_at,
|
|
21247
21415
|
journal_entries (
|
|
21248
21416
|
id, debit_amount, credit_amount,
|
|
21249
21417
|
accounts ( account_number, name )
|
|
21250
21418
|
)
|
|
21251
|
-
`
|
|
21419
|
+
`
|
|
21420
|
+
).eq("id", transactionId).single();
|
|
21252
21421
|
return toTextContent({
|
|
21253
21422
|
message: "Transaction created successfully",
|
|
21254
21423
|
transaction: complete
|
|
@@ -21257,77 +21426,136 @@ function registerTransactionTools(server2) {
|
|
|
21257
21426
|
);
|
|
21258
21427
|
server2.tool(
|
|
21259
21428
|
"kirjapro_create_transactions_batch",
|
|
21260
|
-
"Create multiple
|
|
21429
|
+
"Create multiple transactions in one call (up to 50). Each must balance (debits = credits) and is validated independently. Prefer kirjapro_create_from_template_batch for standard patterns.",
|
|
21261
21430
|
{
|
|
21262
|
-
fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID"),
|
|
21263
|
-
transactions: external_exports.array(
|
|
21264
|
-
|
|
21265
|
-
|
|
21266
|
-
|
|
21267
|
-
|
|
21268
|
-
|
|
21269
|
-
|
|
21270
|
-
|
|
21271
|
-
|
|
21272
|
-
|
|
21431
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21432
|
+
transactions: external_exports.array(
|
|
21433
|
+
external_exports.object({
|
|
21434
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
|
|
21435
|
+
description: external_exports.string().min(1).max(500),
|
|
21436
|
+
documentNumber: external_exports.string().optional(),
|
|
21437
|
+
entries: external_exports.array(
|
|
21438
|
+
external_exports.object({
|
|
21439
|
+
accountNumber: external_exports.string().describe('Account number (e.g. "4000")'),
|
|
21440
|
+
debitAmount: external_exports.number().min(0).default(0),
|
|
21441
|
+
creditAmount: external_exports.number().min(0).default(0)
|
|
21442
|
+
})
|
|
21443
|
+
).min(2)
|
|
21444
|
+
})
|
|
21445
|
+
).min(1).max(50).describe("Array of transactions to create (max 50)")
|
|
21273
21446
|
},
|
|
21274
21447
|
async (params) => {
|
|
21275
21448
|
const supabase2 = getSupabase();
|
|
21276
21449
|
const companyId2 = getCompanyId();
|
|
21277
|
-
|
|
21450
|
+
let fiscalYearId = params.fiscalYearId;
|
|
21451
|
+
if (!fiscalYearId) {
|
|
21452
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
21453
|
+
if ("isError" in resolved) return resolved;
|
|
21454
|
+
fiscalYearId = resolved.id;
|
|
21455
|
+
}
|
|
21456
|
+
const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
21278
21457
|
if (fyError || !fy) {
|
|
21279
|
-
return toErrorContent(`Fiscal year ${
|
|
21458
|
+
return toErrorContent(`Fiscal year ${fiscalYearId} not found.`);
|
|
21280
21459
|
}
|
|
21281
21460
|
if (fy.is_closed) {
|
|
21282
|
-
return toErrorContent(
|
|
21461
|
+
return toErrorContent(
|
|
21462
|
+
`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`
|
|
21463
|
+
);
|
|
21464
|
+
}
|
|
21465
|
+
const allAccountNumbers = [
|
|
21466
|
+
...new Set(
|
|
21467
|
+
params.transactions.flatMap(
|
|
21468
|
+
(t) => t.entries.map((e) => e.accountNumber)
|
|
21469
|
+
)
|
|
21470
|
+
)
|
|
21471
|
+
];
|
|
21472
|
+
const uuidIdentifiers = allAccountNumbers.filter(
|
|
21473
|
+
(identifier) => isUuid(identifier)
|
|
21474
|
+
);
|
|
21475
|
+
const numberIdentifiers = allAccountNumbers.filter(
|
|
21476
|
+
(identifier) => !isUuid(identifier)
|
|
21477
|
+
);
|
|
21478
|
+
const [accountsByNumber, accountsById] = await Promise.all([
|
|
21479
|
+
numberIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", numberIdentifiers) : Promise.resolve({ data: [], error: null }),
|
|
21480
|
+
uuidIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("id", uuidIdentifiers) : Promise.resolve({ data: [], error: null })
|
|
21481
|
+
]);
|
|
21482
|
+
const accError = accountsByNumber.error || accountsById.error;
|
|
21483
|
+
if (accError) {
|
|
21484
|
+
return toErrorContent(
|
|
21485
|
+
sanitizeDbError("Failed to resolve accounts", accError)
|
|
21486
|
+
);
|
|
21487
|
+
}
|
|
21488
|
+
const accounts = [
|
|
21489
|
+
...accountsByNumber.data || [],
|
|
21490
|
+
...accountsById.data || []
|
|
21491
|
+
];
|
|
21492
|
+
const accountMap = /* @__PURE__ */ new Map();
|
|
21493
|
+
for (const account of accounts) {
|
|
21494
|
+
accountMap.set(account.account_number, account.id);
|
|
21495
|
+
accountMap.set(account.id, account.id);
|
|
21283
21496
|
}
|
|
21284
|
-
const allAccountNumbers = [...new Set(
|
|
21285
|
-
params.transactions.flatMap((t) => t.entries.map((e) => e.accountNumber))
|
|
21286
|
-
)];
|
|
21287
|
-
const { data: accounts } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", allAccountNumbers);
|
|
21288
|
-
const accountMap = new Map((accounts || []).map((a) => [a.account_number, a.id]));
|
|
21289
21497
|
const created = [];
|
|
21290
21498
|
const failed = [];
|
|
21291
21499
|
for (let i = 0; i < params.transactions.length; i++) {
|
|
21292
21500
|
const tx = params.transactions[i];
|
|
21293
21501
|
if (tx.transactionDate < fy.start_date || tx.transactionDate > fy.end_date) {
|
|
21294
|
-
failed.push({
|
|
21502
|
+
failed.push({
|
|
21503
|
+
index: i,
|
|
21504
|
+
error: `Date ${tx.transactionDate} outside fiscal year range.`
|
|
21505
|
+
});
|
|
21295
21506
|
continue;
|
|
21296
21507
|
}
|
|
21297
|
-
const missing = tx.entries.filter(
|
|
21508
|
+
const missing = tx.entries.filter(
|
|
21509
|
+
(e) => !accountMap.has(e.accountNumber)
|
|
21510
|
+
);
|
|
21298
21511
|
if (missing.length > 0) {
|
|
21299
|
-
failed.push({
|
|
21512
|
+
failed.push({
|
|
21513
|
+
index: i,
|
|
21514
|
+
error: `Unknown accounts: ${missing.map((m) => m.accountNumber).join(", ")}`
|
|
21515
|
+
});
|
|
21300
21516
|
continue;
|
|
21301
21517
|
}
|
|
21302
|
-
const totalDebit = roundCents(
|
|
21303
|
-
|
|
21518
|
+
const totalDebit = roundCents(
|
|
21519
|
+
tx.entries.reduce((s, e) => s + e.debitAmount, 0)
|
|
21520
|
+
);
|
|
21521
|
+
const totalCredit = roundCents(
|
|
21522
|
+
tx.entries.reduce((s, e) => s + e.creditAmount, 0)
|
|
21523
|
+
);
|
|
21304
21524
|
if (totalDebit !== totalCredit) {
|
|
21305
|
-
failed.push({
|
|
21306
|
-
|
|
21307
|
-
|
|
21308
|
-
|
|
21309
|
-
company_id: companyId2,
|
|
21310
|
-
fiscal_year_id: params.fiscalYearId,
|
|
21311
|
-
transaction_date: tx.transactionDate,
|
|
21312
|
-
description: tx.description,
|
|
21313
|
-
document_number: tx.documentNumber || null
|
|
21314
|
-
}).select("id").single();
|
|
21315
|
-
if (txErr) {
|
|
21316
|
-
failed.push({ index: i, error: txErr.message });
|
|
21525
|
+
failed.push({
|
|
21526
|
+
index: i,
|
|
21527
|
+
error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}`
|
|
21528
|
+
});
|
|
21317
21529
|
continue;
|
|
21318
21530
|
}
|
|
21319
21531
|
const entries = tx.entries.map((e) => ({
|
|
21320
|
-
transaction_id: newTx.id,
|
|
21321
21532
|
account_id: accountMap.get(e.accountNumber),
|
|
21322
21533
|
debit_amount: e.debitAmount,
|
|
21323
21534
|
credit_amount: e.creditAmount
|
|
21324
21535
|
}));
|
|
21325
|
-
const { error:
|
|
21326
|
-
|
|
21327
|
-
|
|
21536
|
+
const { data: newTxId, error: txErr } = await supabase2.rpc(
|
|
21537
|
+
"create_transaction_with_entries",
|
|
21538
|
+
{
|
|
21539
|
+
p_company_id: companyId2,
|
|
21540
|
+
p_fiscal_year_id: fiscalYearId,
|
|
21541
|
+
p_transaction_date: tx.transactionDate,
|
|
21542
|
+
p_description: tx.description,
|
|
21543
|
+
p_document_number: tx.documentNumber || "",
|
|
21544
|
+
p_entries: entries
|
|
21545
|
+
}
|
|
21546
|
+
);
|
|
21547
|
+
if (txErr || !newTxId) {
|
|
21548
|
+
failed.push({
|
|
21549
|
+
index: i,
|
|
21550
|
+
error: txErr?.message || "No transaction ID returned"
|
|
21551
|
+
});
|
|
21328
21552
|
continue;
|
|
21329
21553
|
}
|
|
21330
|
-
created.push({
|
|
21554
|
+
created.push({
|
|
21555
|
+
id: newTxId,
|
|
21556
|
+
description: tx.description,
|
|
21557
|
+
date: tx.transactionDate
|
|
21558
|
+
});
|
|
21331
21559
|
}
|
|
21332
21560
|
return toTextContent({
|
|
21333
21561
|
created_count: created.length,
|
|
@@ -21346,13 +21574,15 @@ function registerTransactionTools(server2) {
|
|
|
21346
21574
|
async (params) => {
|
|
21347
21575
|
const supabase2 = getSupabase();
|
|
21348
21576
|
const companyId2 = getCompanyId();
|
|
21349
|
-
const { data: transaction, error: error2 } = await supabase2.from("transactions").select(
|
|
21577
|
+
const { data: transaction, error: error2 } = await supabase2.from("transactions").select(
|
|
21578
|
+
`
|
|
21350
21579
|
id, transaction_date, description, document_number, created_at,
|
|
21351
21580
|
journal_entries (
|
|
21352
21581
|
id, account_id, debit_amount, credit_amount, description,
|
|
21353
21582
|
accounts ( account_number, name, account_type )
|
|
21354
21583
|
)
|
|
21355
|
-
`
|
|
21584
|
+
`
|
|
21585
|
+
).eq("id", params.transactionId).eq("company_id", companyId2).single();
|
|
21356
21586
|
if (error2 || !transaction) {
|
|
21357
21587
|
return toErrorContent(
|
|
21358
21588
|
`Transaction ${params.transactionId} not found. Use kirjapro_list_transactions to find transaction IDs.`
|
|
@@ -21376,6 +21606,14 @@ function registerTransactionTools(server2) {
|
|
|
21376
21606
|
}
|
|
21377
21607
|
|
|
21378
21608
|
// src/types.ts
|
|
21609
|
+
var VALID_OFFER_TRANSITIONS = {
|
|
21610
|
+
draft: ["sent"],
|
|
21611
|
+
sent: ["viewed", "accepted", "rejected", "expired", "draft"],
|
|
21612
|
+
viewed: ["accepted", "rejected", "expired", "draft"],
|
|
21613
|
+
accepted: ["draft"],
|
|
21614
|
+
rejected: ["draft"],
|
|
21615
|
+
expired: ["draft"]
|
|
21616
|
+
};
|
|
21379
21617
|
var VALID_TRANSITIONS = {
|
|
21380
21618
|
draft: ["sent", "cancelled"],
|
|
21381
21619
|
sent: ["paid", "partial", "overdue", "cancelled", "draft"],
|
|
@@ -21399,18 +21637,12 @@ function generateReferenceNumber(baseNumber) {
|
|
|
21399
21637
|
const checkDigit = (10 - sum % 10) % 10;
|
|
21400
21638
|
return digits + checkDigit;
|
|
21401
21639
|
}
|
|
21402
|
-
function generateInvoiceNumber(lastNumber, prefix = "INV") {
|
|
21403
|
-
if (!lastNumber) return `${prefix}00001`;
|
|
21404
|
-
const numPart = lastNumber.replace(/\D/g, "");
|
|
21405
|
-
const next = parseInt(numPart || "0") + 1;
|
|
21406
|
-
return `${prefix}${next.toString().padStart(5, "0")}`;
|
|
21407
|
-
}
|
|
21408
21640
|
function registerInvoiceTools(server2) {
|
|
21409
21641
|
server2.tool(
|
|
21410
21642
|
"kirjapro_list_invoices",
|
|
21411
21643
|
"List invoices with customer names. Filter by status (draft, sent, paid, overdue, cancelled).",
|
|
21412
21644
|
{
|
|
21413
|
-
status: external_exports.enum(["draft", "sent", "paid", "overdue", "cancelled"]).optional().describe("Filter by invoice status"),
|
|
21645
|
+
status: external_exports.enum(["draft", "sent", "paid", "partial", "overdue", "cancelled"]).optional().describe("Filter by invoice status"),
|
|
21414
21646
|
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
21415
21647
|
offset: external_exports.number().min(0).default(0).describe("Skip first N results")
|
|
21416
21648
|
},
|
|
@@ -21418,18 +21650,21 @@ function registerInvoiceTools(server2) {
|
|
|
21418
21650
|
const supabase2 = getSupabase();
|
|
21419
21651
|
const companyId2 = getCompanyId();
|
|
21420
21652
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21421
|
-
let query = supabase2.from("invoices").select(
|
|
21653
|
+
let query = supabase2.from("invoices").select(
|
|
21654
|
+
`
|
|
21422
21655
|
id, invoice_number, reference_number, invoice_date, due_date,
|
|
21423
21656
|
status, subtotal, vat_total, total, notes,
|
|
21424
21657
|
customers ( name, business_id )
|
|
21425
|
-
`,
|
|
21658
|
+
`,
|
|
21659
|
+
{ count: "exact" }
|
|
21660
|
+
).eq("company_id", companyId2).order("invoice_date", { ascending: false });
|
|
21426
21661
|
if (params.status) {
|
|
21427
21662
|
query = query.eq("status", params.status);
|
|
21428
21663
|
}
|
|
21429
21664
|
query = query.range(offset, offset + limit - 1);
|
|
21430
21665
|
const { data, count, error: error2 } = await query;
|
|
21431
21666
|
if (error2) {
|
|
21432
|
-
return toErrorContent(
|
|
21667
|
+
return toErrorContent(sanitizeDbError("Failed to list invoices", error2));
|
|
21433
21668
|
}
|
|
21434
21669
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
21435
21670
|
}
|
|
@@ -21442,13 +21677,15 @@ function registerInvoiceTools(server2) {
|
|
|
21442
21677
|
invoiceDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Invoice date (YYYY-MM-DD)"),
|
|
21443
21678
|
paymentTermDays: external_exports.number().min(0).max(365).default(14).describe("Payment term in days (default 14)"),
|
|
21444
21679
|
notes: external_exports.string().optional().describe("Notes printed on the invoice"),
|
|
21445
|
-
lines: external_exports.array(
|
|
21446
|
-
|
|
21447
|
-
|
|
21448
|
-
|
|
21449
|
-
|
|
21450
|
-
|
|
21451
|
-
|
|
21680
|
+
lines: external_exports.array(
|
|
21681
|
+
external_exports.object({
|
|
21682
|
+
description: external_exports.string().min(1).describe("Line item description"),
|
|
21683
|
+
quantity: external_exports.number().min(0).describe("Quantity"),
|
|
21684
|
+
unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
|
|
21685
|
+
unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
|
|
21686
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
|
|
21687
|
+
})
|
|
21688
|
+
).min(1).describe("Invoice line items")
|
|
21452
21689
|
},
|
|
21453
21690
|
async (params) => {
|
|
21454
21691
|
const supabase2 = getSupabase();
|
|
@@ -21459,12 +21696,24 @@ function registerInvoiceTools(server2) {
|
|
|
21459
21696
|
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
21460
21697
|
);
|
|
21461
21698
|
}
|
|
21462
|
-
const { data:
|
|
21463
|
-
|
|
21464
|
-
|
|
21465
|
-
|
|
21466
|
-
|
|
21467
|
-
|
|
21699
|
+
const { data: invoiceNumber, error: numError } = await supabase2.rpc(
|
|
21700
|
+
"get_next_invoice_number",
|
|
21701
|
+
{
|
|
21702
|
+
p_company_id: companyId2
|
|
21703
|
+
}
|
|
21704
|
+
);
|
|
21705
|
+
if (numError || !invoiceNumber) {
|
|
21706
|
+
return toErrorContent(
|
|
21707
|
+
numError ? sanitizeDbError("Failed to generate invoice number", numError) : "Failed to generate invoice number."
|
|
21708
|
+
);
|
|
21709
|
+
}
|
|
21710
|
+
const referenceNumber = generateReferenceNumber(
|
|
21711
|
+
invoiceNumber.replace(/\D/g, "")
|
|
21712
|
+
);
|
|
21713
|
+
const dueDateStr = addDaysToIsoDate(
|
|
21714
|
+
params.invoiceDate,
|
|
21715
|
+
params.paymentTermDays
|
|
21716
|
+
);
|
|
21468
21717
|
const calculatedLines = params.lines.map((line, i) => {
|
|
21469
21718
|
const vatRate = parseFloat(line.vatRate);
|
|
21470
21719
|
const subtotal2 = roundCents(line.quantity * line.unitPrice);
|
|
@@ -21482,32 +21731,42 @@ function registerInvoiceTools(server2) {
|
|
|
21482
21731
|
total: total2
|
|
21483
21732
|
};
|
|
21484
21733
|
});
|
|
21485
|
-
const subtotal = roundCents(
|
|
21486
|
-
|
|
21734
|
+
const subtotal = roundCents(
|
|
21735
|
+
calculatedLines.reduce((s, l) => s + l.subtotal, 0)
|
|
21736
|
+
);
|
|
21737
|
+
const vatTotal = roundCents(
|
|
21738
|
+
calculatedLines.reduce((s, l) => s + l.vat_amount, 0)
|
|
21739
|
+
);
|
|
21487
21740
|
const total = roundCents(subtotal + vatTotal);
|
|
21488
|
-
const { data:
|
|
21489
|
-
|
|
21490
|
-
|
|
21491
|
-
|
|
21492
|
-
|
|
21493
|
-
|
|
21494
|
-
|
|
21495
|
-
|
|
21496
|
-
|
|
21497
|
-
|
|
21498
|
-
|
|
21499
|
-
|
|
21500
|
-
|
|
21501
|
-
|
|
21502
|
-
|
|
21741
|
+
const { data: invoiceId, error: invError } = await supabase2.rpc(
|
|
21742
|
+
"create_invoice_with_lines",
|
|
21743
|
+
{
|
|
21744
|
+
p_company_id: companyId2,
|
|
21745
|
+
p_customer_id: params.customerId,
|
|
21746
|
+
p_invoice_number: invoiceNumber,
|
|
21747
|
+
p_reference_number: referenceNumber,
|
|
21748
|
+
p_invoice_date: params.invoiceDate,
|
|
21749
|
+
p_due_date: dueDateStr,
|
|
21750
|
+
p_status: "draft",
|
|
21751
|
+
p_subtotal: subtotal,
|
|
21752
|
+
p_vat_total: vatTotal,
|
|
21753
|
+
p_total: total,
|
|
21754
|
+
p_notes: params.notes || "",
|
|
21755
|
+
p_lines: calculatedLines
|
|
21756
|
+
}
|
|
21757
|
+
);
|
|
21758
|
+
if (invError || !invoiceId) {
|
|
21759
|
+
return toErrorContent(
|
|
21760
|
+
invError ? sanitizeDbError("Failed to create invoice", invError) : "Failed to create invoice."
|
|
21761
|
+
);
|
|
21503
21762
|
}
|
|
21504
|
-
const
|
|
21505
|
-
|
|
21506
|
-
|
|
21507
|
-
|
|
21508
|
-
|
|
21509
|
-
|
|
21510
|
-
|
|
21763
|
+
const { data: invoice, error: fetchError } = await supabase2.from("invoices").select(
|
|
21764
|
+
"id, invoice_number, reference_number, invoice_date, due_date, status, subtotal, vat_total, total, notes, created_at"
|
|
21765
|
+
).eq("id", invoiceId).eq("company_id", companyId2).single();
|
|
21766
|
+
if (fetchError || !invoice) {
|
|
21767
|
+
return toErrorContent(
|
|
21768
|
+
fetchError ? sanitizeDbError("Invoice created but fetch failed", fetchError) : "Invoice created but could not be retrieved."
|
|
21769
|
+
);
|
|
21511
21770
|
}
|
|
21512
21771
|
return toTextContent({
|
|
21513
21772
|
message: `Invoice ${invoiceNumber} created for ${customer.name}`,
|
|
@@ -21533,23 +21792,32 @@ function registerInvoiceTools(server2) {
|
|
|
21533
21792
|
async (params) => {
|
|
21534
21793
|
const supabase2 = getSupabase();
|
|
21535
21794
|
const companyId2 = getCompanyId();
|
|
21536
|
-
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
21795
|
+
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
21796
|
+
`
|
|
21537
21797
|
id, invoice_number, total, status,
|
|
21538
21798
|
invoice_payments ( amount )
|
|
21539
|
-
`
|
|
21799
|
+
`
|
|
21800
|
+
).eq("id", params.invoiceId).eq("company_id", companyId2).single();
|
|
21540
21801
|
if (invError || !invoice) {
|
|
21541
21802
|
return toErrorContent(
|
|
21542
21803
|
`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
|
|
21543
21804
|
);
|
|
21544
21805
|
}
|
|
21545
21806
|
if (invoice.status === "paid") {
|
|
21546
|
-
return toErrorContent(
|
|
21807
|
+
return toErrorContent(
|
|
21808
|
+
`Invoice ${invoice.invoice_number} is already fully paid.`
|
|
21809
|
+
);
|
|
21547
21810
|
}
|
|
21548
21811
|
if (invoice.status === "cancelled") {
|
|
21549
|
-
return toErrorContent(
|
|
21812
|
+
return toErrorContent(
|
|
21813
|
+
`Invoice ${invoice.invoice_number} is cancelled and cannot accept payments.`
|
|
21814
|
+
);
|
|
21550
21815
|
}
|
|
21551
21816
|
const paidSoFar = roundCents(
|
|
21552
|
-
(invoice.invoice_payments || []).reduce(
|
|
21817
|
+
(invoice.invoice_payments || []).reduce(
|
|
21818
|
+
(s, p) => s + (p.amount || 0),
|
|
21819
|
+
0
|
|
21820
|
+
)
|
|
21553
21821
|
);
|
|
21554
21822
|
const remaining = roundCents(invoice.total - paidSoFar);
|
|
21555
21823
|
if (params.amount > remaining + 0.01) {
|
|
@@ -21557,30 +21825,29 @@ function registerInvoiceTools(server2) {
|
|
|
21557
21825
|
`Payment amount ${params.amount.toFixed(2)} exceeds remaining balance ${remaining.toFixed(2)} EUR on invoice ${invoice.invoice_number} (total: ${invoice.total.toFixed(2)}, paid: ${paidSoFar.toFixed(2)}). Reduce the payment amount to ${remaining.toFixed(2)} or less.`
|
|
21558
21826
|
);
|
|
21559
21827
|
}
|
|
21560
|
-
const { data:
|
|
21561
|
-
|
|
21562
|
-
|
|
21563
|
-
|
|
21564
|
-
|
|
21565
|
-
|
|
21566
|
-
|
|
21567
|
-
|
|
21568
|
-
|
|
21569
|
-
|
|
21570
|
-
|
|
21571
|
-
|
|
21572
|
-
|
|
21573
|
-
|
|
21574
|
-
|
|
21575
|
-
|
|
21576
|
-
paid_date: newStatus === "paid" ? params.paymentDate : null
|
|
21577
|
-
}).eq("id", params.invoiceId);
|
|
21828
|
+
const { data: paymentResult, error: payError } = await supabase2.rpc(
|
|
21829
|
+
"record_invoice_payment",
|
|
21830
|
+
{
|
|
21831
|
+
p_company_id: companyId2,
|
|
21832
|
+
p_invoice_id: params.invoiceId,
|
|
21833
|
+
p_payment_date: params.paymentDate,
|
|
21834
|
+
p_amount: params.amount,
|
|
21835
|
+
p_payment_method: params.paymentMethod,
|
|
21836
|
+
p_reference: params.reference || void 0,
|
|
21837
|
+
p_notes: params.notes || void 0
|
|
21838
|
+
}
|
|
21839
|
+
);
|
|
21840
|
+
if (payError || !paymentResult) {
|
|
21841
|
+
return toErrorContent(
|
|
21842
|
+
payError ? sanitizeDbError("Failed to record payment", payError) : "Failed to record payment."
|
|
21843
|
+
);
|
|
21578
21844
|
}
|
|
21845
|
+
const result = paymentResult;
|
|
21579
21846
|
return toTextContent({
|
|
21580
21847
|
message: `Payment of ${params.amount.toFixed(2)} EUR recorded on invoice ${invoice.invoice_number}`,
|
|
21581
|
-
|
|
21582
|
-
invoice_status:
|
|
21583
|
-
remaining_balance:
|
|
21848
|
+
payment_id: result.payment_id,
|
|
21849
|
+
invoice_status: result.invoice_status,
|
|
21850
|
+
remaining_balance: result.remaining_balance
|
|
21584
21851
|
});
|
|
21585
21852
|
}
|
|
21586
21853
|
);
|
|
@@ -21593,13 +21860,15 @@ function registerInvoiceTools(server2) {
|
|
|
21593
21860
|
async (params) => {
|
|
21594
21861
|
const supabase2 = getSupabase();
|
|
21595
21862
|
const companyId2 = getCompanyId();
|
|
21596
|
-
const { data: invoice, error: error2 } = await supabase2.from("invoices").select(
|
|
21863
|
+
const { data: invoice, error: error2 } = await supabase2.from("invoices").select(
|
|
21864
|
+
`
|
|
21597
21865
|
id, invoice_number, reference_number, invoice_date, due_date,
|
|
21598
21866
|
status, subtotal, vat_total, total, notes, created_at,
|
|
21599
21867
|
customers ( id, name, business_id, email, address, postal_code, city ),
|
|
21600
21868
|
invoice_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
|
|
21601
21869
|
invoice_payments ( id, payment_date, amount, payment_method, reference )
|
|
21602
|
-
`
|
|
21870
|
+
`
|
|
21871
|
+
).eq("id", params.invoiceId).eq("company_id", companyId2).single();
|
|
21603
21872
|
if (error2 || !invoice) {
|
|
21604
21873
|
return toErrorContent(
|
|
21605
21874
|
`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
|
|
@@ -21607,7 +21876,12 @@ function registerInvoiceTools(server2) {
|
|
|
21607
21876
|
}
|
|
21608
21877
|
const vatMap = /* @__PURE__ */ new Map();
|
|
21609
21878
|
for (const line of invoice.invoice_lines || []) {
|
|
21610
|
-
const existing = vatMap.get(line.vat_rate) || {
|
|
21879
|
+
const existing = vatMap.get(line.vat_rate) || {
|
|
21880
|
+
vat_rate: line.vat_rate,
|
|
21881
|
+
subtotal: 0,
|
|
21882
|
+
vat_amount: 0,
|
|
21883
|
+
total: 0
|
|
21884
|
+
};
|
|
21611
21885
|
existing.subtotal += line.subtotal || 0;
|
|
21612
21886
|
existing.vat_amount += line.vat_amount || 0;
|
|
21613
21887
|
existing.total += line.total || 0;
|
|
@@ -21620,7 +21894,9 @@ function registerInvoiceTools(server2) {
|
|
|
21620
21894
|
total: roundCents(v.total)
|
|
21621
21895
|
}));
|
|
21622
21896
|
const payments = invoice.invoice_payments || [];
|
|
21623
|
-
const paidTotal = roundCents(
|
|
21897
|
+
const paidTotal = roundCents(
|
|
21898
|
+
payments.reduce((s, p) => s + (p.amount || 0), 0)
|
|
21899
|
+
);
|
|
21624
21900
|
const remainingBalance = roundCents(invoice.total - paidTotal);
|
|
21625
21901
|
return toTextContent({
|
|
21626
21902
|
...invoice,
|
|
@@ -21663,9 +21939,11 @@ function registerInvoiceTools(server2) {
|
|
|
21663
21939
|
if (newStatus === "paid") {
|
|
21664
21940
|
updateFields.paid_date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
21665
21941
|
}
|
|
21666
|
-
const { error: updateError } = await supabase2.from("invoices").update(updateFields).eq("id", params.invoiceId);
|
|
21942
|
+
const { error: updateError } = await supabase2.from("invoices").update(updateFields).eq("id", params.invoiceId).eq("company_id", companyId2);
|
|
21667
21943
|
if (updateError) {
|
|
21668
|
-
return toErrorContent(
|
|
21944
|
+
return toErrorContent(
|
|
21945
|
+
sanitizeDbError("Failed to update invoice status", updateError)
|
|
21946
|
+
);
|
|
21669
21947
|
}
|
|
21670
21948
|
return toTextContent({
|
|
21671
21949
|
message: `Invoice ${invoice.invoice_number} status changed from "${currentStatus}" to "${newStatus}"`,
|
|
@@ -21695,13 +21973,23 @@ function registerInvoiceTools(server2) {
|
|
|
21695
21973
|
`Cannot delete invoice ${invoice.invoice_number} \u2014 status is "${invoice.status}". Only draft invoices can be deleted. Use kirjapro_update_invoice_status to cancel it first.`
|
|
21696
21974
|
);
|
|
21697
21975
|
}
|
|
21976
|
+
const { error: paymentsError } = await supabase2.from("invoice_payments").delete().eq("invoice_id", params.invoiceId);
|
|
21977
|
+
if (paymentsError) {
|
|
21978
|
+
return toErrorContent(
|
|
21979
|
+
sanitizeDbError("Failed to delete invoice payments", paymentsError)
|
|
21980
|
+
);
|
|
21981
|
+
}
|
|
21698
21982
|
const { error: linesError } = await supabase2.from("invoice_lines").delete().eq("invoice_id", params.invoiceId);
|
|
21699
21983
|
if (linesError) {
|
|
21700
|
-
return toErrorContent(
|
|
21984
|
+
return toErrorContent(
|
|
21985
|
+
sanitizeDbError("Failed to delete invoice lines", linesError)
|
|
21986
|
+
);
|
|
21701
21987
|
}
|
|
21702
21988
|
const { error: deleteError } = await supabase2.from("invoices").delete().eq("id", params.invoiceId);
|
|
21703
21989
|
if (deleteError) {
|
|
21704
|
-
return toErrorContent(
|
|
21990
|
+
return toErrorContent(
|
|
21991
|
+
sanitizeDbError("Failed to delete invoice", deleteError)
|
|
21992
|
+
);
|
|
21705
21993
|
}
|
|
21706
21994
|
return toTextContent({
|
|
21707
21995
|
message: `Draft invoice ${invoice.invoice_number} deleted`,
|
|
@@ -21756,7 +22044,7 @@ function registerCustomerTools(server2) {
|
|
|
21756
22044
|
query = query.range(offset, offset + limit - 1);
|
|
21757
22045
|
const { data, count, error: error2 } = await query;
|
|
21758
22046
|
if (error2) {
|
|
21759
|
-
return toErrorContent(
|
|
22047
|
+
return toErrorContent(sanitizeDbError("Failed to list customers", error2));
|
|
21760
22048
|
}
|
|
21761
22049
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
21762
22050
|
}
|
|
@@ -21784,7 +22072,7 @@ function registerCustomerTools(server2) {
|
|
|
21784
22072
|
`Y-tunnus '${params.businessId}' is invalid (checksum failed). Valid format: 7 digits + hyphen + check digit. Example: 1234567-8.`
|
|
21785
22073
|
);
|
|
21786
22074
|
}
|
|
21787
|
-
const { data: lastCustomer } = await supabase2.from("customers").select("customer_number").eq("company_id", companyId2).order("created_at", { ascending: false }).limit(1).
|
|
22075
|
+
const { data: lastCustomer } = await supabase2.from("customers").select("customer_number").eq("company_id", companyId2).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
21788
22076
|
const lastNum = parseInt(lastCustomer?.customer_number?.replace(/\D/g, "") || "0");
|
|
21789
22077
|
const customerNumber = (lastNum + 1).toString().padStart(5, "0");
|
|
21790
22078
|
const { data: customer, error: error2 } = await supabase2.from("customers").insert({
|
|
@@ -21802,7 +22090,7 @@ function registerCustomerTools(server2) {
|
|
|
21802
22090
|
payment_term_days: params.paymentTermDays
|
|
21803
22091
|
}).select().single();
|
|
21804
22092
|
if (error2) {
|
|
21805
|
-
return toErrorContent(
|
|
22093
|
+
return toErrorContent(sanitizeDbError("Failed to create customer", error2));
|
|
21806
22094
|
}
|
|
21807
22095
|
return toTextContent({
|
|
21808
22096
|
message: `Customer "${params.name}" created with number ${customerNumber}`,
|
|
@@ -21825,12 +22113,15 @@ function registerCustomerTools(server2) {
|
|
|
21825
22113
|
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
21826
22114
|
);
|
|
21827
22115
|
}
|
|
21828
|
-
const { data: invoices } = await supabase2.from("invoices").select("total, status").eq("company_id", companyId2).eq("customer_id", params.customerId);
|
|
22116
|
+
const { data: invoices } = await supabase2.from("invoices").select("total, status, invoice_payments(amount)").eq("company_id", companyId2).eq("customer_id", params.customerId);
|
|
21829
22117
|
const invoiceList = invoices || [];
|
|
21830
22118
|
const invoiceCount = invoiceList.length;
|
|
21831
22119
|
const totalRevenue = roundCents(invoiceList.reduce((s, i) => s + (i.total || 0), 0));
|
|
21832
22120
|
const outstandingBalance = roundCents(
|
|
21833
|
-
invoiceList.filter((i) => i.status === "sent" || i.status === "partial" || i.status === "overdue").reduce((s, i) =>
|
|
22121
|
+
invoiceList.filter((i) => i.status === "sent" || i.status === "partial" || i.status === "overdue").reduce((s, i) => {
|
|
22122
|
+
const paid = (i.invoice_payments || []).reduce((ps, p) => ps + (p.amount || 0), 0);
|
|
22123
|
+
return s + (i.total || 0) - paid;
|
|
22124
|
+
}, 0)
|
|
21834
22125
|
);
|
|
21835
22126
|
return toTextContent({
|
|
21836
22127
|
...customer,
|
|
@@ -21890,7 +22181,7 @@ function registerCustomerTools(server2) {
|
|
|
21890
22181
|
}
|
|
21891
22182
|
const { data: updated, error: updateError } = await supabase2.from("customers").update(updates).eq("id", params.customerId).select().single();
|
|
21892
22183
|
if (updateError) {
|
|
21893
|
-
return toErrorContent(
|
|
22184
|
+
return toErrorContent(sanitizeDbError("Failed to update customer", updateError));
|
|
21894
22185
|
}
|
|
21895
22186
|
return toTextContent({
|
|
21896
22187
|
message: `Customer "${updated.name}" updated`,
|
|
@@ -21899,6 +22190,42 @@ function registerCustomerTools(server2) {
|
|
|
21899
22190
|
});
|
|
21900
22191
|
}
|
|
21901
22192
|
);
|
|
22193
|
+
server2.tool(
|
|
22194
|
+
"kirjapro_lookup_business",
|
|
22195
|
+
"Look up a Finnish company by Y-tunnus (business ID) from PRH (Patent and Registration Office). Returns official company name, address, company form, and registration date. Useful for auto-filling customer details.",
|
|
22196
|
+
{
|
|
22197
|
+
businessId: external_exports.string().regex(/^\d{7}-\d$/).describe('Finnish Y-tunnus (e.g. "1234567-8")')
|
|
22198
|
+
},
|
|
22199
|
+
async (params) => {
|
|
22200
|
+
if (!validateFinnishBusinessId(params.businessId)) {
|
|
22201
|
+
return toErrorContent(
|
|
22202
|
+
`Y-tunnus '${params.businessId}' is invalid (checksum failed). Valid format: 7 digits + hyphen + check digit. Example: 1234567-8.`
|
|
22203
|
+
);
|
|
22204
|
+
}
|
|
22205
|
+
const supabase2 = getSupabase();
|
|
22206
|
+
const result = await invokeEdgeFunction(
|
|
22207
|
+
supabase2,
|
|
22208
|
+
"prh-lookup",
|
|
22209
|
+
{ businessId: params.businessId }
|
|
22210
|
+
);
|
|
22211
|
+
if (result.error !== null) {
|
|
22212
|
+
return toErrorContent(result.error);
|
|
22213
|
+
}
|
|
22214
|
+
const company = result.data.company;
|
|
22215
|
+
return toTextContent({
|
|
22216
|
+
business_id: company.businessId,
|
|
22217
|
+
name: company.name,
|
|
22218
|
+
registration_date: company.registrationDate,
|
|
22219
|
+
company_form: company.companyForm,
|
|
22220
|
+
address: company.address,
|
|
22221
|
+
postal_code: company.postalCode,
|
|
22222
|
+
city: company.city,
|
|
22223
|
+
country: company.country,
|
|
22224
|
+
website: company.website,
|
|
22225
|
+
phone: company.phone
|
|
22226
|
+
});
|
|
22227
|
+
}
|
|
22228
|
+
);
|
|
21902
22229
|
}
|
|
21903
22230
|
|
|
21904
22231
|
// src/tools/reports.ts
|
|
@@ -21912,17 +22239,24 @@ function registerReportTools(server2) {
|
|
|
21912
22239
|
async (params) => {
|
|
21913
22240
|
const supabase2 = getSupabase();
|
|
21914
22241
|
const companyId2 = getCompanyId();
|
|
22242
|
+
const { data: fiscalYear, error: fyError } = await supabase2.from("fiscal_years").select("start_date, end_date").eq("id", params.fiscalYearId).eq("company_id", companyId2).single();
|
|
22243
|
+
if (fyError || !fiscalYear) {
|
|
22244
|
+
return toErrorContent(
|
|
22245
|
+
`Fiscal year ${params.fiscalYearId} not found. Use kirjapro_list_fiscal_years to find available fiscal years.`
|
|
22246
|
+
);
|
|
22247
|
+
}
|
|
22248
|
+
const JOURNAL_ENTRY_CAP = 5e4;
|
|
21915
22249
|
const [entriesResult, txCountResult, invoiceResult, bankResult] = await Promise.all([
|
|
21916
|
-
// Revenue & expense totals from journal entries
|
|
22250
|
+
// Revenue & expense totals from journal entries (capped to prevent OOM on large datasets)
|
|
21917
22251
|
supabase2.from("journal_entries").select(`
|
|
21918
22252
|
debit_amount, credit_amount,
|
|
21919
22253
|
account:accounts!inner(account_type),
|
|
21920
22254
|
transaction:transactions!inner(fiscal_year_id)
|
|
21921
|
-
|
|
22255
|
+
`, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).limit(JOURNAL_ENTRY_CAP),
|
|
21922
22256
|
// Transaction count
|
|
21923
22257
|
supabase2.from("transactions").select("*", { count: "exact", head: true }).eq("company_id", companyId2).eq("fiscal_year_id", params.fiscalYearId),
|
|
21924
|
-
// Invoice stats
|
|
21925
|
-
supabase2.from("invoices").select("id, status, total, due_date").eq("company_id", companyId2),
|
|
22258
|
+
// Invoice stats scoped to fiscal year date range
|
|
22259
|
+
supabase2.from("invoices").select("id, status, total, due_date").eq("company_id", companyId2).gte("invoice_date", fiscalYear.start_date).lte("invoice_date", fiscalYear.end_date),
|
|
21926
22260
|
// Bank balances
|
|
21927
22261
|
supabase2.from("bank_accounts").select("account_name, iban, current_balance").eq("company_id", companyId2).eq("is_active", true)
|
|
21928
22262
|
]);
|
|
@@ -21952,11 +22286,12 @@ function registerReportTools(server2) {
|
|
|
21952
22286
|
}
|
|
21953
22287
|
}
|
|
21954
22288
|
const bankAccounts = (bankResult.data || []).map((b) => ({
|
|
21955
|
-
name: b.account_name,
|
|
22289
|
+
name: b.account_name || "Unnamed",
|
|
21956
22290
|
iban: b.iban,
|
|
21957
22291
|
balance: b.current_balance || 0
|
|
21958
22292
|
}));
|
|
21959
22293
|
const totalBankBalance = bankAccounts.reduce((s, b) => s + b.balance, 0);
|
|
22294
|
+
const entriesCapped = (entriesResult.count || 0) >= JOURNAL_ENTRY_CAP;
|
|
21960
22295
|
return toTextContent({
|
|
21961
22296
|
financial_summary: {
|
|
21962
22297
|
revenue: roundCents(revenue),
|
|
@@ -21965,7 +22300,8 @@ function registerReportTools(server2) {
|
|
|
21965
22300
|
expenses_formatted: formatCurrency(expenses),
|
|
21966
22301
|
profit: roundCents(revenue - expenses),
|
|
21967
22302
|
profit_formatted: formatCurrency(revenue - expenses),
|
|
21968
|
-
transaction_count: txCountResult.count || 0
|
|
22303
|
+
transaction_count: txCountResult.count || 0,
|
|
22304
|
+
...entriesCapped && { warning: `Results capped at ${JOURNAL_ENTRY_CAP} journal entries. Totals may be approximate.` }
|
|
21969
22305
|
},
|
|
21970
22306
|
invoice_stats: invoiceStats,
|
|
21971
22307
|
bank_summary: {
|
|
@@ -21987,13 +22323,14 @@ function registerReportTools(server2) {
|
|
|
21987
22323
|
async (params) => {
|
|
21988
22324
|
const supabase2 = getSupabase();
|
|
21989
22325
|
getCompanyId();
|
|
21990
|
-
const
|
|
22326
|
+
const ENTRY_CAP = 5e4;
|
|
22327
|
+
const { data: entries, count: entryCount, error: error2 } = await supabase2.from("journal_entries").select(`
|
|
21991
22328
|
debit_amount, credit_amount,
|
|
21992
22329
|
account:accounts!inner(id, account_number, name, account_type),
|
|
21993
22330
|
transaction:transactions!inner(fiscal_year_id, transaction_date)
|
|
21994
|
-
|
|
22331
|
+
`, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).gte("transaction.transaction_date", params.dateFrom).lte("transaction.transaction_date", params.dateTo).limit(ENTRY_CAP);
|
|
21995
22332
|
if (error2) {
|
|
21996
|
-
return toErrorContent(
|
|
22333
|
+
return toErrorContent(sanitizeDbError("Failed to generate income statement", error2));
|
|
21997
22334
|
}
|
|
21998
22335
|
const accountTotals = /* @__PURE__ */ new Map();
|
|
21999
22336
|
for (const entry of entries || []) {
|
|
@@ -22027,6 +22364,7 @@ function registerReportTools(server2) {
|
|
|
22027
22364
|
expenseAccounts.push(acc);
|
|
22028
22365
|
}
|
|
22029
22366
|
}
|
|
22367
|
+
const capped = (entryCount || 0) >= ENTRY_CAP;
|
|
22030
22368
|
return toTextContent({
|
|
22031
22369
|
period: { from: params.dateFrom, to: params.dateTo },
|
|
22032
22370
|
revenue: {
|
|
@@ -22040,7 +22378,8 @@ function registerReportTools(server2) {
|
|
|
22040
22378
|
total_formatted: formatCurrency(totalExpenses)
|
|
22041
22379
|
},
|
|
22042
22380
|
net_profit: roundCents(totalRevenue - totalExpenses),
|
|
22043
|
-
net_profit_formatted: formatCurrency(totalRevenue - totalExpenses)
|
|
22381
|
+
net_profit_formatted: formatCurrency(totalRevenue - totalExpenses),
|
|
22382
|
+
...capped && { warning: `Results capped at ${ENTRY_CAP} journal entries. Narrow the date range for accurate totals.` }
|
|
22044
22383
|
});
|
|
22045
22384
|
}
|
|
22046
22385
|
);
|
|
@@ -22054,13 +22393,14 @@ function registerReportTools(server2) {
|
|
|
22054
22393
|
async (params) => {
|
|
22055
22394
|
const supabase2 = getSupabase();
|
|
22056
22395
|
getCompanyId();
|
|
22057
|
-
const
|
|
22396
|
+
const BS_ENTRY_CAP = 5e4;
|
|
22397
|
+
const { data: entries, count: bsEntryCount, error: error2 } = await supabase2.from("journal_entries").select(`
|
|
22058
22398
|
debit_amount, credit_amount,
|
|
22059
22399
|
account:accounts!inner(id, account_number, name, account_type),
|
|
22060
22400
|
transaction:transactions!inner(fiscal_year_id, transaction_date)
|
|
22061
|
-
|
|
22401
|
+
`, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).lte("transaction.transaction_date", params.reportDate).limit(BS_ENTRY_CAP);
|
|
22062
22402
|
if (error2) {
|
|
22063
|
-
return toErrorContent(
|
|
22403
|
+
return toErrorContent(sanitizeDbError("Failed to generate balance sheet", error2));
|
|
22064
22404
|
}
|
|
22065
22405
|
const accountTotals = /* @__PURE__ */ new Map();
|
|
22066
22406
|
for (const entry of entries || []) {
|
|
@@ -22105,6 +22445,7 @@ function registerReportTools(server2) {
|
|
|
22105
22445
|
break;
|
|
22106
22446
|
}
|
|
22107
22447
|
}
|
|
22448
|
+
const bsCapped = (bsEntryCount || 0) >= BS_ENTRY_CAP;
|
|
22108
22449
|
return toTextContent({
|
|
22109
22450
|
report_date: params.reportDate,
|
|
22110
22451
|
assets: {
|
|
@@ -22123,7 +22464,8 @@ function registerReportTools(server2) {
|
|
|
22123
22464
|
total_formatted: formatCurrency(totalEquity)
|
|
22124
22465
|
},
|
|
22125
22466
|
total_liabilities_and_equity: roundCents(totalLiabilities + totalEquity),
|
|
22126
|
-
balanced: Math.abs(totalAssets - (totalLiabilities + totalEquity)) < 0.01
|
|
22467
|
+
balanced: Math.abs(totalAssets - (totalLiabilities + totalEquity)) < 0.01,
|
|
22468
|
+
...bsCapped && { warning: `Results capped at ${BS_ENTRY_CAP} journal entries. Totals may be approximate.` }
|
|
22127
22469
|
});
|
|
22128
22470
|
}
|
|
22129
22471
|
);
|
|
@@ -22136,7 +22478,7 @@ function registerReportTools(server2) {
|
|
|
22136
22478
|
const companyId2 = getCompanyId();
|
|
22137
22479
|
const { data: invoices, error: error2 } = await supabase2.from("invoices").select("id, invoice_number, total, due_date, invoice_date, customers(name)").eq("company_id", companyId2).in("status", ["sent", "overdue"]);
|
|
22138
22480
|
if (error2) {
|
|
22139
|
-
return toErrorContent(
|
|
22481
|
+
return toErrorContent(sanitizeDbError("Failed to get AR aging", error2));
|
|
22140
22482
|
}
|
|
22141
22483
|
const now = /* @__PURE__ */ new Date();
|
|
22142
22484
|
const buckets = [
|
|
@@ -22208,7 +22550,7 @@ function registerReportTools(server2) {
|
|
|
22208
22550
|
}
|
|
22209
22551
|
const { data: entries, error: error2 } = await query;
|
|
22210
22552
|
if (error2) {
|
|
22211
|
-
return toErrorContent(
|
|
22553
|
+
return toErrorContent(sanitizeDbError("Failed to get general ledger", error2));
|
|
22212
22554
|
}
|
|
22213
22555
|
const debitNormal = ["asset", "expense"];
|
|
22214
22556
|
const isDebitNormal = debitNormal.includes(account.account_type);
|
|
@@ -22248,6 +22590,38 @@ function registerReportTools(server2) {
|
|
|
22248
22590
|
});
|
|
22249
22591
|
}
|
|
22250
22592
|
);
|
|
22593
|
+
server2.tool(
|
|
22594
|
+
"kirjapro_get_alv_summary",
|
|
22595
|
+
"Get VAT (ALV) summary for a date range. Returns output/input VAT breakdown by rate and OmaVero field codes (301-320). For filing deadlines, read kirjapro://compliance/deadlines.",
|
|
22596
|
+
{
|
|
22597
|
+
dateFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Period start date (YYYY-MM-DD)"),
|
|
22598
|
+
dateTo: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Period end date (YYYY-MM-DD)")
|
|
22599
|
+
},
|
|
22600
|
+
async (params) => {
|
|
22601
|
+
const supabase2 = getSupabase();
|
|
22602
|
+
const companyId2 = getCompanyId();
|
|
22603
|
+
const { data, error: error2 } = await supabase2.rpc("get_alv_summary", {
|
|
22604
|
+
p_company_id: companyId2,
|
|
22605
|
+
p_date_from: params.dateFrom,
|
|
22606
|
+
p_date_to: params.dateTo
|
|
22607
|
+
});
|
|
22608
|
+
if (error2) {
|
|
22609
|
+
return toErrorContent(sanitizeDbError("Failed to get ALV summary", error2));
|
|
22610
|
+
}
|
|
22611
|
+
const result = data;
|
|
22612
|
+
if (!result) {
|
|
22613
|
+
return toTextContent({
|
|
22614
|
+
period: { from: params.dateFrom, to: params.dateTo },
|
|
22615
|
+
message: "No VAT data found for this period."
|
|
22616
|
+
});
|
|
22617
|
+
}
|
|
22618
|
+
return toTextContent({
|
|
22619
|
+
...result,
|
|
22620
|
+
period: { from: params.dateFrom, to: params.dateTo },
|
|
22621
|
+
net_vat_payable_formatted: formatCurrency(result.net_vat_payable || 0)
|
|
22622
|
+
});
|
|
22623
|
+
}
|
|
22624
|
+
);
|
|
22251
22625
|
}
|
|
22252
22626
|
|
|
22253
22627
|
// src/tools/banking.ts
|
|
@@ -22262,7 +22636,9 @@ function registerBankingTools(server2) {
|
|
|
22262
22636
|
async (params) => {
|
|
22263
22637
|
const supabase2 = getSupabase();
|
|
22264
22638
|
const companyId2 = getCompanyId();
|
|
22265
|
-
const { data: bankTx, error: btError } = await supabase2.from("bank_transactions").select(
|
|
22639
|
+
const { data: bankTx, error: btError } = await supabase2.from("bank_transactions").select(
|
|
22640
|
+
"id, amount, booking_date, credit_debit, counterparty_name, reference, reconciled_at"
|
|
22641
|
+
).eq("id", params.bankTransactionId).eq("company_id", companyId2).single();
|
|
22266
22642
|
if (btError || !bankTx) {
|
|
22267
22643
|
return toErrorContent(
|
|
22268
22644
|
`Bank transaction ${params.bankTransactionId} not found. Verify the ID and ensure it belongs to your company.`
|
|
@@ -22278,58 +22654,58 @@ function registerBankingTools(server2) {
|
|
|
22278
22654
|
`Only credit (incoming) bank transactions can be matched to invoices. This is a debit (outgoing) transaction of ${Math.abs(bankTx.amount).toFixed(2)} EUR.`
|
|
22279
22655
|
);
|
|
22280
22656
|
}
|
|
22281
|
-
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
22657
|
+
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
22658
|
+
`
|
|
22282
22659
|
id, invoice_number, total, status,
|
|
22283
22660
|
invoice_payments ( amount )
|
|
22284
|
-
`
|
|
22661
|
+
`
|
|
22662
|
+
).eq("id", params.invoiceId).eq("company_id", companyId2).single();
|
|
22285
22663
|
if (invError || !invoice) {
|
|
22286
22664
|
return toErrorContent(
|
|
22287
22665
|
`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
|
|
22288
22666
|
);
|
|
22289
22667
|
}
|
|
22290
22668
|
if (invoice.status === "paid") {
|
|
22291
|
-
return toErrorContent(
|
|
22669
|
+
return toErrorContent(
|
|
22670
|
+
`Invoice ${invoice.invoice_number} is already fully paid.`
|
|
22671
|
+
);
|
|
22672
|
+
}
|
|
22673
|
+
if (invoice.status === "cancelled") {
|
|
22674
|
+
return toErrorContent(
|
|
22675
|
+
`Invoice ${invoice.invoice_number} is cancelled and cannot accept payments.`
|
|
22676
|
+
);
|
|
22292
22677
|
}
|
|
22293
22678
|
const paidSoFar = roundCents(
|
|
22294
|
-
(invoice.invoice_payments || []).reduce(
|
|
22679
|
+
(invoice.invoice_payments || []).reduce(
|
|
22680
|
+
(s, p) => s + (p.amount || 0),
|
|
22681
|
+
0
|
|
22682
|
+
)
|
|
22295
22683
|
);
|
|
22296
22684
|
const remaining = roundCents(invoice.total - paidSoFar);
|
|
22297
|
-
const paymentAmount =
|
|
22298
|
-
|
|
22299
|
-
|
|
22300
|
-
|
|
22301
|
-
|
|
22302
|
-
payment_method: "bank_transfer",
|
|
22303
|
-
reference: bankTx.reference || null
|
|
22304
|
-
}).select().single();
|
|
22305
|
-
if (payError) {
|
|
22306
|
-
return toErrorContent(`Failed to create payment: ${payError.message}`);
|
|
22685
|
+
const paymentAmount = roundCents(Math.abs(bankTx.amount));
|
|
22686
|
+
if (paymentAmount > remaining + 0.01) {
|
|
22687
|
+
return toErrorContent(
|
|
22688
|
+
`Bank transaction amount ${paymentAmount.toFixed(2)} exceeds remaining invoice balance ${remaining.toFixed(2)} EUR on ${invoice.invoice_number}. Use a smaller matching target or split the payment before reconciling.`
|
|
22689
|
+
);
|
|
22307
22690
|
}
|
|
22308
|
-
const { data:
|
|
22309
|
-
|
|
22310
|
-
|
|
22311
|
-
|
|
22312
|
-
|
|
22313
|
-
|
|
22314
|
-
|
|
22315
|
-
|
|
22316
|
-
|
|
22317
|
-
|
|
22318
|
-
|
|
22319
|
-
|
|
22320
|
-
|
|
22321
|
-
|
|
22322
|
-
|
|
22323
|
-
reconciliation_match_id: match?.id || null
|
|
22324
|
-
}).eq("id", params.bankTransactionId);
|
|
22325
|
-
const newPaidTotal = roundCents(paidSoFar + paymentAmount);
|
|
22326
|
-
const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
|
|
22327
|
-
if (newStatus !== invoice.status) {
|
|
22328
|
-
await supabase2.from("invoices").update({
|
|
22329
|
-
status: newStatus,
|
|
22330
|
-
paid_date: newStatus === "paid" ? bankTx.booking_date : null
|
|
22331
|
-
}).eq("id", params.invoiceId);
|
|
22691
|
+
const { data: matchResult, error: matchError } = await supabase2.rpc(
|
|
22692
|
+
"confirm_reconciliation_match",
|
|
22693
|
+
{
|
|
22694
|
+
p_company_id: companyId2,
|
|
22695
|
+
p_bank_transaction_id: params.bankTransactionId,
|
|
22696
|
+
p_match_type: "invoice",
|
|
22697
|
+
p_matched_invoice_id: params.invoiceId,
|
|
22698
|
+
p_confidence: 100,
|
|
22699
|
+
p_match_method: "mcp_manual"
|
|
22700
|
+
}
|
|
22701
|
+
);
|
|
22702
|
+
if (matchError || !matchResult) {
|
|
22703
|
+
return toErrorContent(
|
|
22704
|
+
matchError ? sanitizeDbError("Failed to confirm reconciliation match", matchError) : "Failed to confirm reconciliation match."
|
|
22705
|
+
);
|
|
22332
22706
|
}
|
|
22707
|
+
const newPaidTotal = roundCents(paidSoFar + paymentAmount);
|
|
22708
|
+
const newStatus = newPaidTotal >= invoice.total ? "paid" : "partial";
|
|
22333
22709
|
return toTextContent({
|
|
22334
22710
|
message: `Bank transaction matched to invoice ${invoice.invoice_number}`,
|
|
22335
22711
|
payment_amount: paymentAmount,
|
|
@@ -22346,9 +22722,11 @@ function registerBankingTools(server2) {
|
|
|
22346
22722
|
async () => {
|
|
22347
22723
|
const supabase2 = getSupabase();
|
|
22348
22724
|
const companyId2 = getCompanyId();
|
|
22349
|
-
const { data, error: error2 } = await supabase2.from("bank_accounts").select(
|
|
22725
|
+
const { data, error: error2 } = await supabase2.from("bank_accounts").select(
|
|
22726
|
+
"id, account_name, iban, bic, bank_name, current_balance, available_balance, is_primary, last_sync_at, connection_status"
|
|
22727
|
+
).eq("company_id", companyId2).eq("is_active", true).order("is_primary", { ascending: false });
|
|
22350
22728
|
if (error2) {
|
|
22351
|
-
return toErrorContent(
|
|
22729
|
+
return toErrorContent(sanitizeDbError("Failed to list bank accounts", error2));
|
|
22352
22730
|
}
|
|
22353
22731
|
return toTextContent({
|
|
22354
22732
|
accounts: data || [],
|
|
@@ -22401,7 +22779,9 @@ function registerBankingTools(server2) {
|
|
|
22401
22779
|
query = query.range(offset, offset + limit - 1);
|
|
22402
22780
|
const { data, count, error: error2 } = await query;
|
|
22403
22781
|
if (error2) {
|
|
22404
|
-
return toErrorContent(
|
|
22782
|
+
return toErrorContent(
|
|
22783
|
+
sanitizeDbError("Failed to list bank transactions", error2)
|
|
22784
|
+
);
|
|
22405
22785
|
}
|
|
22406
22786
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
22407
22787
|
}
|
|
@@ -22524,7 +22904,7 @@ function registerFiscalYearTools(server2) {
|
|
|
22524
22904
|
const companyId2 = getCompanyId();
|
|
22525
22905
|
const { data, error: error2 } = await supabase2.from("fiscal_years").select("id, start_date, end_date, is_closed, created_at").eq("company_id", companyId2).order("start_date", { ascending: false });
|
|
22526
22906
|
if (error2) {
|
|
22527
|
-
return toErrorContent(
|
|
22907
|
+
return toErrorContent(sanitizeDbError("Failed to list fiscal years", error2));
|
|
22528
22908
|
}
|
|
22529
22909
|
return toTextContent({
|
|
22530
22910
|
fiscal_years: data || [],
|
|
@@ -22534,137 +22914,1371 @@ function registerFiscalYearTools(server2) {
|
|
|
22534
22914
|
);
|
|
22535
22915
|
}
|
|
22536
22916
|
|
|
22537
|
-
// src/tools/
|
|
22538
|
-
function
|
|
22539
|
-
|
|
22540
|
-
|
|
22541
|
-
|
|
22542
|
-
|
|
22543
|
-
|
|
22544
|
-
|
|
22545
|
-
|
|
22546
|
-
|
|
22917
|
+
// src/tools/templates.ts
|
|
22918
|
+
function splitVat(gross, vatRate) {
|
|
22919
|
+
if (vatRate === 0) return { net: roundCents(gross), vat: 0 };
|
|
22920
|
+
const net = roundCents(gross / (1 + vatRate / 100));
|
|
22921
|
+
const vat = roundCents(gross - net);
|
|
22922
|
+
return { net, vat };
|
|
22923
|
+
}
|
|
22924
|
+
function applyOverrides(entries, overrides) {
|
|
22925
|
+
if (!overrides) return entries;
|
|
22926
|
+
return entries.map((e) => ({
|
|
22927
|
+
...e,
|
|
22928
|
+
accountNumber: overrides[e.accountNumber] ?? e.accountNumber
|
|
22929
|
+
}));
|
|
22930
|
+
}
|
|
22931
|
+
function saleCashTemplate(revenueAccount, vatRate) {
|
|
22932
|
+
return (amount, opts) => {
|
|
22933
|
+
const { net, vat } = splitVat(amount, vatRate);
|
|
22934
|
+
const entries = [
|
|
22935
|
+
{
|
|
22936
|
+
accountNumber: "2310",
|
|
22937
|
+
debitAmount: roundCents(amount),
|
|
22938
|
+
creditAmount: 0
|
|
22939
|
+
},
|
|
22940
|
+
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
|
|
22941
|
+
];
|
|
22942
|
+
if (vat > 0) {
|
|
22943
|
+
entries.push({
|
|
22944
|
+
accountNumber: "4050",
|
|
22945
|
+
debitAmount: 0,
|
|
22946
|
+
creditAmount: vat
|
|
22947
|
+
});
|
|
22948
|
+
}
|
|
22949
|
+
return applyOverrides(entries, opts?.accountOverrides);
|
|
22950
|
+
};
|
|
22547
22951
|
}
|
|
22548
|
-
|
|
22549
|
-
|
|
22550
|
-
|
|
22551
|
-
|
|
22552
|
-
|
|
22553
|
-
|
|
22554
|
-
|
|
22555
|
-
|
|
22556
|
-
|
|
22557
|
-
|
|
22558
|
-
|
|
22559
|
-
|
|
22560
|
-
|
|
22561
|
-
|
|
22562
|
-
|
|
22563
|
-
|
|
22564
|
-
|
|
22565
|
-
|
|
22566
|
-
|
|
22567
|
-
|
|
22568
|
-
|
|
22569
|
-
|
|
22570
|
-
|
|
22571
|
-
{
|
|
22572
|
-
|
|
22573
|
-
|
|
22574
|
-
|
|
22575
|
-
|
|
22576
|
-
|
|
22577
|
-
|
|
22578
|
-
|
|
22579
|
-
|
|
22580
|
-
|
|
22581
|
-
|
|
22582
|
-
|
|
22583
|
-
{
|
|
22584
|
-
|
|
22585
|
-
|
|
22586
|
-
|
|
22587
|
-
|
|
22588
|
-
|
|
22589
|
-
{
|
|
22590
|
-
|
|
22591
|
-
|
|
22592
|
-
|
|
22593
|
-
|
|
22594
|
-
|
|
22595
|
-
//
|
|
22596
|
-
{
|
|
22597
|
-
|
|
22598
|
-
|
|
22599
|
-
|
|
22600
|
-
|
|
22601
|
-
|
|
22602
|
-
{
|
|
22603
|
-
|
|
22604
|
-
|
|
22605
|
-
|
|
22606
|
-
|
|
22607
|
-
|
|
22608
|
-
{
|
|
22609
|
-
|
|
22610
|
-
|
|
22611
|
-
|
|
22612
|
-
|
|
22613
|
-
|
|
22614
|
-
|
|
22615
|
-
{
|
|
22616
|
-
|
|
22617
|
-
|
|
22618
|
-
|
|
22619
|
-
|
|
22620
|
-
|
|
22621
|
-
|
|
22622
|
-
|
|
22623
|
-
|
|
22624
|
-
|
|
22625
|
-
|
|
22626
|
-
|
|
22627
|
-
|
|
22628
|
-
|
|
22629
|
-
|
|
22630
|
-
|
|
22631
|
-
|
|
22632
|
-
|
|
22633
|
-
|
|
22634
|
-
|
|
22635
|
-
|
|
22636
|
-
{
|
|
22637
|
-
|
|
22638
|
-
|
|
22639
|
-
|
|
22640
|
-
|
|
22641
|
-
|
|
22642
|
-
|
|
22643
|
-
|
|
22644
|
-
|
|
22645
|
-
|
|
22646
|
-
|
|
22647
|
-
|
|
22648
|
-
|
|
22649
|
-
|
|
22650
|
-
|
|
22651
|
-
|
|
22652
|
-
|
|
22653
|
-
|
|
22654
|
-
}
|
|
22655
|
-
|
|
22952
|
+
function saleBankTemplate(revenueAccount, vatRate) {
|
|
22953
|
+
return (amount, opts) => {
|
|
22954
|
+
const { net, vat } = splitVat(amount, vatRate);
|
|
22955
|
+
const entries = [
|
|
22956
|
+
{
|
|
22957
|
+
accountNumber: "2320",
|
|
22958
|
+
debitAmount: roundCents(amount),
|
|
22959
|
+
creditAmount: 0
|
|
22960
|
+
},
|
|
22961
|
+
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
|
|
22962
|
+
];
|
|
22963
|
+
if (vat > 0) {
|
|
22964
|
+
entries.push({
|
|
22965
|
+
accountNumber: "4050",
|
|
22966
|
+
debitAmount: 0,
|
|
22967
|
+
creditAmount: vat
|
|
22968
|
+
});
|
|
22969
|
+
}
|
|
22970
|
+
return applyOverrides(entries, opts?.accountOverrides);
|
|
22971
|
+
};
|
|
22972
|
+
}
|
|
22973
|
+
var TEMPLATES = [
|
|
22974
|
+
// Cash sales
|
|
22975
|
+
{
|
|
22976
|
+
id: "cash_sale_25",
|
|
22977
|
+
description: "K\xE4teismyynti ALV 25,5%",
|
|
22978
|
+
vatRate: 25.5,
|
|
22979
|
+
expand: saleCashTemplate("5010", 25.5)
|
|
22980
|
+
},
|
|
22981
|
+
{
|
|
22982
|
+
id: "cash_sale_14",
|
|
22983
|
+
description: "K\xE4teismyynti ALV 13,5%",
|
|
22984
|
+
vatRate: 13.5,
|
|
22985
|
+
expand: saleCashTemplate("5020", 13.5)
|
|
22986
|
+
},
|
|
22987
|
+
{
|
|
22988
|
+
id: "cash_sale_10",
|
|
22989
|
+
description: "K\xE4teismyynti ALV 10%",
|
|
22990
|
+
vatRate: 10,
|
|
22991
|
+
expand: saleCashTemplate("5030", 10)
|
|
22992
|
+
},
|
|
22993
|
+
{
|
|
22994
|
+
id: "cash_sale_0",
|
|
22995
|
+
description: "K\xE4teismyynti ALV 0%",
|
|
22996
|
+
vatRate: 0,
|
|
22997
|
+
expand: saleCashTemplate("5040", 0)
|
|
22998
|
+
},
|
|
22999
|
+
// Bank sales
|
|
23000
|
+
{
|
|
23001
|
+
id: "bank_sale_25",
|
|
23002
|
+
description: "Pankkimyynti ALV 25,5%",
|
|
23003
|
+
vatRate: 25.5,
|
|
23004
|
+
expand: saleBankTemplate("5010", 25.5)
|
|
23005
|
+
},
|
|
23006
|
+
{
|
|
23007
|
+
id: "bank_sale_14",
|
|
23008
|
+
description: "Pankkimyynti ALV 13,5%",
|
|
23009
|
+
vatRate: 13.5,
|
|
23010
|
+
expand: saleBankTemplate("5020", 13.5)
|
|
23011
|
+
},
|
|
23012
|
+
{
|
|
23013
|
+
id: "bank_sale_10",
|
|
23014
|
+
description: "Pankkimyynti ALV 10%",
|
|
23015
|
+
vatRate: 10,
|
|
23016
|
+
expand: saleBankTemplate("5030", 10)
|
|
23017
|
+
},
|
|
23018
|
+
// Invoice sale
|
|
23019
|
+
{
|
|
23020
|
+
id: "invoice_sale_25",
|
|
23021
|
+
description: "Laskumyynti ALV 25,5% (myyntisaamiset)",
|
|
23022
|
+
vatRate: 25.5,
|
|
23023
|
+
expand: (amount, opts) => {
|
|
23024
|
+
const { net, vat } = splitVat(amount, 25.5);
|
|
23025
|
+
return applyOverrides(
|
|
23026
|
+
[
|
|
23027
|
+
{
|
|
23028
|
+
accountNumber: "2110",
|
|
23029
|
+
debitAmount: roundCents(amount),
|
|
23030
|
+
creditAmount: 0
|
|
23031
|
+
},
|
|
23032
|
+
{ accountNumber: "5010", debitAmount: 0, creditAmount: net },
|
|
23033
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat }
|
|
23034
|
+
],
|
|
23035
|
+
opts?.accountOverrides
|
|
23036
|
+
);
|
|
23037
|
+
}
|
|
23038
|
+
},
|
|
23039
|
+
// Purchase expenses
|
|
23040
|
+
{
|
|
23041
|
+
id: "purchase_expense",
|
|
23042
|
+
description: "Osto ALV 25,5% (pankkitililt\xE4)",
|
|
23043
|
+
vatRate: 25.5,
|
|
23044
|
+
expand: (amount, opts) => {
|
|
23045
|
+
const { net, vat } = splitVat(amount, 25.5);
|
|
23046
|
+
return applyOverrides(
|
|
23047
|
+
[
|
|
23048
|
+
{ accountNumber: "6010", debitAmount: net, creditAmount: 0 },
|
|
23049
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23050
|
+
{
|
|
23051
|
+
accountNumber: "2320",
|
|
23052
|
+
debitAmount: 0,
|
|
23053
|
+
creditAmount: roundCents(amount)
|
|
23054
|
+
}
|
|
23055
|
+
],
|
|
23056
|
+
opts?.accountOverrides
|
|
23057
|
+
);
|
|
23058
|
+
}
|
|
23059
|
+
},
|
|
23060
|
+
{
|
|
23061
|
+
id: "service_expense",
|
|
23062
|
+
description: "Ulkopuolinen palvelu ALV 25,5%",
|
|
23063
|
+
vatRate: 25.5,
|
|
23064
|
+
expand: (amount, opts) => {
|
|
23065
|
+
const { net, vat } = splitVat(amount, 25.5);
|
|
23066
|
+
return applyOverrides(
|
|
23067
|
+
[
|
|
23068
|
+
{ accountNumber: "6030", debitAmount: net, creditAmount: 0 },
|
|
23069
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23070
|
+
{
|
|
23071
|
+
accountNumber: "2320",
|
|
23072
|
+
debitAmount: 0,
|
|
23073
|
+
creditAmount: roundCents(amount)
|
|
23074
|
+
}
|
|
23075
|
+
],
|
|
23076
|
+
opts?.accountOverrides
|
|
23077
|
+
);
|
|
23078
|
+
}
|
|
23079
|
+
},
|
|
23080
|
+
// Rent (no VAT)
|
|
23081
|
+
{
|
|
23082
|
+
id: "rent_payment",
|
|
23083
|
+
description: "Vuokranmaksu (pankkitililt\xE4)",
|
|
23084
|
+
vatRate: null,
|
|
23085
|
+
expand: (amount, opts) => applyOverrides(
|
|
23086
|
+
[
|
|
23087
|
+
{
|
|
23088
|
+
accountNumber: "8010",
|
|
23089
|
+
debitAmount: roundCents(amount),
|
|
23090
|
+
creditAmount: 0
|
|
23091
|
+
},
|
|
23092
|
+
{
|
|
23093
|
+
accountNumber: "2320",
|
|
23094
|
+
debitAmount: 0,
|
|
23095
|
+
creditAmount: roundCents(amount)
|
|
23096
|
+
}
|
|
23097
|
+
],
|
|
23098
|
+
opts?.accountOverrides
|
|
23099
|
+
)
|
|
23100
|
+
},
|
|
23101
|
+
// Salary
|
|
23102
|
+
{
|
|
23103
|
+
id: "salary_payment",
|
|
23104
|
+
description: "Palkanmaksu (bruttopalkka, ennakonpid\xE4tys, TyEL)",
|
|
23105
|
+
vatRate: null,
|
|
23106
|
+
expand: (amount, opts) => {
|
|
23107
|
+
const gross = roundCents(amount);
|
|
23108
|
+
const withholdingRate = opts?.withholdingRate ?? 20;
|
|
23109
|
+
const tyelEmployeeRate = opts?.tyelRate ?? 7.3;
|
|
23110
|
+
const tyelEmployerRate = 17.55;
|
|
23111
|
+
const withholding = roundCents(gross * withholdingRate / 100);
|
|
23112
|
+
const tyelEmployee = roundCents(gross * tyelEmployeeRate / 100);
|
|
23113
|
+
const tyelEmployer = roundCents(gross * tyelEmployerRate / 100);
|
|
23114
|
+
const netPay = roundCents(gross - withholding - tyelEmployee);
|
|
23115
|
+
return applyOverrides(
|
|
23116
|
+
[
|
|
23117
|
+
{ accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
|
|
23118
|
+
{ accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
|
|
23119
|
+
{ accountNumber: "2320", debitAmount: 0, creditAmount: netPay },
|
|
23120
|
+
{ accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
|
|
23121
|
+
{
|
|
23122
|
+
accountNumber: "4080",
|
|
23123
|
+
debitAmount: 0,
|
|
23124
|
+
creditAmount: roundCents(tyelEmployee + tyelEmployer)
|
|
23125
|
+
}
|
|
23126
|
+
],
|
|
23127
|
+
opts?.accountOverrides
|
|
23128
|
+
);
|
|
23129
|
+
}
|
|
23130
|
+
},
|
|
23131
|
+
// Bank transfer
|
|
23132
|
+
{
|
|
23133
|
+
id: "bank_transfer",
|
|
23134
|
+
description: "Tilisiirto kassasta pankkitilille",
|
|
23135
|
+
vatRate: null,
|
|
23136
|
+
expand: (amount, opts) => applyOverrides(
|
|
23137
|
+
[
|
|
23138
|
+
{
|
|
23139
|
+
accountNumber: "2320",
|
|
23140
|
+
debitAmount: roundCents(amount),
|
|
23141
|
+
creditAmount: 0
|
|
23142
|
+
},
|
|
23143
|
+
{
|
|
23144
|
+
accountNumber: "2310",
|
|
23145
|
+
debitAmount: 0,
|
|
23146
|
+
creditAmount: roundCents(amount)
|
|
23147
|
+
}
|
|
23148
|
+
],
|
|
23149
|
+
opts?.accountOverrides
|
|
23150
|
+
)
|
|
23151
|
+
}
|
|
23152
|
+
];
|
|
23153
|
+
var TEMPLATE_MAP = new Map(TEMPLATES.map((t) => [t.id, t]));
|
|
23154
|
+
function expandTemplate(templateId, amount, opts) {
|
|
23155
|
+
const template = TEMPLATE_MAP.get(templateId);
|
|
23156
|
+
if (!template) {
|
|
23157
|
+
return {
|
|
23158
|
+
error: `Unknown template "${templateId}". Use kirjapro_list_templates to see available templates.`
|
|
23159
|
+
};
|
|
23160
|
+
}
|
|
23161
|
+
if (amount <= 0) {
|
|
23162
|
+
return { error: "Amount must be positive." };
|
|
23163
|
+
}
|
|
23164
|
+
const entries = template.expand(amount, opts);
|
|
23165
|
+
return { entries };
|
|
23166
|
+
}
|
|
23167
|
+
async function resolveAndCreate(params) {
|
|
23168
|
+
const supabase2 = getSupabase();
|
|
23169
|
+
const companyId2 = getCompanyId();
|
|
23170
|
+
let fiscalYearId = params.fiscalYearId;
|
|
23171
|
+
if (!fiscalYearId) {
|
|
23172
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
23173
|
+
if ("isError" in resolved) return resolved;
|
|
23174
|
+
fiscalYearId = resolved.id;
|
|
23175
|
+
}
|
|
23176
|
+
const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
23177
|
+
if (fyError || !fy) {
|
|
23178
|
+
return toErrorContent(
|
|
23179
|
+
`Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard or read kirjapro://fiscal-years resource.`
|
|
23180
|
+
);
|
|
23181
|
+
}
|
|
23182
|
+
if (fy.is_closed) {
|
|
23183
|
+
return toErrorContent(
|
|
23184
|
+
`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`
|
|
23185
|
+
);
|
|
23186
|
+
}
|
|
23187
|
+
if (params.transactionDate < fy.start_date || params.transactionDate > fy.end_date) {
|
|
23188
|
+
return toErrorContent(
|
|
23189
|
+
`Transaction date ${params.transactionDate} outside fiscal year (${fy.start_date} to ${fy.end_date}).`
|
|
23190
|
+
);
|
|
23191
|
+
}
|
|
23192
|
+
const accountNumbers = [
|
|
23193
|
+
...new Set(params.entries.map((e) => e.accountNumber))
|
|
23194
|
+
];
|
|
23195
|
+
const { data: accounts, error: accError } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", accountNumbers);
|
|
23196
|
+
if (accError) {
|
|
23197
|
+
return toErrorContent(`Failed to resolve accounts: ${accError.message}`);
|
|
23198
|
+
}
|
|
23199
|
+
const accountMap = new Map(
|
|
23200
|
+
(accounts || []).map((a) => [a.account_number, a.id])
|
|
23201
|
+
);
|
|
23202
|
+
const missing = accountNumbers.filter((n) => !accountMap.has(n));
|
|
23203
|
+
if (missing.length > 0) {
|
|
23204
|
+
return toErrorContent(
|
|
23205
|
+
`Account numbers not found: ${missing.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
|
|
23206
|
+
);
|
|
23207
|
+
}
|
|
23208
|
+
const totalDebit = roundCents(
|
|
23209
|
+
params.entries.reduce((s, e) => s + e.debitAmount, 0)
|
|
23210
|
+
);
|
|
23211
|
+
const totalCredit = roundCents(
|
|
23212
|
+
params.entries.reduce((s, e) => s + e.creditAmount, 0)
|
|
23213
|
+
);
|
|
23214
|
+
if (Math.abs(totalDebit - totalCredit) >= 0.01) {
|
|
23215
|
+
return toErrorContent(
|
|
23216
|
+
`Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`
|
|
23217
|
+
);
|
|
23218
|
+
}
|
|
23219
|
+
const journalEntries = params.entries.map((e) => ({
|
|
23220
|
+
account_id: accountMap.get(e.accountNumber),
|
|
23221
|
+
debit_amount: e.debitAmount,
|
|
23222
|
+
credit_amount: e.creditAmount
|
|
23223
|
+
}));
|
|
23224
|
+
const { data: transactionId, error: txError } = await supabase2.rpc(
|
|
23225
|
+
"create_transaction_with_entries",
|
|
23226
|
+
{
|
|
23227
|
+
p_company_id: companyId2,
|
|
23228
|
+
p_fiscal_year_id: fiscalYearId,
|
|
23229
|
+
p_transaction_date: params.transactionDate,
|
|
23230
|
+
p_description: params.description,
|
|
23231
|
+
p_document_number: params.documentNumber || "",
|
|
23232
|
+
p_entries: journalEntries
|
|
23233
|
+
}
|
|
23234
|
+
);
|
|
23235
|
+
if (txError || !transactionId) {
|
|
23236
|
+
return toErrorContent(
|
|
23237
|
+
txError ? sanitizeDbError("Failed to create transaction", txError) : "Failed to create transaction: no ID returned."
|
|
23238
|
+
);
|
|
23239
|
+
}
|
|
23240
|
+
return { id: transactionId };
|
|
23241
|
+
}
|
|
23242
|
+
function registerTemplateTools(server2) {
|
|
23243
|
+
server2.tool(
|
|
23244
|
+
"kirjapro_list_templates",
|
|
23245
|
+
"List available transaction templates. Templates handle double-entry balancing and VAT splitting automatically.",
|
|
23246
|
+
{},
|
|
23247
|
+
async () => {
|
|
23248
|
+
const list = TEMPLATES.map((t) => ({
|
|
23249
|
+
id: t.id,
|
|
23250
|
+
description: t.description,
|
|
23251
|
+
vatRate: t.vatRate
|
|
23252
|
+
}));
|
|
23253
|
+
return toTextContent({ templates: list });
|
|
23254
|
+
}
|
|
23255
|
+
);
|
|
23256
|
+
server2.tool(
|
|
23257
|
+
"kirjapro_create_from_template",
|
|
23258
|
+
'Create a transaction from a template. Use kirjapro_list_templates for available templates. accountOverrides swaps default accounts (e.g. {"2320": "2330"}).',
|
|
23259
|
+
{
|
|
23260
|
+
template: external_exports.string().describe('Template ID (e.g. "cash_sale_25", "purchase_expense")'),
|
|
23261
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23262
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
23263
|
+
amount: external_exports.number().positive().describe("Gross amount in EUR"),
|
|
23264
|
+
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
23265
|
+
documentNumber: external_exports.string().optional().describe("Document/receipt number"),
|
|
23266
|
+
withholdingRate: external_exports.number().min(0).max(100).optional().describe("Withholding tax % for salary_payment (default 20)"),
|
|
23267
|
+
tyelRate: external_exports.number().min(0).max(100).optional().describe("TyEL employee % for salary_payment (default 7.30)"),
|
|
23268
|
+
accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional().describe('Account number swaps, e.g. {"2320": "2330"}')
|
|
23269
|
+
},
|
|
23270
|
+
async (params) => {
|
|
23271
|
+
const result = expandTemplate(params.template, params.amount, {
|
|
23272
|
+
withholdingRate: params.withholdingRate,
|
|
23273
|
+
tyelRate: params.tyelRate,
|
|
23274
|
+
accountOverrides: params.accountOverrides
|
|
23275
|
+
});
|
|
23276
|
+
if ("error" in result) {
|
|
23277
|
+
return toErrorContent(result.error);
|
|
23278
|
+
}
|
|
23279
|
+
const createResult = await resolveAndCreate({
|
|
23280
|
+
fiscalYearId: params.fiscalYearId,
|
|
23281
|
+
transactionDate: params.transactionDate,
|
|
23282
|
+
description: params.description,
|
|
23283
|
+
documentNumber: params.documentNumber,
|
|
23284
|
+
entries: result.entries
|
|
23285
|
+
});
|
|
23286
|
+
if ("isError" in createResult) {
|
|
23287
|
+
return createResult;
|
|
23288
|
+
}
|
|
23289
|
+
return toTextContent({
|
|
23290
|
+
message: "Transaction created from template",
|
|
23291
|
+
template: params.template,
|
|
23292
|
+
transaction_id: createResult.id,
|
|
23293
|
+
entries: result.entries
|
|
23294
|
+
});
|
|
23295
|
+
}
|
|
23296
|
+
);
|
|
23297
|
+
server2.tool(
|
|
23298
|
+
"kirjapro_create_from_template_batch",
|
|
23299
|
+
"Create multiple transactions from templates in one call (up to 50). Each is validated independently \u2014 valid ones succeed even if others fail.",
|
|
23300
|
+
{
|
|
23301
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23302
|
+
transactions: external_exports.array(
|
|
23303
|
+
external_exports.object({
|
|
23304
|
+
template: external_exports.string().describe("Template ID"),
|
|
23305
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
|
|
23306
|
+
amount: external_exports.number().positive().describe("Gross amount in EUR"),
|
|
23307
|
+
description: external_exports.string().min(1).max(500),
|
|
23308
|
+
documentNumber: external_exports.string().optional(),
|
|
23309
|
+
withholdingRate: external_exports.number().min(0).max(100).optional(),
|
|
23310
|
+
tyelRate: external_exports.number().min(0).max(100).optional(),
|
|
23311
|
+
accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional()
|
|
23312
|
+
})
|
|
23313
|
+
).min(1).max(50).describe("Array of template transactions to create (max 50)")
|
|
23314
|
+
},
|
|
23315
|
+
async (params) => {
|
|
23316
|
+
const created = [];
|
|
23317
|
+
const failed = [];
|
|
23318
|
+
for (let i = 0; i < params.transactions.length; i++) {
|
|
23319
|
+
const tx = params.transactions[i];
|
|
23320
|
+
const result = expandTemplate(tx.template, tx.amount, {
|
|
23321
|
+
withholdingRate: tx.withholdingRate,
|
|
23322
|
+
tyelRate: tx.tyelRate,
|
|
23323
|
+
accountOverrides: tx.accountOverrides
|
|
23324
|
+
});
|
|
23325
|
+
if ("error" in result) {
|
|
23326
|
+
failed.push({ index: i, error: result.error });
|
|
23327
|
+
continue;
|
|
23328
|
+
}
|
|
23329
|
+
const createResult = await resolveAndCreate({
|
|
23330
|
+
fiscalYearId: params.fiscalYearId,
|
|
23331
|
+
transactionDate: tx.transactionDate,
|
|
23332
|
+
description: tx.description,
|
|
23333
|
+
documentNumber: tx.documentNumber,
|
|
23334
|
+
entries: result.entries
|
|
23335
|
+
});
|
|
23336
|
+
if ("isError" in createResult) {
|
|
23337
|
+
failed.push({ index: i, error: createResult.content[0].text });
|
|
23338
|
+
continue;
|
|
23339
|
+
}
|
|
23340
|
+
created.push({
|
|
23341
|
+
id: createResult.id,
|
|
23342
|
+
description: tx.description,
|
|
23343
|
+
date: tx.transactionDate
|
|
23344
|
+
});
|
|
23345
|
+
}
|
|
23346
|
+
return toTextContent({
|
|
23347
|
+
created_count: created.length,
|
|
23348
|
+
failed_count: failed.length,
|
|
23349
|
+
created,
|
|
23350
|
+
failed
|
|
23351
|
+
});
|
|
23352
|
+
}
|
|
23353
|
+
);
|
|
23354
|
+
}
|
|
23355
|
+
|
|
23356
|
+
// src/tools/bookings.ts
|
|
23357
|
+
var DEFAULTS = {
|
|
23358
|
+
tyelEmployee: 7.3,
|
|
23359
|
+
tyelEmployer: 17.55,
|
|
23360
|
+
sotu: 1.91,
|
|
23361
|
+
ttvEmployer: 0.31,
|
|
23362
|
+
ttvEmployee: 0.89
|
|
23363
|
+
};
|
|
23364
|
+
function registerBookingTools(server2) {
|
|
23365
|
+
server2.tool(
|
|
23366
|
+
"kirjapro_book_purchase",
|
|
23367
|
+
"Book a purchase (osto) with automatic VAT splitting. Supports reverse charge (AVL 9\xA7) and entertainment (edustus, AVL 114\xA7). Rate rules: food/restaurants 13.5%, insurance 0% (exempt), alcohol 25.5%.",
|
|
23368
|
+
{
|
|
23369
|
+
amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
|
|
23370
|
+
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
23371
|
+
date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
23372
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
|
|
23373
|
+
expenseAccount: external_exports.string().describe('Expense account number (e.g. "8080")'),
|
|
23374
|
+
reverseCharge: external_exports.boolean().default(false).describe("EU reverse charge: D expense + D 2140 + C 4050 + C bank"),
|
|
23375
|
+
edustus: external_exports.boolean().default(false).describe("Entertainment expense (AVL 114\xA7): 0% VAT deduction, full gross to expense"),
|
|
23376
|
+
paymentAccount: external_exports.string().default("2320").describe('Payment account (default "2320" Pankkitili)'),
|
|
23377
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23378
|
+
documentNumber: external_exports.string().optional().describe("Document/receipt number")
|
|
23379
|
+
},
|
|
23380
|
+
async (params) => {
|
|
23381
|
+
const rate = Number.parseFloat(params.vatRate);
|
|
23382
|
+
const gross = roundCents(params.amount);
|
|
23383
|
+
let entries;
|
|
23384
|
+
if (params.edustus) {
|
|
23385
|
+
entries = [
|
|
23386
|
+
{ accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
|
|
23387
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23388
|
+
];
|
|
23389
|
+
} else if (params.reverseCharge) {
|
|
23390
|
+
const vat = roundCents(gross * rate / 100);
|
|
23391
|
+
entries = [
|
|
23392
|
+
{ accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
|
|
23393
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23394
|
+
// ALV-saamiset (input)
|
|
23395
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat },
|
|
23396
|
+
// ALV-velka (output)
|
|
23397
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23398
|
+
];
|
|
23399
|
+
} else if (rate === 0) {
|
|
23400
|
+
entries = [
|
|
23401
|
+
{ accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
|
|
23402
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23403
|
+
];
|
|
23404
|
+
} else {
|
|
23405
|
+
const { net, vat } = splitVat(gross, rate);
|
|
23406
|
+
entries = [
|
|
23407
|
+
{ accountNumber: params.expenseAccount, debitAmount: net, creditAmount: 0 },
|
|
23408
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23409
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23410
|
+
];
|
|
23411
|
+
}
|
|
23412
|
+
const result = await resolveAndCreate({
|
|
23413
|
+
fiscalYearId: params.fiscalYearId,
|
|
23414
|
+
transactionDate: params.date,
|
|
23415
|
+
description: params.description,
|
|
23416
|
+
documentNumber: params.documentNumber,
|
|
23417
|
+
entries
|
|
23418
|
+
});
|
|
23419
|
+
if ("isError" in result) return result;
|
|
23420
|
+
return toTextContent({
|
|
23421
|
+
message: "Purchase booked",
|
|
23422
|
+
transaction_id: result.id,
|
|
23423
|
+
entries
|
|
23424
|
+
});
|
|
23425
|
+
}
|
|
23426
|
+
);
|
|
23427
|
+
server2.tool(
|
|
23428
|
+
"kirjapro_book_salary",
|
|
23429
|
+
"Book salary payment (palkanmaksu). Creates salary + employer cost transactions with withholding, TyEL, TTV, sotu. Default rates are 2026 Finnish social insurance rates.",
|
|
23430
|
+
{
|
|
23431
|
+
grossSalary: external_exports.number().positive().describe("Gross salary EUR"),
|
|
23432
|
+
employeeName: external_exports.string().min(1).describe("Employee name (for description)"),
|
|
23433
|
+
date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Payment date (YYYY-MM-DD)"),
|
|
23434
|
+
withholdingRate: external_exports.number().min(0).max(100).describe("Withholding tax % (ennakonpid\xE4tys)"),
|
|
23435
|
+
tyelEmployeeRate: external_exports.number().min(0).max(100).default(DEFAULTS.tyelEmployee).describe("TyEL employee % (default 7.30)"),
|
|
23436
|
+
tyelEmployerRate: external_exports.number().min(0).max(100).default(DEFAULTS.tyelEmployer).describe("TyEL employer % (default 17.55)"),
|
|
23437
|
+
sotuRate: external_exports.number().min(0).max(100).default(DEFAULTS.sotu).describe("Sairausvakuutusmaksu employer % (default 1.91)"),
|
|
23438
|
+
ttvEmployerRate: external_exports.number().min(0).max(100).default(DEFAULTS.ttvEmployer).describe("Ty\xF6tt\xF6myysvakuutus employer % (default 0.31)"),
|
|
23439
|
+
ttvEmployeeRate: external_exports.number().min(0).max(100).default(DEFAULTS.ttvEmployee).describe("Ty\xF6tt\xF6myysvakuutus employee % (default 0.89)"),
|
|
23440
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23441
|
+
paymentAccount: external_exports.string().default("2320").describe('Bank account (default "2320")')
|
|
23442
|
+
},
|
|
23443
|
+
async (params) => {
|
|
23444
|
+
const gross = roundCents(params.grossSalary);
|
|
23445
|
+
const withholding = roundCents(gross * params.withholdingRate / 100);
|
|
23446
|
+
const tyelEmployee = roundCents(gross * params.tyelEmployeeRate / 100);
|
|
23447
|
+
const ttvEmployee = roundCents(gross * params.ttvEmployeeRate / 100);
|
|
23448
|
+
const netPay = roundCents(gross - withholding - tyelEmployee - ttvEmployee);
|
|
23449
|
+
const tyelEmployer = roundCents(gross * params.tyelEmployerRate / 100);
|
|
23450
|
+
const sotuAmount = roundCents(gross * params.sotuRate / 100);
|
|
23451
|
+
const ttvEmployer = roundCents(gross * params.ttvEmployerRate / 100);
|
|
23452
|
+
if (netPay < 0) {
|
|
23453
|
+
return toErrorContent(
|
|
23454
|
+
`Net pay is negative (${netPay.toFixed(2)}). Check withholding rate (${params.withholdingRate}%) and deduction rates.`
|
|
23455
|
+
);
|
|
23456
|
+
}
|
|
23457
|
+
const salaryEntries = [
|
|
23458
|
+
{ accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
|
|
23459
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: netPay },
|
|
23460
|
+
{ accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
|
|
23461
|
+
{ accountNumber: "4080", debitAmount: 0, creditAmount: tyelEmployee },
|
|
23462
|
+
{ accountNumber: "4070", debitAmount: 0, creditAmount: ttvEmployee }
|
|
23463
|
+
];
|
|
23464
|
+
const salaryResult = await resolveAndCreate({
|
|
23465
|
+
fiscalYearId: params.fiscalYearId,
|
|
23466
|
+
transactionDate: params.date,
|
|
23467
|
+
description: `Palkka ${params.employeeName}`,
|
|
23468
|
+
entries: salaryEntries
|
|
23469
|
+
});
|
|
23470
|
+
if ("isError" in salaryResult) return salaryResult;
|
|
23471
|
+
const employerSotuTtv = roundCents(sotuAmount + ttvEmployer);
|
|
23472
|
+
const employerEntries = [
|
|
23473
|
+
{ accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
|
|
23474
|
+
{ accountNumber: "7030", debitAmount: employerSotuTtv, creditAmount: 0 },
|
|
23475
|
+
{ accountNumber: "4080", debitAmount: 0, creditAmount: tyelEmployer },
|
|
23476
|
+
{ accountNumber: "4070", debitAmount: 0, creditAmount: employerSotuTtv }
|
|
23477
|
+
];
|
|
23478
|
+
const employerResult = await resolveAndCreate({
|
|
23479
|
+
fiscalYearId: params.fiscalYearId,
|
|
23480
|
+
transactionDate: params.date,
|
|
23481
|
+
description: `Sivukulut ${params.employeeName}`,
|
|
23482
|
+
entries: employerEntries
|
|
23483
|
+
});
|
|
23484
|
+
if ("isError" in employerResult) return employerResult;
|
|
23485
|
+
return toTextContent({
|
|
23486
|
+
message: "Salary booked (2 transactions)",
|
|
23487
|
+
salary_transaction_id: salaryResult.id,
|
|
23488
|
+
employer_costs_transaction_id: employerResult.id,
|
|
23489
|
+
summary: {
|
|
23490
|
+
gross,
|
|
23491
|
+
withholding,
|
|
23492
|
+
tyelEmployee,
|
|
23493
|
+
ttvEmployee,
|
|
23494
|
+
netPay,
|
|
23495
|
+
tyelEmployer,
|
|
23496
|
+
sotu: sotuAmount,
|
|
23497
|
+
ttvEmployer,
|
|
23498
|
+
totalEmployerCost: roundCents(gross + tyelEmployer + sotuAmount + ttvEmployer)
|
|
23499
|
+
}
|
|
23500
|
+
});
|
|
23501
|
+
}
|
|
23502
|
+
);
|
|
23503
|
+
server2.tool(
|
|
23504
|
+
"kirjapro_book_vat_settlement",
|
|
23505
|
+
"Book ALV-tilitys (VAT settlement) for a month. Calculates net from ALV accounts (2140/4050) and creates settlement. Default date: 12th of month+1.",
|
|
23506
|
+
{
|
|
23507
|
+
month: external_exports.string().regex(/^\d{4}-\d{2}$/).describe("Settlement month (YYYY-MM)"),
|
|
23508
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23509
|
+
settlementDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Settlement date (default: 12th of month+1)"),
|
|
23510
|
+
paymentAccount: external_exports.string().default("2320").describe('Bank account (default "2320")')
|
|
23511
|
+
},
|
|
23512
|
+
async (params) => {
|
|
23513
|
+
const supabase2 = getSupabase();
|
|
23514
|
+
const companyId2 = getCompanyId();
|
|
23515
|
+
let fiscalYearId = params.fiscalYearId;
|
|
23516
|
+
if (!fiscalYearId) {
|
|
23517
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
23518
|
+
if ("isError" in resolved) return resolved;
|
|
23519
|
+
fiscalYearId = resolved.id;
|
|
23520
|
+
}
|
|
23521
|
+
const [year, monthStr] = params.month.split("-");
|
|
23522
|
+
const monthStart = `${params.month}-01`;
|
|
23523
|
+
const monthNum = Number.parseInt(monthStr, 10);
|
|
23524
|
+
const nextMonth = monthNum === 12 ? `${Number.parseInt(year, 10) + 1}-01` : `${year}-${String(monthNum + 1).padStart(2, "0")}`;
|
|
23525
|
+
const monthEnd = /* @__PURE__ */ new Date(`${nextMonth}-01`);
|
|
23526
|
+
monthEnd.setDate(monthEnd.getDate() - 1);
|
|
23527
|
+
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
|
23528
|
+
let settlementDate = params.settlementDate ?? `${nextMonth}-12`;
|
|
23529
|
+
const { data: fy } = await supabase2.from("fiscal_years").select("start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
23530
|
+
if (fy && settlementDate > fy.end_date) {
|
|
23531
|
+
settlementDate = fy.end_date;
|
|
23532
|
+
}
|
|
23533
|
+
const { data: alvSaamiset, error: err1 } = await supabase2.from("journal_entries").select("debit_amount, credit_amount, accounts!inner(account_number), transactions!inner(transaction_date, company_id, fiscal_year_id)").eq("accounts.account_number", "2140").eq("transactions.company_id", companyId2).eq("transactions.fiscal_year_id", fiscalYearId).gte("transactions.transaction_date", monthStart).lte("transactions.transaction_date", monthEndStr);
|
|
23534
|
+
if (err1) {
|
|
23535
|
+
return toErrorContent(`Failed to query ALV-saamiset: ${err1.message}`);
|
|
23536
|
+
}
|
|
23537
|
+
const { data: alvVelka, error: err2 } = await supabase2.from("journal_entries").select("debit_amount, credit_amount, accounts!inner(account_number), transactions!inner(transaction_date, company_id, fiscal_year_id)").eq("accounts.account_number", "4050").eq("transactions.company_id", companyId2).eq("transactions.fiscal_year_id", fiscalYearId).gte("transactions.transaction_date", monthStart).lte("transactions.transaction_date", monthEndStr);
|
|
23538
|
+
if (err2) {
|
|
23539
|
+
return toErrorContent(`Failed to query ALV-velka: ${err2.message}`);
|
|
23540
|
+
}
|
|
23541
|
+
const saamisetBalance = roundCents(
|
|
23542
|
+
(alvSaamiset || []).reduce((s, e) => s + (e.debit_amount || 0) - (e.credit_amount || 0), 0)
|
|
23543
|
+
);
|
|
23544
|
+
const velkaBalance = roundCents(
|
|
23545
|
+
(alvVelka || []).reduce((s, e) => s + (e.credit_amount || 0) - (e.debit_amount || 0), 0)
|
|
23546
|
+
);
|
|
23547
|
+
if (saamisetBalance === 0 && velkaBalance === 0) {
|
|
23548
|
+
return toTextContent({
|
|
23549
|
+
message: `No VAT entries found for ${params.month}. Nothing to settle.`,
|
|
23550
|
+
alv_saamiset: 0,
|
|
23551
|
+
alv_velka: 0
|
|
23552
|
+
});
|
|
23553
|
+
}
|
|
23554
|
+
const netVat = roundCents(velkaBalance - saamisetBalance);
|
|
23555
|
+
let entries;
|
|
23556
|
+
if (netVat > 0) {
|
|
23557
|
+
entries = [
|
|
23558
|
+
{ accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
|
|
23559
|
+
{ accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance },
|
|
23560
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: netVat }
|
|
23561
|
+
];
|
|
23562
|
+
} else if (netVat < 0) {
|
|
23563
|
+
const refund = Math.abs(netVat);
|
|
23564
|
+
entries = [
|
|
23565
|
+
{ accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
|
|
23566
|
+
{ accountNumber: params.paymentAccount, debitAmount: refund, creditAmount: 0 },
|
|
23567
|
+
{ accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance }
|
|
23568
|
+
];
|
|
23569
|
+
} else {
|
|
23570
|
+
entries = [
|
|
23571
|
+
{ accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
|
|
23572
|
+
{ accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance }
|
|
23573
|
+
];
|
|
23574
|
+
}
|
|
23575
|
+
const result = await resolveAndCreate({
|
|
23576
|
+
fiscalYearId,
|
|
23577
|
+
transactionDate: settlementDate,
|
|
23578
|
+
description: `ALV-tilitys ${params.month}`,
|
|
23579
|
+
entries
|
|
23580
|
+
});
|
|
23581
|
+
if ("isError" in result) return result;
|
|
23582
|
+
return toTextContent({
|
|
23583
|
+
message: `VAT settlement booked for ${params.month}`,
|
|
23584
|
+
transaction_id: result.id,
|
|
23585
|
+
alv_saamiset: saamisetBalance,
|
|
23586
|
+
alv_velka: velkaBalance,
|
|
23587
|
+
net_vat: netVat,
|
|
23588
|
+
direction: netVat > 0 ? "pay" : netVat < 0 ? "refund" : "zero"
|
|
23589
|
+
});
|
|
23590
|
+
}
|
|
23591
|
+
);
|
|
23592
|
+
server2.tool(
|
|
23593
|
+
"kirjapro_book_sale",
|
|
23594
|
+
"Book a sale (myynti) with automatic VAT splitting. Payment types: invoice (default, D Myyntisaamiset), cash (D bank), prepaid.",
|
|
23595
|
+
{
|
|
23596
|
+
amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
|
|
23597
|
+
description: external_exports.string().min(1).max(500).describe('Transaction description (e.g. "Konsultointi Acme Oy, 40h \xD7 95 \u20AC")'),
|
|
23598
|
+
date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
23599
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
|
|
23600
|
+
revenueAccount: external_exports.string().default("3000").describe('Revenue account number (default "3000" Myynti)'),
|
|
23601
|
+
paymentType: external_exports.enum(["invoice", "cash", "prepaid"]).default("invoice").describe("invoice = receivable (1700), cash/prepaid = bank payment (2320)"),
|
|
23602
|
+
paymentAccount: external_exports.string().default("2320").describe('Bank account for cash/prepaid payments (default "2320")'),
|
|
23603
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23604
|
+
documentNumber: external_exports.string().optional().describe("Document/receipt number")
|
|
23605
|
+
},
|
|
23606
|
+
async (params) => {
|
|
23607
|
+
const rate = Number.parseFloat(params.vatRate);
|
|
23608
|
+
const gross = roundCents(params.amount);
|
|
23609
|
+
let entries;
|
|
23610
|
+
const debitAccount = params.paymentType === "invoice" ? "1700" : params.paymentAccount;
|
|
23611
|
+
if (rate === 0) {
|
|
23612
|
+
entries = [
|
|
23613
|
+
{ accountNumber: debitAccount, debitAmount: gross, creditAmount: 0 },
|
|
23614
|
+
{ accountNumber: params.revenueAccount, debitAmount: 0, creditAmount: gross }
|
|
23615
|
+
];
|
|
23616
|
+
} else {
|
|
23617
|
+
const { net: net2, vat: vat2 } = splitVat(gross, rate);
|
|
23618
|
+
entries = [
|
|
23619
|
+
{ accountNumber: debitAccount, debitAmount: gross, creditAmount: 0 },
|
|
23620
|
+
{ accountNumber: params.revenueAccount, debitAmount: 0, creditAmount: net2 },
|
|
23621
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat2 }
|
|
23622
|
+
];
|
|
23623
|
+
}
|
|
23624
|
+
const result = await resolveAndCreate({
|
|
23625
|
+
fiscalYearId: params.fiscalYearId,
|
|
23626
|
+
transactionDate: params.date,
|
|
23627
|
+
description: params.description,
|
|
23628
|
+
documentNumber: params.documentNumber,
|
|
23629
|
+
entries
|
|
23630
|
+
});
|
|
23631
|
+
if ("isError" in result) return result;
|
|
23632
|
+
const { net, vat } = rate > 0 ? splitVat(gross, rate) : { net: gross, vat: 0 };
|
|
23633
|
+
return toTextContent({
|
|
23634
|
+
message: "Sale booked",
|
|
23635
|
+
transaction_id: result.id,
|
|
23636
|
+
gross,
|
|
23637
|
+
net,
|
|
23638
|
+
vat,
|
|
23639
|
+
vat_rate: `${params.vatRate} %`,
|
|
23640
|
+
payment_type: params.paymentType,
|
|
23641
|
+
debit_account: debitAccount,
|
|
23642
|
+
revenue_account: params.revenueAccount,
|
|
23643
|
+
entries
|
|
23644
|
+
});
|
|
23645
|
+
}
|
|
23646
|
+
);
|
|
23647
|
+
}
|
|
23648
|
+
|
|
23649
|
+
// src/tools/offers.ts
|
|
23650
|
+
function generateOfferNumber(lastNumber, type) {
|
|
23651
|
+
const prefix = type === "sopimus" ? "SOP" : "TAR";
|
|
23652
|
+
if (!lastNumber) return `${prefix}00001`;
|
|
23653
|
+
const numPart = lastNumber.replace(/\D/g, "");
|
|
23654
|
+
const next = parseInt(numPart || "0") + 1;
|
|
23655
|
+
return `${prefix}${next.toString().padStart(5, "0")}`;
|
|
23656
|
+
}
|
|
23657
|
+
function registerOfferTools(server2) {
|
|
23658
|
+
server2.tool(
|
|
23659
|
+
"kirjapro_list_offers",
|
|
23660
|
+
"List offers/contracts with customer names. Filter by status (draft, sent, viewed, accepted, rejected, expired) and type (tarjous, sopimus).",
|
|
23661
|
+
{
|
|
23662
|
+
status: external_exports.enum(["draft", "sent", "viewed", "accepted", "rejected", "expired"]).optional().describe("Filter by offer status"),
|
|
23663
|
+
type: external_exports.enum(["tarjous", "sopimus"]).optional().describe("Filter by type (tarjous = offer, sopimus = contract)"),
|
|
23664
|
+
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
23665
|
+
offset: external_exports.number().min(0).default(0).describe("Skip first N results")
|
|
23666
|
+
},
|
|
23667
|
+
async (params) => {
|
|
23668
|
+
const supabase2 = getSupabase();
|
|
23669
|
+
const companyId2 = getCompanyId();
|
|
23670
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
23671
|
+
let query = supabase2.from("offers").select(`
|
|
23672
|
+
id, offer_number, type, title, status, subtotal, vat_total, total,
|
|
23673
|
+
valid_from, valid_until, invoice_id, created_at,
|
|
23674
|
+
customers ( name, business_id )
|
|
23675
|
+
`, { count: "exact" }).eq("company_id", companyId2).order("created_at", { ascending: false });
|
|
23676
|
+
if (params.status) {
|
|
23677
|
+
query = query.eq("status", params.status);
|
|
23678
|
+
}
|
|
23679
|
+
if (params.type) {
|
|
23680
|
+
query = query.eq("type", params.type);
|
|
23681
|
+
}
|
|
23682
|
+
query = query.range(offset, offset + limit - 1);
|
|
23683
|
+
const { data, count, error: error2 } = await query;
|
|
23684
|
+
if (error2) {
|
|
23685
|
+
return toErrorContent(sanitizeDbError("Failed to list offers", error2));
|
|
23686
|
+
}
|
|
23687
|
+
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
23688
|
+
}
|
|
23689
|
+
);
|
|
23690
|
+
server2.tool(
|
|
23691
|
+
"kirjapro_get_offer",
|
|
23692
|
+
"Get a single offer/contract with full detail: customer info, line items, events, and VAT breakdown.",
|
|
23693
|
+
{
|
|
23694
|
+
offerId: external_exports.string().uuid().describe("Offer UUID")
|
|
23695
|
+
},
|
|
23696
|
+
async (params) => {
|
|
23697
|
+
const supabase2 = getSupabase();
|
|
23698
|
+
const companyId2 = getCompanyId();
|
|
23699
|
+
const { data: offer, error: error2 } = await supabase2.from("offers").select(`
|
|
23700
|
+
id, offer_number, type, title, content, status,
|
|
23701
|
+
subtotal, vat_total, total, notes, valid_from, valid_until,
|
|
23702
|
+
invoice_id, sent_at, created_at,
|
|
23703
|
+
customers ( id, name, business_id, email, address, postal_code, city ),
|
|
23704
|
+
offer_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
|
|
23705
|
+
offer_events ( id, event_type, created_at, metadata )
|
|
23706
|
+
`).eq("id", params.offerId).eq("company_id", companyId2).single();
|
|
23707
|
+
if (error2 || !offer) {
|
|
23708
|
+
return toErrorContent(
|
|
23709
|
+
`Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
|
|
23710
|
+
);
|
|
23711
|
+
}
|
|
23712
|
+
return toTextContent(offer);
|
|
23713
|
+
}
|
|
23714
|
+
);
|
|
23715
|
+
server2.tool(
|
|
23716
|
+
"kirjapro_create_offer",
|
|
23717
|
+
"Create a new offer (tarjous) or contract (sopimus) with line items. Automatically generates offer number and calculates VAT. Returns the created offer.",
|
|
23718
|
+
{
|
|
23719
|
+
type: external_exports.enum(["tarjous", "sopimus"]).default("tarjous").describe("Type: tarjous (offer) or sopimus (contract)"),
|
|
23720
|
+
title: external_exports.string().min(1).describe("Offer title"),
|
|
23721
|
+
customerId: external_exports.string().uuid().optional().describe("Customer UUID (optional)"),
|
|
23722
|
+
content: external_exports.string().optional().describe("Free-text content/description"),
|
|
23723
|
+
validFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Valid from date (YYYY-MM-DD)"),
|
|
23724
|
+
validUntil: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Valid until date (YYYY-MM-DD)"),
|
|
23725
|
+
notes: external_exports.string().optional().describe("Internal notes (not visible to customer)"),
|
|
23726
|
+
lines: external_exports.array(external_exports.object({
|
|
23727
|
+
description: external_exports.string().min(1).describe("Line item description"),
|
|
23728
|
+
quantity: external_exports.number().min(0).describe("Quantity"),
|
|
23729
|
+
unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
|
|
23730
|
+
unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
|
|
23731
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
|
|
23732
|
+
})).min(1).describe("Line items")
|
|
23733
|
+
},
|
|
23734
|
+
async (params) => {
|
|
23735
|
+
const supabase2 = getSupabase();
|
|
23736
|
+
const companyId2 = getCompanyId();
|
|
23737
|
+
if (params.customerId) {
|
|
23738
|
+
const { data: customer, error: custError } = await supabase2.from("customers").select("id, name").eq("id", params.customerId).eq("company_id", companyId2).single();
|
|
23739
|
+
if (custError || !customer) {
|
|
23740
|
+
return toErrorContent(
|
|
23741
|
+
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
23742
|
+
);
|
|
23743
|
+
}
|
|
23744
|
+
}
|
|
23745
|
+
const { data: lastOffer } = await supabase2.from("offers").select("offer_number").eq("company_id", companyId2).eq("type", params.type).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
23746
|
+
const offerNumber = generateOfferNumber(lastOffer?.offer_number || null, params.type);
|
|
23747
|
+
const calculatedLines = params.lines.map((line, i) => {
|
|
23748
|
+
const vatRate = parseFloat(line.vatRate);
|
|
23749
|
+
const subtotal2 = roundCents(line.quantity * line.unitPrice);
|
|
23750
|
+
const vatAmount = roundCents(subtotal2 * (vatRate / 100));
|
|
23751
|
+
const total2 = roundCents(subtotal2 + vatAmount);
|
|
23752
|
+
return {
|
|
23753
|
+
line_number: i + 1,
|
|
23754
|
+
description: line.description,
|
|
23755
|
+
quantity: line.quantity,
|
|
23756
|
+
unit: line.unit,
|
|
23757
|
+
unit_price: line.unitPrice,
|
|
23758
|
+
vat_rate: vatRate,
|
|
23759
|
+
subtotal: subtotal2,
|
|
23760
|
+
vat_amount: vatAmount,
|
|
23761
|
+
total: total2
|
|
23762
|
+
};
|
|
23763
|
+
});
|
|
23764
|
+
const subtotal = roundCents(calculatedLines.reduce((s, l) => s + l.subtotal, 0));
|
|
23765
|
+
const vatTotal = roundCents(calculatedLines.reduce((s, l) => s + l.vat_amount, 0));
|
|
23766
|
+
const total = roundCents(subtotal + vatTotal);
|
|
23767
|
+
const { data: offerId, error: rpcError } = await supabase2.rpc("create_offer_with_lines", {
|
|
23768
|
+
p_company_id: companyId2,
|
|
23769
|
+
p_customer_id: params.customerId || null,
|
|
23770
|
+
p_type: params.type,
|
|
23771
|
+
p_title: params.title,
|
|
23772
|
+
p_offer_number: offerNumber,
|
|
23773
|
+
p_content: params.content || null,
|
|
23774
|
+
p_status: "draft",
|
|
23775
|
+
p_valid_from: params.validFrom || null,
|
|
23776
|
+
p_valid_until: params.validUntil || null,
|
|
23777
|
+
p_notes: params.notes || null,
|
|
23778
|
+
p_subtotal: subtotal,
|
|
23779
|
+
p_vat_total: vatTotal,
|
|
23780
|
+
p_total: total,
|
|
23781
|
+
p_lines: calculatedLines
|
|
23782
|
+
});
|
|
23783
|
+
if (rpcError) {
|
|
23784
|
+
return toErrorContent(sanitizeDbError("Failed to create offer", rpcError));
|
|
23785
|
+
}
|
|
23786
|
+
return toTextContent({
|
|
23787
|
+
message: `${params.type === "sopimus" ? "Contract" : "Offer"} ${offerNumber} created`,
|
|
23788
|
+
offer_id: offerId,
|
|
23789
|
+
offer_number: offerNumber,
|
|
23790
|
+
type: params.type,
|
|
23791
|
+
title: params.title,
|
|
23792
|
+
subtotal,
|
|
23793
|
+
vat_total: vatTotal,
|
|
23794
|
+
total,
|
|
23795
|
+
lines: calculatedLines
|
|
23796
|
+
});
|
|
23797
|
+
}
|
|
23798
|
+
);
|
|
23799
|
+
server2.tool(
|
|
23800
|
+
"kirjapro_update_offer_status",
|
|
23801
|
+
"Update offer status with state machine validation. Valid transitions: draft\u2192sent, sent\u2192viewed/accepted/rejected/expired/draft, viewed\u2192accepted/rejected/expired/draft, accepted/rejected/expired\u2192draft.",
|
|
23802
|
+
{
|
|
23803
|
+
offerId: external_exports.string().uuid().describe("Offer UUID"),
|
|
23804
|
+
status: external_exports.enum(["draft", "sent", "viewed", "accepted", "rejected", "expired"]).describe("New status")
|
|
23805
|
+
},
|
|
23806
|
+
async (params) => {
|
|
23807
|
+
const supabase2 = getSupabase();
|
|
23808
|
+
const companyId2 = getCompanyId();
|
|
23809
|
+
const { data: offer, error: fetchError } = await supabase2.from("offers").select("id, offer_number, status, invoice_id").eq("id", params.offerId).eq("company_id", companyId2).single();
|
|
23810
|
+
if (fetchError || !offer) {
|
|
23811
|
+
return toErrorContent(
|
|
23812
|
+
`Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
|
|
23813
|
+
);
|
|
23814
|
+
}
|
|
23815
|
+
const currentStatus = offer.status;
|
|
23816
|
+
const newStatus = params.status;
|
|
23817
|
+
if (currentStatus === newStatus) {
|
|
23818
|
+
return toErrorContent(
|
|
23819
|
+
`Offer ${offer.offer_number} is already in status "${currentStatus}".`
|
|
23820
|
+
);
|
|
23821
|
+
}
|
|
23822
|
+
const validNext = VALID_OFFER_TRANSITIONS[currentStatus] || [];
|
|
23823
|
+
if (!validNext.includes(newStatus)) {
|
|
23824
|
+
return toErrorContent(
|
|
23825
|
+
`Cannot transition offer ${offer.offer_number} from "${currentStatus}" to "${newStatus}". Valid next statuses: ${validNext.length > 0 ? validNext.join(", ") : "(none)"}.`
|
|
23826
|
+
);
|
|
23827
|
+
}
|
|
23828
|
+
if (newStatus === "draft" && offer.invoice_id) {
|
|
23829
|
+
return toErrorContent(
|
|
23830
|
+
`Cannot revert offer ${offer.offer_number} to draft \u2014 it has already been converted to an invoice.`
|
|
23831
|
+
);
|
|
23832
|
+
}
|
|
23833
|
+
const updateFields = { status: newStatus };
|
|
23834
|
+
const timestampMap = {
|
|
23835
|
+
sent: "sent_at",
|
|
23836
|
+
viewed: "viewed_at",
|
|
23837
|
+
accepted: "accepted_at",
|
|
23838
|
+
rejected: "rejected_at"
|
|
23839
|
+
};
|
|
23840
|
+
if (timestampMap[newStatus]) {
|
|
23841
|
+
updateFields[timestampMap[newStatus]] = (/* @__PURE__ */ new Date()).toISOString();
|
|
23842
|
+
}
|
|
23843
|
+
const { error: updateError } = await supabase2.from("offers").update(updateFields).eq("id", params.offerId).eq("company_id", companyId2);
|
|
23844
|
+
if (updateError) {
|
|
23845
|
+
return toErrorContent(sanitizeDbError("Failed to update offer status", updateError));
|
|
23846
|
+
}
|
|
23847
|
+
const { error: eventError } = await supabase2.from("offer_events").insert({
|
|
23848
|
+
offer_id: params.offerId,
|
|
23849
|
+
event_type: `status_${newStatus}`
|
|
23850
|
+
});
|
|
23851
|
+
if (eventError) {
|
|
23852
|
+
console.error("Failed to log offer event:", eventError);
|
|
23853
|
+
}
|
|
23854
|
+
return toTextContent({
|
|
23855
|
+
message: `Offer ${offer.offer_number} status changed from "${currentStatus}" to "${newStatus}"`,
|
|
23856
|
+
offer_number: offer.offer_number,
|
|
23857
|
+
previous_status: currentStatus,
|
|
23858
|
+
new_status: newStatus
|
|
23859
|
+
});
|
|
23860
|
+
}
|
|
23861
|
+
);
|
|
23862
|
+
server2.tool(
|
|
23863
|
+
"kirjapro_delete_offer",
|
|
23864
|
+
'Delete a draft offer/contract. Only offers with status "draft" can be deleted.',
|
|
23865
|
+
{
|
|
23866
|
+
offerId: external_exports.string().uuid().describe("Offer UUID")
|
|
23867
|
+
},
|
|
23868
|
+
async (params) => {
|
|
23869
|
+
const supabase2 = getSupabase();
|
|
23870
|
+
const companyId2 = getCompanyId();
|
|
23871
|
+
const { data: offer, error: fetchError } = await supabase2.from("offers").select("id, offer_number, status, type").eq("id", params.offerId).eq("company_id", companyId2).single();
|
|
23872
|
+
if (fetchError || !offer) {
|
|
23873
|
+
return toErrorContent(
|
|
23874
|
+
`Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
|
|
23875
|
+
);
|
|
23876
|
+
}
|
|
23877
|
+
if (offer.status !== "draft") {
|
|
23878
|
+
return toErrorContent(
|
|
23879
|
+
`Cannot delete offer ${offer.offer_number} \u2014 status is "${offer.status}". Only draft offers can be deleted.`
|
|
23880
|
+
);
|
|
23881
|
+
}
|
|
23882
|
+
const { error: deleteError } = await supabase2.from("offers").delete().eq("id", params.offerId).eq("company_id", companyId2);
|
|
23883
|
+
if (deleteError) {
|
|
23884
|
+
return toErrorContent(sanitizeDbError("Failed to delete offer", deleteError));
|
|
23885
|
+
}
|
|
23886
|
+
return toTextContent({
|
|
23887
|
+
message: `Draft ${offer.type === "sopimus" ? "contract" : "offer"} ${offer.offer_number} deleted`,
|
|
23888
|
+
offer_number: offer.offer_number
|
|
23889
|
+
});
|
|
23890
|
+
}
|
|
23891
|
+
);
|
|
23892
|
+
}
|
|
23893
|
+
|
|
23894
|
+
// src/tools/recurring-invoices.ts
|
|
23895
|
+
function registerRecurringInvoiceTools(server2) {
|
|
23896
|
+
server2.tool(
|
|
23897
|
+
"kirjapro_list_recurring_invoices",
|
|
23898
|
+
"List recurring invoice templates with pagination. Shows frequency, next generation date, active status, and customer info.",
|
|
23899
|
+
{
|
|
23900
|
+
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
23901
|
+
offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
|
|
23902
|
+
activeOnly: external_exports.boolean().default(true).describe("Only show active templates (default true)")
|
|
23903
|
+
},
|
|
23904
|
+
async (params) => {
|
|
23905
|
+
const supabase2 = getSupabase();
|
|
23906
|
+
const companyId2 = getCompanyId();
|
|
23907
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
23908
|
+
let query = supabase2.from("recurring_invoices").select(
|
|
23909
|
+
"id, name, frequency, next_date, end_date, is_active, auto_send, last_generated_at, times_generated, created_at, customer:customers(id, name)",
|
|
23910
|
+
{ count: "exact" }
|
|
23911
|
+
).eq("company_id", companyId2).order("next_date");
|
|
23912
|
+
if (params.activeOnly) {
|
|
23913
|
+
query = query.eq("is_active", true);
|
|
23914
|
+
}
|
|
23915
|
+
query = query.range(offset, offset + limit - 1);
|
|
23916
|
+
const { data, count, error: error2 } = await query;
|
|
23917
|
+
if (error2) {
|
|
23918
|
+
return toErrorContent(sanitizeDbError("Failed to list recurring invoices", error2));
|
|
23919
|
+
}
|
|
23920
|
+
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
23921
|
+
}
|
|
23922
|
+
);
|
|
23923
|
+
server2.tool(
|
|
23924
|
+
"kirjapro_get_recurring_invoice",
|
|
23925
|
+
"Get a single recurring invoice template with full details including template data (lines, notes, payment terms) and generation history.",
|
|
23926
|
+
{
|
|
23927
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
|
|
23928
|
+
},
|
|
23929
|
+
async (params) => {
|
|
23930
|
+
const supabase2 = getSupabase();
|
|
23931
|
+
const companyId2 = getCompanyId();
|
|
23932
|
+
const { data, error: error2 } = await supabase2.from("recurring_invoices").select(
|
|
23933
|
+
"*, customer:customers(id, name, business_id, email)"
|
|
23934
|
+
).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
23935
|
+
if (error2 || !data) {
|
|
23936
|
+
return toErrorContent(
|
|
23937
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
23938
|
+
);
|
|
23939
|
+
}
|
|
23940
|
+
const { data: logs } = await supabase2.from("recurring_invoice_logs").select("id, status, scheduled_date, invoice_number, error_message, created_at").eq("recurring_invoice_id", params.recurringInvoiceId).eq("company_id", companyId2).order("created_at", { ascending: false }).limit(10);
|
|
23941
|
+
return toTextContent({
|
|
23942
|
+
...data,
|
|
23943
|
+
recent_logs: logs || []
|
|
23944
|
+
});
|
|
23945
|
+
}
|
|
23946
|
+
);
|
|
23947
|
+
server2.tool(
|
|
23948
|
+
"kirjapro_create_recurring_invoice",
|
|
23949
|
+
"Create a new recurring invoice template. Specify customer, frequency, start date, and invoice line items. Invoices will be generated automatically on the schedule.",
|
|
23950
|
+
{
|
|
23951
|
+
customerId: external_exports.string().uuid().describe("Customer UUID"),
|
|
23952
|
+
name: external_exports.string().min(1).max(200).describe('Template name (e.g. "Monthly hosting fee")'),
|
|
23953
|
+
frequency: external_exports.enum(["weekly", "monthly", "quarterly", "yearly"]).describe("Generation frequency"),
|
|
23954
|
+
nextDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("First generation date (YYYY-MM-DD)"),
|
|
23955
|
+
endDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Stop generating after this date"),
|
|
23956
|
+
autoSend: external_exports.boolean().default(false).describe("Auto-send generated invoices (default false)"),
|
|
23957
|
+
lines: external_exports.array(external_exports.object({
|
|
23958
|
+
description: external_exports.string().describe("Line item description"),
|
|
23959
|
+
quantity: external_exports.number().positive().describe("Quantity"),
|
|
23960
|
+
unit: external_exports.string().default("kpl").describe('Unit (default "kpl")'),
|
|
23961
|
+
unitPrice: external_exports.number().describe("Unit price (EUR)"),
|
|
23962
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("VAT rate")
|
|
23963
|
+
})).min(1).describe("Invoice line items"),
|
|
23964
|
+
notes: external_exports.string().optional().describe("Invoice notes"),
|
|
23965
|
+
paymentTermDays: external_exports.number().min(0).max(365).default(14).describe("Payment term in days")
|
|
23966
|
+
},
|
|
23967
|
+
async (params) => {
|
|
23968
|
+
const supabase2 = getSupabase();
|
|
23969
|
+
const companyId2 = getCompanyId();
|
|
23970
|
+
const { data: customer, error: custError } = await supabase2.from("customers").select("id, name").eq("id", params.customerId).eq("company_id", companyId2).maybeSingle();
|
|
23971
|
+
if (custError || !customer) {
|
|
23972
|
+
return toErrorContent(
|
|
23973
|
+
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
23974
|
+
);
|
|
23975
|
+
}
|
|
23976
|
+
const templateData = {
|
|
23977
|
+
lines: params.lines.map((l, i) => ({
|
|
23978
|
+
line_number: i + 1,
|
|
23979
|
+
description: l.description,
|
|
23980
|
+
quantity: l.quantity,
|
|
23981
|
+
unit: l.unit,
|
|
23982
|
+
unit_price: l.unitPrice,
|
|
23983
|
+
vat_rate: parseFloat(l.vatRate)
|
|
23984
|
+
})),
|
|
23985
|
+
notes: params.notes || null,
|
|
23986
|
+
payment_terms_days: params.paymentTermDays
|
|
23987
|
+
};
|
|
23988
|
+
const { data, error: error2 } = await supabase2.from("recurring_invoices").insert({
|
|
23989
|
+
company_id: companyId2,
|
|
23990
|
+
customer_id: params.customerId,
|
|
23991
|
+
name: params.name,
|
|
23992
|
+
frequency: params.frequency,
|
|
23993
|
+
next_date: params.nextDate,
|
|
23994
|
+
end_date: params.endDate || null,
|
|
23995
|
+
is_active: true,
|
|
23996
|
+
auto_send: params.autoSend,
|
|
23997
|
+
template_data: templateData
|
|
23998
|
+
}).select().single();
|
|
23999
|
+
if (error2) {
|
|
24000
|
+
return toErrorContent(sanitizeDbError("Failed to create recurring invoice", error2));
|
|
24001
|
+
}
|
|
24002
|
+
return toTextContent({
|
|
24003
|
+
message: `Recurring invoice "${params.name}" created for ${customer.name} (${params.frequency}, next: ${params.nextDate})`,
|
|
24004
|
+
recurring_invoice: data
|
|
24005
|
+
});
|
|
24006
|
+
}
|
|
24007
|
+
);
|
|
24008
|
+
server2.tool(
|
|
24009
|
+
"kirjapro_update_recurring_invoice",
|
|
24010
|
+
"Update a recurring invoice template. Only provided fields are changed. Use this to pause/resume (isActive), change schedule, or update line items.",
|
|
24011
|
+
{
|
|
24012
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID"),
|
|
24013
|
+
name: external_exports.string().min(1).max(200).optional().describe("Template name"),
|
|
24014
|
+
frequency: external_exports.enum(["weekly", "monthly", "quarterly", "yearly"]).optional().describe("Generation frequency"),
|
|
24015
|
+
nextDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Next generation date"),
|
|
24016
|
+
endDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable().describe("End date (null to remove)"),
|
|
24017
|
+
isActive: external_exports.boolean().optional().describe("Active status (false to pause)"),
|
|
24018
|
+
autoSend: external_exports.boolean().optional().describe("Auto-send generated invoices"),
|
|
24019
|
+
lines: external_exports.array(external_exports.object({
|
|
24020
|
+
description: external_exports.string().describe("Line item description"),
|
|
24021
|
+
quantity: external_exports.number().positive().describe("Quantity"),
|
|
24022
|
+
unit: external_exports.string().default("kpl").describe("Unit"),
|
|
24023
|
+
unitPrice: external_exports.number().describe("Unit price (EUR)"),
|
|
24024
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("VAT rate")
|
|
24025
|
+
})).min(1).optional().describe("Replace all invoice lines"),
|
|
24026
|
+
notes: external_exports.string().optional().nullable().describe("Invoice notes (null to remove)"),
|
|
24027
|
+
paymentTermDays: external_exports.number().min(0).max(365).optional().describe("Payment term in days")
|
|
24028
|
+
},
|
|
24029
|
+
async (params) => {
|
|
24030
|
+
const supabase2 = getSupabase();
|
|
24031
|
+
const companyId2 = getCompanyId();
|
|
24032
|
+
const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, template_data").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
24033
|
+
if (fetchError || !existing) {
|
|
24034
|
+
return toErrorContent(
|
|
24035
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
24036
|
+
);
|
|
24037
|
+
}
|
|
24038
|
+
const updates = {};
|
|
24039
|
+
if (params.name !== void 0) updates.name = params.name;
|
|
24040
|
+
if (params.frequency !== void 0) updates.frequency = params.frequency;
|
|
24041
|
+
if (params.nextDate !== void 0) updates.next_date = params.nextDate;
|
|
24042
|
+
if (params.endDate !== void 0) updates.end_date = params.endDate;
|
|
24043
|
+
if (params.isActive !== void 0) updates.is_active = params.isActive;
|
|
24044
|
+
if (params.autoSend !== void 0) updates.auto_send = params.autoSend;
|
|
24045
|
+
if (params.lines !== void 0 || params.notes !== void 0 || params.paymentTermDays !== void 0) {
|
|
24046
|
+
const currentTemplate = existing.template_data || {};
|
|
24047
|
+
const newTemplate = { ...currentTemplate };
|
|
24048
|
+
if (params.lines !== void 0) {
|
|
24049
|
+
newTemplate.lines = params.lines.map((l, i) => ({
|
|
24050
|
+
line_number: i + 1,
|
|
24051
|
+
description: l.description,
|
|
24052
|
+
quantity: l.quantity,
|
|
24053
|
+
unit: l.unit,
|
|
24054
|
+
unit_price: l.unitPrice,
|
|
24055
|
+
vat_rate: parseFloat(l.vatRate)
|
|
24056
|
+
}));
|
|
24057
|
+
}
|
|
24058
|
+
if (params.notes !== void 0) newTemplate.notes = params.notes;
|
|
24059
|
+
if (params.paymentTermDays !== void 0) newTemplate.payment_terms_days = params.paymentTermDays;
|
|
24060
|
+
updates.template_data = newTemplate;
|
|
24061
|
+
}
|
|
24062
|
+
if (Object.keys(updates).length === 0) {
|
|
24063
|
+
return toErrorContent("No fields to update. Provide at least one field to change.");
|
|
24064
|
+
}
|
|
24065
|
+
const { data, error: error2 } = await supabase2.from("recurring_invoices").update(updates).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).select().single();
|
|
24066
|
+
if (error2) {
|
|
24067
|
+
return toErrorContent(sanitizeDbError("Failed to update recurring invoice", error2));
|
|
24068
|
+
}
|
|
24069
|
+
return toTextContent({
|
|
24070
|
+
message: `Recurring invoice "${data.name}" updated`,
|
|
24071
|
+
recurring_invoice: data,
|
|
24072
|
+
fields_changed: Object.keys(updates)
|
|
24073
|
+
});
|
|
24074
|
+
}
|
|
24075
|
+
);
|
|
24076
|
+
server2.tool(
|
|
24077
|
+
"kirjapro_delete_recurring_invoice",
|
|
24078
|
+
"Delete a recurring invoice template. Previously generated invoices are NOT affected.",
|
|
24079
|
+
{
|
|
24080
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
|
|
24081
|
+
},
|
|
24082
|
+
async (params) => {
|
|
24083
|
+
const supabase2 = getSupabase();
|
|
24084
|
+
const companyId2 = getCompanyId();
|
|
24085
|
+
const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
24086
|
+
if (fetchError || !existing) {
|
|
24087
|
+
return toErrorContent(
|
|
24088
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
24089
|
+
);
|
|
24090
|
+
}
|
|
24091
|
+
const { error: error2 } = await supabase2.from("recurring_invoices").delete().eq("id", params.recurringInvoiceId).eq("company_id", companyId2);
|
|
24092
|
+
if (error2) {
|
|
24093
|
+
return toErrorContent(sanitizeDbError("Failed to delete recurring invoice", error2));
|
|
24094
|
+
}
|
|
24095
|
+
return toTextContent({
|
|
24096
|
+
message: `Recurring invoice "${existing.name}" deleted. Previously generated invoices are unaffected.`
|
|
24097
|
+
});
|
|
24098
|
+
}
|
|
24099
|
+
);
|
|
24100
|
+
server2.tool(
|
|
24101
|
+
"kirjapro_trigger_recurring_invoice",
|
|
24102
|
+
"Manually trigger generation of a recurring invoice now, regardless of its next scheduled date. Returns the generated invoice.",
|
|
24103
|
+
{
|
|
24104
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
|
|
24105
|
+
},
|
|
24106
|
+
async (params) => {
|
|
24107
|
+
const supabase2 = getSupabase();
|
|
24108
|
+
const companyId2 = getCompanyId();
|
|
24109
|
+
const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, is_active").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
24110
|
+
if (fetchError || !existing) {
|
|
24111
|
+
return toErrorContent(
|
|
24112
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
24113
|
+
);
|
|
24114
|
+
}
|
|
24115
|
+
if (!existing.is_active) {
|
|
24116
|
+
return toErrorContent(
|
|
24117
|
+
`Recurring invoice "${existing.name}" is paused. Activate it first with kirjapro_update_recurring_invoice.`
|
|
24118
|
+
);
|
|
24119
|
+
}
|
|
24120
|
+
const result = await invokeEdgeFunction(
|
|
24121
|
+
supabase2,
|
|
24122
|
+
"generate-recurring-invoices",
|
|
24123
|
+
{ recurring_invoice_id: params.recurringInvoiceId, company_id: companyId2 }
|
|
24124
|
+
);
|
|
24125
|
+
if (result.error !== null) {
|
|
24126
|
+
return toErrorContent(result.error);
|
|
24127
|
+
}
|
|
24128
|
+
return toTextContent({
|
|
24129
|
+
message: `Invoice generated from "${existing.name}"`,
|
|
24130
|
+
...result.data
|
|
24131
|
+
});
|
|
24132
|
+
}
|
|
24133
|
+
);
|
|
24134
|
+
}
|
|
24135
|
+
|
|
24136
|
+
// src/tools/received-invoices.ts
|
|
24137
|
+
function registerReceivedInvoiceTools(server2) {
|
|
24138
|
+
server2.tool(
|
|
24139
|
+
"kirjapro_list_received_invoices",
|
|
24140
|
+
"List received (purchase) invoices with pagination and filters. Shows sender, amounts, status, and due dates.",
|
|
24141
|
+
{
|
|
24142
|
+
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
24143
|
+
offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
|
|
24144
|
+
status: external_exports.enum(["new", "reviewed", "approved", "rejected", "booked"]).optional().describe("Filter by status"),
|
|
24145
|
+
search: external_exports.string().optional().describe("Search by sender name, invoice number, or business ID"),
|
|
24146
|
+
dateFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date from (YYYY-MM-DD)"),
|
|
24147
|
+
dateTo: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date to (YYYY-MM-DD)")
|
|
24148
|
+
},
|
|
24149
|
+
async (params) => {
|
|
24150
|
+
const supabase2 = getSupabase();
|
|
24151
|
+
const companyId2 = getCompanyId();
|
|
24152
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
24153
|
+
let query = supabase2.from("received_invoices").select(
|
|
24154
|
+
"id, sender_business_id, sender_name, invoice_number, invoice_date, due_date, total_amount, vat_amount, currency, payment_reference, iban, status, created_at",
|
|
24155
|
+
{ count: "exact" }
|
|
24156
|
+
).eq("company_id", companyId2).order("created_at", { ascending: false });
|
|
24157
|
+
if (params.status) {
|
|
24158
|
+
query = query.eq("status", params.status);
|
|
24159
|
+
}
|
|
24160
|
+
if (params.search) {
|
|
24161
|
+
const s = escapeLike(params.search);
|
|
24162
|
+
query = query.or(
|
|
24163
|
+
`sender_name.ilike.%${s}%,invoice_number.ilike.%${s}%,sender_business_id.ilike.%${s}%`
|
|
24164
|
+
);
|
|
24165
|
+
}
|
|
24166
|
+
if (params.dateFrom) {
|
|
24167
|
+
query = query.gte("invoice_date", params.dateFrom);
|
|
24168
|
+
}
|
|
24169
|
+
if (params.dateTo) {
|
|
24170
|
+
query = query.lte("invoice_date", params.dateTo);
|
|
24171
|
+
}
|
|
24172
|
+
query = query.range(offset, offset + limit - 1);
|
|
24173
|
+
const { data, count, error: error2 } = await query;
|
|
24174
|
+
if (error2) {
|
|
24175
|
+
return toErrorContent(sanitizeDbError("Failed to list received invoices", error2));
|
|
24176
|
+
}
|
|
24177
|
+
const results = (data || []).map((inv) => ({
|
|
24178
|
+
...inv,
|
|
24179
|
+
total_amount_formatted: inv.total_amount != null ? formatCurrency(inv.total_amount) : null
|
|
24180
|
+
}));
|
|
24181
|
+
return toTextContent(paginate(results, count || 0, offset, limit));
|
|
24182
|
+
}
|
|
24183
|
+
);
|
|
24184
|
+
server2.tool(
|
|
24185
|
+
"kirjapro_get_received_invoice",
|
|
24186
|
+
"Get a single received invoice with full details including sender info, line items, and linked transaction.",
|
|
24187
|
+
{
|
|
24188
|
+
receivedInvoiceId: external_exports.string().uuid().describe("Received invoice UUID")
|
|
24189
|
+
},
|
|
24190
|
+
async (params) => {
|
|
24191
|
+
const supabase2 = getSupabase();
|
|
24192
|
+
const companyId2 = getCompanyId();
|
|
24193
|
+
const { data, error: error2 } = await supabase2.from("received_invoices").select("*").eq("id", params.receivedInvoiceId).eq("company_id", companyId2).single();
|
|
24194
|
+
if (error2 || !data) {
|
|
24195
|
+
return toErrorContent(
|
|
24196
|
+
`Received invoice ${params.receivedInvoiceId} not found. Use kirjapro_list_received_invoices to find IDs.`
|
|
24197
|
+
);
|
|
24198
|
+
}
|
|
24199
|
+
let linkedTransaction = null;
|
|
24200
|
+
if (data.transaction_id) {
|
|
24201
|
+
const { data: tx } = await supabase2.from("transactions").select("id, transaction_date, description, document_number").eq("id", data.transaction_id).eq("company_id", companyId2).single();
|
|
24202
|
+
linkedTransaction = tx;
|
|
24203
|
+
}
|
|
24204
|
+
return toTextContent({
|
|
24205
|
+
...data,
|
|
24206
|
+
total_amount_formatted: data.total_amount != null ? formatCurrency(data.total_amount) : null,
|
|
24207
|
+
vat_amount_formatted: data.vat_amount != null ? formatCurrency(data.vat_amount) : null,
|
|
24208
|
+
linked_transaction: linkedTransaction
|
|
24209
|
+
});
|
|
24210
|
+
}
|
|
24211
|
+
);
|
|
24212
|
+
}
|
|
24213
|
+
|
|
24214
|
+
// src/tools/index.ts
|
|
24215
|
+
function registerTools(server2) {
|
|
24216
|
+
registerAccountTools(server2);
|
|
24217
|
+
registerTransactionTools(server2);
|
|
24218
|
+
registerTemplateTools(server2);
|
|
24219
|
+
registerBookingTools(server2);
|
|
24220
|
+
registerInvoiceTools(server2);
|
|
24221
|
+
registerCustomerTools(server2);
|
|
24222
|
+
registerReportTools(server2);
|
|
24223
|
+
registerBankingTools(server2);
|
|
24224
|
+
registerValidationTools(server2);
|
|
24225
|
+
registerFiscalYearTools(server2);
|
|
24226
|
+
registerOfferTools(server2);
|
|
24227
|
+
registerRecurringInvoiceTools(server2);
|
|
24228
|
+
registerReceivedInvoiceTools(server2);
|
|
24229
|
+
}
|
|
24230
|
+
|
|
24231
|
+
// src/resources/schema.ts
|
|
24232
|
+
var CHART_OF_ACCOUNTS_SUMMARY = {
|
|
24233
|
+
standard: "YSE 2017",
|
|
24234
|
+
ranges: [
|
|
24235
|
+
{ range: "1000-1299", type: "asset", label: "Pysyv\xE4t vastaavat (fixed assets)", examples: "1130 Koneet ja kalusto, 1210 Osakkeet" },
|
|
24236
|
+
{ range: "2000-2399", type: "asset", label: "Vaihtuvat vastaavat (current assets)", examples: "2110 Myyntisaamiset, 2140 ALV-saamiset, 2310 Kassa, 2320 Pankkitili" },
|
|
24237
|
+
{ range: "3000-3099", type: "equity", label: "Oma p\xE4\xE4oma (equity)", examples: "3010 Osakep\xE4\xE4oma, 3030 SVOP, 3050 Tilikauden tulos" },
|
|
24238
|
+
{ range: "4000-4099", type: "liability", label: "Vieras p\xE4\xE4oma (liabilities)", examples: "4030 Ostovelat, 4050 ALV-velka, 4060 Ennakonpid\xE4tysvelka, 4080 TyEL-velka" },
|
|
24239
|
+
{ range: "5000-5199", type: "revenue", label: "Tuotot (revenue)", examples: "5010 Myynti 25,5%, 5020 Myynti 13,5%, 5040 Myynti 0%" },
|
|
24240
|
+
{ range: "6000-6099", type: "expense", label: "Materiaalit ja palvelut", examples: "6010 Ostot, 6030 Ulkopuoliset palvelut" },
|
|
24241
|
+
{ range: "7000-7599", type: "expense", label: "Henkil\xF6st\xF6 ja poistot", examples: "7010 Palkat, 7020 El\xE4kekulut, 7510 Poistot" },
|
|
24242
|
+
{ range: "8000-8499", type: "expense", label: "Liiketoiminnan muut kulut", examples: "8010 Toimitila, 8050 Markkinointi, 8060 Hallinto, 8070 Vakuutukset" },
|
|
24243
|
+
{ range: "9000-9099", type: "expense", label: "Tuloverot", examples: "9010 Tilikauden tuloverot" }
|
|
24244
|
+
],
|
|
24245
|
+
note: "Use kirjapro_list_accounts to get full account list with search/filter."
|
|
24246
|
+
};
|
|
24247
|
+
var VAT_RATES = [
|
|
24248
|
+
{ rate: 25.5, label: "Yleinen verokanta", description: "Standard rate for most goods and services (from 1.9.2024, was 24%)", basis: "AVL 84\xA7" },
|
|
24249
|
+
{ rate: 13.5, label: "Alennettu verokanta", description: "Food, animal feed, restaurants, transport, accommodation, books, medicine, sports, culture, performing artists, sanitary products, broadcasting (from 1.1.2026, was 14%)", basis: "AVL 85\xA7, 85a\xA7" },
|
|
24250
|
+
{ rate: 10, label: "Lehdet ja aikakauslehdet", description: "Newspapers and magazines (print and digital) only", basis: "AVL 85a\xA7" },
|
|
24251
|
+
{ rate: 0, label: "Nollaverokanta", description: "Exports (AVL 70\xA7), intra-EU B2B supplies (AVL 72a\xA7), vessels, international transport. Input VAT deductible", basis: "AVL 55-72h\xA7" },
|
|
24252
|
+
{ rate: null, label: "Verovapaat myynnit", description: "Healthcare (AVL 34-36\xA7), education (AVL 39-40\xA7), financial services (AVL 41-42\xA7), insurance (AVL 44\xA7). NO input VAT deduction", basis: "AVL 34-44\xA7" }
|
|
24253
|
+
];
|
|
24254
|
+
function registerSchemaResources(server2) {
|
|
24255
|
+
server2.resource(
|
|
24256
|
+
"chart-of-accounts",
|
|
24257
|
+
"kirjapro://accounts/chart",
|
|
24258
|
+
{
|
|
24259
|
+
description: "Finnish chart of accounts summary (YSE 2017). Shows account number ranges and common accounts per category. Use kirjapro_list_accounts tool for full detail.",
|
|
24260
|
+
mimeType: "application/json"
|
|
24261
|
+
},
|
|
24262
|
+
async (uri) => ({
|
|
24263
|
+
contents: [{
|
|
24264
|
+
uri: uri.href,
|
|
24265
|
+
mimeType: "application/json",
|
|
24266
|
+
text: JSON.stringify(CHART_OF_ACCOUNTS_SUMMARY)
|
|
24267
|
+
}]
|
|
24268
|
+
})
|
|
24269
|
+
);
|
|
22656
24270
|
server2.resource(
|
|
22657
24271
|
"vat-rates",
|
|
22658
24272
|
"kirjapro://vat-rates",
|
|
22659
24273
|
{
|
|
22660
|
-
description: "Finnish VAT rates (2026). Rates: 25.5% (general), 13.5% (food/restaurants), 10% (
|
|
24274
|
+
description: "Finnish VAT rates (2026). Rates: 25.5% (general), 13.5% (food/restaurants/books/medicine/transport/accommodation/sports), 10% (newspapers/magazines only), 0% (exports/vessels \u2014 input VAT deductible), exempt (healthcare/education/finance/insurance \u2014 NO input VAT deduction).",
|
|
22661
24275
|
mimeType: "application/json"
|
|
22662
24276
|
},
|
|
22663
24277
|
async (uri) => ({
|
|
22664
24278
|
contents: [{
|
|
22665
24279
|
uri: uri.href,
|
|
22666
24280
|
mimeType: "application/json",
|
|
22667
|
-
text: JSON.stringify(VAT_RATES
|
|
24281
|
+
text: JSON.stringify(VAT_RATES)
|
|
22668
24282
|
}]
|
|
22669
24283
|
})
|
|
22670
24284
|
);
|
|
@@ -22687,7 +24301,7 @@ function registerCompanyResources(server2) {
|
|
|
22687
24301
|
contents: [{
|
|
22688
24302
|
uri: uri.href,
|
|
22689
24303
|
mimeType: "application/json",
|
|
22690
|
-
text: JSON.stringify(company || {}
|
|
24304
|
+
text: JSON.stringify(company || {})
|
|
22691
24305
|
}]
|
|
22692
24306
|
};
|
|
22693
24307
|
}
|
|
@@ -22703,27 +24317,341 @@ function registerCompanyResources(server2) {
|
|
|
22703
24317
|
const supabase2 = getSupabase();
|
|
22704
24318
|
const companyId2 = getCompanyId();
|
|
22705
24319
|
const { data: fiscalYears } = await supabase2.from("fiscal_years").select("id, start_date, end_date, is_closed, created_at").eq("company_id", companyId2).order("start_date", { ascending: false });
|
|
24320
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
24321
|
+
const enriched = (fiscalYears || []).map((fy) => ({
|
|
24322
|
+
...fy,
|
|
24323
|
+
is_current: !fy.is_closed && fy.start_date <= today && fy.end_date >= today
|
|
24324
|
+
}));
|
|
22706
24325
|
return {
|
|
22707
24326
|
contents: [{
|
|
22708
24327
|
uri: uri.href,
|
|
22709
24328
|
mimeType: "application/json",
|
|
22710
|
-
text: JSON.stringify(
|
|
24329
|
+
text: JSON.stringify(enriched)
|
|
22711
24330
|
}]
|
|
22712
24331
|
};
|
|
22713
24332
|
}
|
|
22714
24333
|
);
|
|
22715
24334
|
}
|
|
22716
24335
|
|
|
24336
|
+
// src/resources/compliance.ts
|
|
24337
|
+
var VAT_COMPLIANCE = {
|
|
24338
|
+
rates: {
|
|
24339
|
+
current_year: 2026,
|
|
24340
|
+
standard: { rate: 25.5, finnish: "Yleinen verokanta", effective: "2024-09-01", basis: "AVL 84\xA7", examples: "IT services, consulting, retail, construction, telecom, energy" },
|
|
24341
|
+
reduced: { rate: 13.5, finnish: "Alennettu verokanta", effective: "2026-01-01", basis: "AVL 85\xA7, 85a\xA7", examples: "Food, animal feed, restaurants, transport, accommodation, books, medicine, sports, culture, performing artists, sanitary products, broadcasting" },
|
|
24342
|
+
second_reduced: { rate: 10, finnish: "Toinen alennettu verokanta", basis: "AVL 85a\xA7", examples: "Newspapers, magazines, digital periodicals only" },
|
|
24343
|
+
zero: { rate: 0, finnish: "Nollaverokanta", basis: "AVL 55-72h\xA7", examples: "Exports outside EU, intra-community supplies, vessels (sale/rental/chartering), international transport" },
|
|
24344
|
+
exempt: { rate: null, finnish: "Verovapaat myynnit", basis: "AVL 34-44\xA7", examples: "Healthcare (AVL 34-36\xA7), education (AVL 39-40\xA7), financial services (AVL 41-42\xA7), insurance (AVL 44\xA7), social services (AVL 37-38\xA7), real estate transfers (AVL 27-28\xA7)" }
|
|
24345
|
+
},
|
|
24346
|
+
rate_history: [
|
|
24347
|
+
{ period: "1.1.2026 \u2192", standard: 25.5, reduced: 13.5, second_reduced: 10, note: "Reduced rate unified from 14% \u2192 13.5%" },
|
|
24348
|
+
{ period: "1.9.2024 \u2192 31.12.2025", standard: 25.5, reduced: 14, second_reduced: 10, note: "Standard raised from 24% \u2192 25.5%" },
|
|
24349
|
+
{ period: "1.1.2013 \u2192 31.8.2024", standard: 24, reduced: 14, second_reduced: 10 }
|
|
24350
|
+
],
|
|
24351
|
+
deduction_rules: [
|
|
24352
|
+
{ type: "Edustuskulut (entertainment)", vat_deduction: "0%", income_tax: "50%", basis: "AVL 114\xA7", booking: "Full gross to expense, NO ALV-saamiset entry" },
|
|
24353
|
+
{ type: "Neuvottelukulut (meetings)", vat_deduction: "100%", income_tax: "100%", basis: "AVL 102\xA7", booking: "Normal VAT split" },
|
|
24354
|
+
{ type: "Henkil\xF6st\xF6kulut (staff events)", vat_deduction: "100%", income_tax: "100%", basis: "AVL 102\xA7", booking: "Normal VAT split" },
|
|
24355
|
+
{ type: "Insurance premiums", vat_deduction: "0% (exempt)", income_tax: "100%", basis: "AVL 44\xA7", booking: "Full gross to expense, no VAT (vakuutusmaksuvero is separate)" },
|
|
24356
|
+
{ type: "Healthcare (staff)", vat_deduction: "0% (exempt)", income_tax: "100%", basis: "AVL 34-36\xA7" },
|
|
24357
|
+
{ type: "Financial services", vat_deduction: "0% (exempt)", income_tax: "100%", basis: "AVL 41-42\xA7" },
|
|
24358
|
+
{ type: "Henkil\xF6auto (passenger car M1)", vat_deduction: "100% if exclusive business use, 0% if ANY private use (incl. commuting)", income_tax: "Proportional", basis: "AVL 114\xA7 1 mom 4 kohta", booking: "Ajop\xE4iv\xE4kirja (mileage logbook) required as proof" },
|
|
24359
|
+
{ type: "Pakettiauto/kuorma-auto (van N1+)", vat_deduction: "Proportional to business use", income_tax: "Proportional", basis: "AVL 102\xA7, 117\xA7" },
|
|
24360
|
+
{ type: "Markkinointitilaisuudet (marketing events)", vat_deduction: "100%", income_tax: "100%", basis: "AVL 102\xA7, KHO 2013:63", booking: "Product launches, trade shows \u2014 NOT client entertainment" }
|
|
24361
|
+
],
|
|
24362
|
+
reverse_charge: {
|
|
24363
|
+
rule: "Buyer self-assesses both output and input VAT. Applies to: (1) foreign B2B providers not charging Finnish VAT, (2) domestic construction services, (3) metal scrap, (4) emissions allowances, (5) investment gold",
|
|
24364
|
+
basis: "AVL 8a-8d\xA7, 9\xA7, 65\xA7; EU VAT Directive Art. 196, Art. 199",
|
|
24365
|
+
scenarios: [
|
|
24366
|
+
{ type: "Foreign B2B", basis: "AVL 9\xA7", note: "Most common: EU and non-EU service providers" },
|
|
24367
|
+
{ type: "Construction services", basis: "AVL 8c\xA7", note: 'Since 1.4.2011. Buyer must itself sell/rent construction services "not just occasionally". Regular company commissioning renovation: NO reverse charge' },
|
|
24368
|
+
{ type: "Metal scrap and waste", basis: "AVL 8d\xA7", note: "Since 1.1.2015. Between VAT-registered entities" },
|
|
24369
|
+
{ type: "Emissions allowances", basis: "AVL 8b\xA7", note: "Greenhouse gas emission allowances" },
|
|
24370
|
+
{ type: "Investment gold", basis: "AVL 8a\xA7", note: "Taxable investment gold, gold materials, gold semi-finished products" }
|
|
24371
|
+
],
|
|
24372
|
+
booking: "4 entries: D expense + D ALV-saamiset + C bank + C ALV-velka (k\xE4\xE4nnetty)",
|
|
24373
|
+
note: "Net VAT effect is zero but BOTH must be reported on ALV-ilmoitus. Invoice must NOT show VAT rate or VAT amount",
|
|
24374
|
+
omavero_fields: "Foreign: 305 (EU goods), 306 (EU services). Domestic construction/scrap: 318 (VAT amount), 319/320 (tax bases)"
|
|
24375
|
+
},
|
|
24376
|
+
common_mistakes: [
|
|
24377
|
+
"Food/coffee at 25.5% \u2014 should be 13.5% (AVL 85\xA7)",
|
|
24378
|
+
"Train ticket at 10% \u2014 should be 13.5% (transport = reduced rate)",
|
|
24379
|
+
"Using 14% after 1.1.2026 \u2014 rate changed to 13.5%",
|
|
24380
|
+
"Restaurant alcohol at 13.5% \u2014 alcohol is 25.5% even at restaurants",
|
|
24381
|
+
"Newspaper/magazine at 13.5% \u2014 periodicals are 10%",
|
|
24382
|
+
"Splitting VAT from insurance premium \u2014 insurance is VAT-exempt, not zero-rated",
|
|
24383
|
+
"Deducting vakuutusmaksuvero as input VAT \u2014 it is not ALV"
|
|
24384
|
+
]
|
|
24385
|
+
};
|
|
24386
|
+
var INVOICE_REQUIREMENTS = {
|
|
24387
|
+
legal_basis: "AVL 209 e \xA7, EU VAT Directive Art. 226",
|
|
24388
|
+
source: "https://www.vero.fi/syventavat-vero-ohjeet/ohje-hakusivu/48090/laskutusvaatimukset-arvonlisaverotuksessa3/",
|
|
24389
|
+
total_markings: 18,
|
|
24390
|
+
mandatory_fields: [
|
|
24391
|
+
{ kohta: 1, field: "Laskun antamisp\xE4iv\xE4", english: "Invoice date", status: "implemented", relevance: "always" },
|
|
24392
|
+
{ kohta: 2, field: "Juokseva tunniste", english: "Sequential invoice number", status: "implemented", relevance: "always", note: "Gaps must be detectable per Vero requirement" },
|
|
24393
|
+
{ kohta: 3, field: "Myyj\xE4n ALV-tunniste", english: "Seller VAT ID (FI + Y-tunnus)", status: "implemented", relevance: "always", note: "Y-tunnus always required; FI-format strictly mandatory for EU cross-border" },
|
|
24394
|
+
{ kohta: 4, field: "Ostajan ALV-tunniste", english: "Buyer VAT ID", status: "implemented", relevance: "conditional", note: "Required for reverse charge and EU B2B" },
|
|
24395
|
+
{ kohta: 5, field: "Myyj\xE4n ja ostajan nimi ja osoite", english: "Names and addresses", status: "implemented", relevance: "always" },
|
|
24396
|
+
{ kohta: 6, field: "Tavaroiden/palvelujen kuvaus ja m\xE4\xE4r\xE4", english: "Description and quantity", status: "implemented", relevance: "always" },
|
|
24397
|
+
{ kohta: 7, field: "Toimitusp\xE4iv\xE4 / suoritusp\xE4iv\xE4", english: "Delivery date", status: "partial", relevance: "conditional", note: "NOT required when same as invoice date (Vero.fi 5.1.7). Required when different" },
|
|
24398
|
+
{ kohta: 8, field: "Veron peruste, yksikk\xF6hinta, alennukset", english: "Tax base, unit price, discounts", status: "implemented", relevance: "always" },
|
|
24399
|
+
{ kohta: 9, field: "Verokanta", english: "VAT rate", status: "implemented", relevance: "always" },
|
|
24400
|
+
{ kohta: 10, field: "Suoritettavan veron m\xE4\xE4r\xE4", english: "VAT amount", status: "implemented", relevance: "always" },
|
|
24401
|
+
{ kohta: 11, field: "Merkint\xE4 verottomuudesta", english: "Tax exemption notation", status: "gap", relevance: "conditional", note: 'Simple "Veroton myynti" suffices. Citing AVL section is recommended but not mandatory (Vero.fi 5.1.11)' },
|
|
24402
|
+
{ kohta: 12, field: "Merkint\xE4 k\xE4\xE4nnetyst\xE4 verovelvollisuudesta", english: "Reverse charge notation", status: "gap", relevance: "conditional", note: '"K\xE4\xE4nnetty verovelvollisuus" phrase is mandatory. Legal basis citation is recommended, not required' },
|
|
24403
|
+
{ kohta: 13, field: "Merkint\xE4 itselaskutuksesta", english: "Self-billing notation", status: "n/a", relevance: "situational", note: "When buyer issues invoice on seller behalf" },
|
|
24404
|
+
{ kohta: 14, field: "Uuden kuljetusv\xE4lineen tiedot", english: "New means of transport details", status: "n/a", relevance: "situational" },
|
|
24405
|
+
{ kohta: 15, field: "Voittomarginaalij\xE4rjestelm\xE4", english: "Margin scheme (used goods)", status: "n/a", relevance: "situational" },
|
|
24406
|
+
{ kohta: 16, field: "Voittomarginaalij\xE4rjestelm\xE4 \u2014 matkatoimistot", english: "Margin scheme (travel agents)", status: "n/a", relevance: "situational" },
|
|
24407
|
+
{ kohta: 17, field: "Verollisen sijoituskullan myynti", english: "Taxable investment gold", status: "n/a", relevance: "situational" },
|
|
24408
|
+
{ kohta: 18, field: "Viittaus alkuper\xE4iseen laskuun", english: "Credit note reference (muutoslasku)", status: "not_implemented", relevance: "conditional", note: "Only mandatory requirement: unambiguous reference to original invoice" }
|
|
24409
|
+
],
|
|
24410
|
+
simplified_invoice: {
|
|
24411
|
+
threshold: 400,
|
|
24412
|
+
note: "Invoices \u2264 400 \u20AC may use simplified format (AVL 209 f \xA7). Also: retail to consumers, restaurant/catering, passenger transport \u2014 regardless of amount"
|
|
24413
|
+
},
|
|
24414
|
+
muutoslasku: {
|
|
24415
|
+
status: "not_implemented",
|
|
24416
|
+
mandatory: "Unambiguous reference to original invoice (number or period)",
|
|
24417
|
+
recommended: "Hyvityslasku label, reason for credit, same numbering sequence \u2014 common practice, not legally mandated"
|
|
24418
|
+
}
|
|
24419
|
+
};
|
|
24420
|
+
var FILING_DEADLINES = {
|
|
24421
|
+
vat_filing: {
|
|
24422
|
+
thresholds: [
|
|
24423
|
+
{ revenue: "< 30,000 \u20AC", periods: ["monthly", "quarterly", "annually"], note: "Annual: deadline last day of February of following year (28.2 or 29.2 in leap year)" },
|
|
24424
|
+
{ revenue: "30,000\u2013100,000 \u20AC", periods: ["monthly", "quarterly"], note: "Quarterly: 12th of 2nd month after quarter" },
|
|
24425
|
+
{ revenue: "> 100,000 \u20AC", periods: ["monthly"], note: "Monthly: 12th of 2nd month after reporting month" }
|
|
24426
|
+
],
|
|
24427
|
+
monthly_example: "January 2026 VAT \u2192 deadline 12.3.2026",
|
|
24428
|
+
quarterly_deadlines: {
|
|
24429
|
+
Q1: "12.5",
|
|
24430
|
+
Q2: "12.8",
|
|
24431
|
+
Q3: "12.11",
|
|
24432
|
+
Q4: "12.2 (next year)"
|
|
24433
|
+
}
|
|
24434
|
+
},
|
|
24435
|
+
tyel: {
|
|
24436
|
+
monthly_report: "5th of following month \u2192 pension company",
|
|
24437
|
+
payment: "20th of following month \u2192 pension company"
|
|
24438
|
+
},
|
|
24439
|
+
tulorekisteri: {
|
|
24440
|
+
palkkatietoilmoitus: "5 calendar days from payment date \u2192 Tulorekisteri",
|
|
24441
|
+
tyonantajan_erillisilmoitus: "5th of following month \u2192 Tulorekisteri (sotu totals)"
|
|
24442
|
+
},
|
|
24443
|
+
sairausvakuutusmaksu_payment: "12th of following month \u2192 Vero (OmaVero, oma-aloitteinen)",
|
|
24444
|
+
tilinpaatos: "4 months from fiscal year end (KPL 3:6)",
|
|
24445
|
+
prh_filing: "8 months from fiscal year end (OYL 8:10)",
|
|
24446
|
+
pienyrittaja: {
|
|
24447
|
+
threshold: 2e4,
|
|
24448
|
+
note: "Revenue under 20,000 \u20AC/year: no VAT registration obligation (from 1.1.2025, was 15,000 \u20AC)",
|
|
24449
|
+
alarajahuojennus: "REMOVED from 1.1.2025 \u2014 hard threshold, no progressive relief"
|
|
24450
|
+
},
|
|
24451
|
+
maksuperusteinen_alv: {
|
|
24452
|
+
threshold: 5e5,
|
|
24453
|
+
note: "Revenue under 500,000 \u20AC: may opt for cash-basis VAT (report when payment received/made, not when invoiced)",
|
|
24454
|
+
application: "Opt-in via OmaVero"
|
|
24455
|
+
},
|
|
24456
|
+
late_penalties: {
|
|
24457
|
+
myohastymismaksu: "3 \u20AC/day for days 1-45 (max 135 \u20AC), then +2% of tax amount (max 15,000 \u20AC per tax type)",
|
|
24458
|
+
viivastyskorko_2026: "9.5% for most taxes, 4.5% for inheritance tax",
|
|
24459
|
+
correction_grace: "Corrections within 45 days of original deadline: no penalty if change \u2264 500 \u20AC",
|
|
24460
|
+
nil_return: "Even when no VAT activity occurred, a nil return must be filed for each verokausi"
|
|
24461
|
+
},
|
|
24462
|
+
payment_deadline: "Same as filing deadline (12th of M+2 for monthly/quarterly, last day of Feb for annual)",
|
|
24463
|
+
omavero_field_codes: {
|
|
24464
|
+
source: "VSRALVKV tietuekuvaus v1.10 (27.2.2026)",
|
|
24465
|
+
source_url: "https://www.vero.fi/contentassets/ef5905e0f5b74bcba89aa9ba9c34015d/verohallinto_tietuekuvaus_vsralvkv_290824.pdf",
|
|
24466
|
+
vat_amounts: {
|
|
24467
|
+
301: "Suoritettava 24%/25,5% vero kotimaan myynnist\xE4 (domestic sales standard rate)",
|
|
24468
|
+
302: "Suoritettava 14%/13,5% vero kotimaan myynnist\xE4 (domestic sales reduced rate)",
|
|
24469
|
+
303: "Suoritettava 10% vero kotimaan myynnist\xE4 (domestic sales 10%)",
|
|
24470
|
+
304: "Vero tavaroiden maahantuonnista EU:n ulkopuolelta (import VAT from outside EU)",
|
|
24471
|
+
305: "Vero tavaraostoista muista EU-maista (reverse charge VAT on EU goods)",
|
|
24472
|
+
306: "Vero palveluostoista muista EU-maista (reverse charge VAT on EU services)",
|
|
24473
|
+
307: "Verokauden v\xE4hennett\xE4v\xE4 vero (total deductible input VAT)",
|
|
24474
|
+
308: "Maksettava vero / Palautukseen oikeuttava vero (net payable/refundable). Formula: (301+302+303+304+305+306+318) - 307",
|
|
24475
|
+
318: "Vero rakentamispalvelun ja metalliromun ostoista (construction/scrap reverse charge VAT)"
|
|
24476
|
+
},
|
|
24477
|
+
tax_bases: {
|
|
24478
|
+
309: "0-verokannan alainen liikevaihto (zero-rate turnover: exports, EU sales with deduction right)",
|
|
24479
|
+
310: "Tavaroiden maahantuonnit EU:n ulkopuolelta (goods imports from outside EU)",
|
|
24480
|
+
311: "Tavaroiden myynnit muihin EU-maihin (goods sales to EU)",
|
|
24481
|
+
312: "Palveluiden myynnit muihin EU-maihin (service sales to EU)",
|
|
24482
|
+
313: "Tavaraostot muista EU-maista (goods purchases from EU)",
|
|
24483
|
+
314: "Palveluostot muista EU-maista (service purchases from EU)",
|
|
24484
|
+
319: "Rakentamispalvelun ja metalliromun myynnit (construction/scrap sales)",
|
|
24485
|
+
320: "Rakentamispalvelun ja metalliromun ostot (construction/scrap purchases)"
|
|
24486
|
+
},
|
|
24487
|
+
removed_2025: "315, 316, 317 (alarajahuojennus fields) removed from 1.1.2025"
|
|
24488
|
+
}
|
|
24489
|
+
};
|
|
24490
|
+
var SOCIAL_INSURANCE_2026 = {
|
|
24491
|
+
year: 2026,
|
|
24492
|
+
sources: [
|
|
24493
|
+
"https://www.etk.fi/ajankohtaista/tyoelakemaksut-vuonna-2026/",
|
|
24494
|
+
"https://www.elo.fi/fi-fi/tyonantaja/tyel-vakuuttaminen/tyel-maksu/sosiaalivakuutusmaksut-2026",
|
|
24495
|
+
"https://tyel-vakuutus.fi/tyonantajan-sivukulut/"
|
|
24496
|
+
],
|
|
24497
|
+
tyel: {
|
|
24498
|
+
keskimaarainen: 24.4,
|
|
24499
|
+
perusmaksu: 24.85,
|
|
24500
|
+
tilapaistyonantaja: 25.85,
|
|
24501
|
+
employee: 7.3,
|
|
24502
|
+
employer_average: 17.1,
|
|
24503
|
+
employer_perusmaksu: 17.55,
|
|
24504
|
+
note: "Perusmaksu 24.85% is what pension companies bill before company-specific adjustments. Keskim\xE4\xE4r\xE4inen 24.40% is the actual average. Age differentiation eliminated in 2026 (ETK)"
|
|
24505
|
+
},
|
|
24506
|
+
sotu: {
|
|
24507
|
+
rate: 1.91,
|
|
24508
|
+
payer: "employer",
|
|
24509
|
+
note: "Sairausvakuutusmaksu, up from 1.87% in 2025. Paid monthly to Vero"
|
|
24510
|
+
},
|
|
24511
|
+
ttv: {
|
|
24512
|
+
employer: 0.31,
|
|
24513
|
+
employee: 0.89,
|
|
24514
|
+
note: "Ty\xF6tt\xF6myysvakuutus. Employer rate 0.31% for payroll \u2264 2,509,500 \u20AC; 1.23% above. Employee up from 0.59% in 2025"
|
|
24515
|
+
},
|
|
24516
|
+
tapaturma: {
|
|
24517
|
+
average: 0.51,
|
|
24518
|
+
range: "0.05\u20135.00%",
|
|
24519
|
+
payer: "employer",
|
|
24520
|
+
note: "Company-specific rate based on industry and claims history"
|
|
24521
|
+
},
|
|
24522
|
+
ryhmahenkivakuutus: {
|
|
24523
|
+
rate: 0.06,
|
|
24524
|
+
payer: "employer",
|
|
24525
|
+
note: "Standardized via collective agreement"
|
|
24526
|
+
},
|
|
24527
|
+
total_employer_side_costs: "~20% of gross salary (incl. TyEL employer share)",
|
|
24528
|
+
accounts: {
|
|
24529
|
+
salary: "7010 Palkat ja palkkiot",
|
|
24530
|
+
tyel_expense: "7020 El\xE4kekulut (TyEL)",
|
|
24531
|
+
sotu_expense: "7030 Muut henkil\xF6sivukulut",
|
|
24532
|
+
withholding_payable: "4060 Ennakonpid\xE4tysvelka",
|
|
24533
|
+
tyel_payable: "4080 TyEL-velka",
|
|
24534
|
+
ttv_payable: "4070 Sosiaaliturvamaksuvelka",
|
|
24535
|
+
bank: "2320 Pankkitili"
|
|
24536
|
+
}
|
|
24537
|
+
};
|
|
24538
|
+
var BOOKKEEPING_RULES = {
|
|
24539
|
+
legal_basis: "Kirjanpitolaki 1336/1997",
|
|
24540
|
+
core_principles: [
|
|
24541
|
+
{ rule: "Double-entry (kahdenkertainen kirjanpito)", basis: "KPL 2:1", note: "Every entry: total debits = total credits" },
|
|
24542
|
+
{ rule: "Voucher principle (tositeperiaate)", basis: "KPL 2:5", note: "Every entry must be based on a numbered voucher" },
|
|
24543
|
+
{ rule: "Audit trail (kirjausketju)", basis: "KPL 2:6", note: "Tosite \u2192 tositenumero \u2192 p\xE4iv\xE4kirja \u2192 p\xE4\xE4kirja \u2192 tilinp\xE4\xE4t\xF6s \u2014 unbroken chain" },
|
|
24544
|
+
{ rule: "Sequential numbering", basis: "KPL 2:5", note: "Gap detection must be possible from the numbering sequence" },
|
|
24545
|
+
{ rule: "Closed period immutability", basis: "KPL 2:7", note: "Corrections to closed periods must use new entries in current period" }
|
|
24546
|
+
],
|
|
24547
|
+
retention: [
|
|
24548
|
+
{ type: "Accounting records (kirjanpitokirjat)", years: 10, basis: "KPL 2:10 1 mom" },
|
|
24549
|
+
{ type: "Financial statements (tilinp\xE4\xE4t\xF6s)", years: 10, basis: "KPL 3:9" },
|
|
24550
|
+
{ type: "Vouchers, invoices (tositteet)", years: 6, basis: "KPL 2:10 2 mom" },
|
|
24551
|
+
{ type: "E-invoice XML originals", years: 6, basis: "KPL 2:7" }
|
|
24552
|
+
],
|
|
24553
|
+
fiscal_year: {
|
|
24554
|
+
normal_duration: "12 months",
|
|
24555
|
+
max_duration: "18 months (first/changed period only)",
|
|
24556
|
+
tilinpaatos_deadline: "4 months from fiscal year end",
|
|
24557
|
+
prh_deadline: "8 months from fiscal year end (Oy, Oyj)"
|
|
24558
|
+
},
|
|
24559
|
+
company_sizes: [
|
|
24560
|
+
{ class: "Mikroyritys", revenue: "< 900K", balance: "< 450K", employees: "< 10", tilinpaatos: "Simplified (PMA)" },
|
|
24561
|
+
{ class: "Pienyritys", revenue: "< 15M", balance: "< 7.5M", employees: "< 50", tilinpaatos: "Standard (PMA)" },
|
|
24562
|
+
{ class: "Keskisuuri", revenue: "< 50M", balance: "< 25M", employees: "< 250", tilinpaatos: "Full (KPA) + toimintakertomus for Oy/Osk" },
|
|
24563
|
+
{ class: "Suuryritys", revenue: "\u2265 50M", balance: "\u2265 25M", employees: "\u2265 250", tilinpaatos: "Full + toimintakertomus + rahoituslaskelma" }
|
|
24564
|
+
]
|
|
24565
|
+
};
|
|
24566
|
+
function registerComplianceResources(server2) {
|
|
24567
|
+
server2.resource(
|
|
24568
|
+
"compliance-vat",
|
|
24569
|
+
"kirjapro://compliance/vat",
|
|
24570
|
+
{
|
|
24571
|
+
description: "Finnish VAT compliance: rates (25.5/13.5/10/0%/exempt), rate history, deduction rules (edustus/neuvottelu/insurance), reverse charge, and common mistakes. CRITICAL: 0% (zero-rated, input VAT deductible) \u2260 exempt (no input VAT deduction). Legal basis: AVL 1501/1993.",
|
|
24572
|
+
mimeType: "application/json"
|
|
24573
|
+
},
|
|
24574
|
+
async (uri) => ({
|
|
24575
|
+
contents: [{
|
|
24576
|
+
uri: uri.href,
|
|
24577
|
+
mimeType: "application/json",
|
|
24578
|
+
text: JSON.stringify(VAT_COMPLIANCE)
|
|
24579
|
+
}]
|
|
24580
|
+
})
|
|
24581
|
+
);
|
|
24582
|
+
server2.resource(
|
|
24583
|
+
"compliance-invoicing",
|
|
24584
|
+
"kirjapro://compliance/invoicing",
|
|
24585
|
+
{
|
|
24586
|
+
description: "Invoice requirements per AVL 209 e \xA7: 18 mandatory markings (kohdat 1-18), muutoslasku/hyvityslasku rules, simplified invoice (\u2264400\u20AC + retail/restaurant). Shows implementation status. Source: vero.fi",
|
|
24587
|
+
mimeType: "application/json"
|
|
24588
|
+
},
|
|
24589
|
+
async (uri) => ({
|
|
24590
|
+
contents: [{
|
|
24591
|
+
uri: uri.href,
|
|
24592
|
+
mimeType: "application/json",
|
|
24593
|
+
text: JSON.stringify(INVOICE_REQUIREMENTS)
|
|
24594
|
+
}]
|
|
24595
|
+
})
|
|
24596
|
+
);
|
|
24597
|
+
server2.resource(
|
|
24598
|
+
"compliance-deadlines",
|
|
24599
|
+
"kirjapro://compliance/deadlines",
|
|
24600
|
+
{
|
|
24601
|
+
description: "Finnish tax filing deadlines: VAT verokausi thresholds (<30K annual, <100K quarterly, >100K monthly), TyEL reporting, tilinp\xE4\xE4t\xF6s deadlines, pienyritt\xE4j\xE4 20K\u20AC threshold, maksuperusteinen ALV.",
|
|
24602
|
+
mimeType: "application/json"
|
|
24603
|
+
},
|
|
24604
|
+
async (uri) => ({
|
|
24605
|
+
contents: [{
|
|
24606
|
+
uri: uri.href,
|
|
24607
|
+
mimeType: "application/json",
|
|
24608
|
+
text: JSON.stringify(FILING_DEADLINES)
|
|
24609
|
+
}]
|
|
24610
|
+
})
|
|
24611
|
+
);
|
|
24612
|
+
server2.resource(
|
|
24613
|
+
"compliance-social-insurance",
|
|
24614
|
+
"kirjapro://compliance/social-insurance",
|
|
24615
|
+
{
|
|
24616
|
+
description: "2026 Finnish social insurance rates: TyEL (perusmaksu 24.85%, keskim\xE4\xE4r\xE4inen 24.40%, employee 7.30%), sotu (1.91%), TTV (0.31%/0.89%), tapaturma (~0.51%), ryhm\xE4henki (0.06%). Sources: ETK, Elo, tyel-vakuutus.fi",
|
|
24617
|
+
mimeType: "application/json"
|
|
24618
|
+
},
|
|
24619
|
+
async (uri) => ({
|
|
24620
|
+
contents: [{
|
|
24621
|
+
uri: uri.href,
|
|
24622
|
+
mimeType: "application/json",
|
|
24623
|
+
text: JSON.stringify(SOCIAL_INSURANCE_2026)
|
|
24624
|
+
}]
|
|
24625
|
+
})
|
|
24626
|
+
);
|
|
24627
|
+
server2.resource(
|
|
24628
|
+
"compliance-bookkeeping",
|
|
24629
|
+
"kirjapro://compliance/bookkeeping",
|
|
24630
|
+
{
|
|
24631
|
+
description: "KPL (Kirjanpitolaki) requirements: double-entry, audit trail, voucher principle, retention periods (10/6 years), fiscal year rules (max 18 months), company size classification (mikro/pien/keski/suuri).",
|
|
24632
|
+
mimeType: "application/json"
|
|
24633
|
+
},
|
|
24634
|
+
async (uri) => ({
|
|
24635
|
+
contents: [{
|
|
24636
|
+
uri: uri.href,
|
|
24637
|
+
mimeType: "application/json",
|
|
24638
|
+
text: JSON.stringify(BOOKKEEPING_RULES)
|
|
24639
|
+
}]
|
|
24640
|
+
})
|
|
24641
|
+
);
|
|
24642
|
+
}
|
|
24643
|
+
|
|
22717
24644
|
// src/resources/index.ts
|
|
22718
24645
|
function registerResources(server2) {
|
|
22719
24646
|
registerSchemaResources(server2);
|
|
22720
24647
|
registerCompanyResources(server2);
|
|
24648
|
+
registerComplianceResources(server2);
|
|
22721
24649
|
}
|
|
22722
24650
|
|
|
22723
24651
|
// src/index.ts
|
|
22724
24652
|
var server = new McpServer({
|
|
22725
24653
|
name: "kirjapro",
|
|
22726
|
-
version: "0.
|
|
24654
|
+
version: "0.5.0"
|
|
22727
24655
|
});
|
|
22728
24656
|
async function main() {
|
|
22729
24657
|
try {
|