@coffer-org/plugin-finance 1.2.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/dist/account/index.d.ts +2 -0
- package/dist/account/index.js +40 -0
- package/dist/bill/index.d.ts +2 -0
- package/dist/bill/index.js +39 -0
- package/dist/fields/iban.d.ts +19 -0
- package/dist/fields/iban.js +36 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +35 -0
- package/dist/item_financing/index.d.ts +2 -0
- package/dist/item_financing/index.js +37 -0
- package/dist/runtime/connection.d.ts +5 -0
- package/dist/runtime/connection.js +14 -0
- package/dist/runtime/firefly.d.ts +50 -0
- package/dist/runtime/firefly.js +64 -0
- package/dist/runtime/import-wise.d.ts +5 -0
- package/dist/runtime/import-wise.js +46 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +35 -0
- package/dist/runtime/mapping.d.ts +8 -0
- package/dist/runtime/mapping.js +68 -0
- package/dist/runtime/sync.d.ts +5 -0
- package/dist/runtime/sync.js +57 -0
- package/dist/runtime/wise.d.ts +8 -0
- package/dist/runtime/wise.js +107 -0
- package/dist/transaction/index.d.ts +2 -0
- package/dist/transaction/index.js +41 -0
- package/package.json +32 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineModule } from '@coffer-org/sdk/module';
|
|
2
|
+
import { field } from '@coffer-org/sdk/fields';
|
|
3
|
+
import { iban } from "../fields/iban.js";
|
|
4
|
+
export default defineModule({
|
|
5
|
+
module: 'account',
|
|
6
|
+
vault: 'finance',
|
|
7
|
+
label: 'finance.account.label',
|
|
8
|
+
icon: 'lucide:landmark',
|
|
9
|
+
claude: `Firefly III account (read-only mirror). kind — account type.
|
|
10
|
+
current_balance — current balance. firefly_id — external key (do not edit).`,
|
|
11
|
+
views: {
|
|
12
|
+
table: ['kind', 'currency', 'current_balance'],
|
|
13
|
+
},
|
|
14
|
+
fields: [
|
|
15
|
+
field.title({ key: 'name', label: 'core.fields.name' }),
|
|
16
|
+
field.sheet({
|
|
17
|
+
fields: [
|
|
18
|
+
[
|
|
19
|
+
field.select({
|
|
20
|
+
key: 'kind',
|
|
21
|
+
label: 'finance.account.fields.kind',
|
|
22
|
+
options: [
|
|
23
|
+
{ value: 'asset', title: 'finance.account.options.asset' },
|
|
24
|
+
{ value: 'expense', title: 'finance.account.options.expense' },
|
|
25
|
+
{ value: 'revenue', title: 'finance.account.options.revenue' },
|
|
26
|
+
{ value: 'liability', title: 'finance.account.options.liability' },
|
|
27
|
+
{ value: 'cash', title: 'finance.account.options.cash' },
|
|
28
|
+
],
|
|
29
|
+
}),
|
|
30
|
+
field.string({ key: 'currency', label: 'finance.account.fields.currency', rules: { max: 3 } }),
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
field.money({ key: 'current_balance', label: 'finance.account.fields.current_balance' }),
|
|
34
|
+
iban({ key: 'iban', label: 'finance.account.fields.iban' }),
|
|
35
|
+
],
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
field.string({ key: 'firefly_id', label: 'finance.account.fields.firefly_id', rules: { max: 32 } }),
|
|
39
|
+
],
|
|
40
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineModule } from '@coffer-org/sdk/module';
|
|
2
|
+
import { field } from '@coffer-org/sdk/fields';
|
|
3
|
+
export default defineModule({
|
|
4
|
+
module: 'bill',
|
|
5
|
+
vault: 'finance',
|
|
6
|
+
label: 'finance.bill.label',
|
|
7
|
+
icon: 'lucide:receipt',
|
|
8
|
+
claude: `Recurring Firefly III payment (Bill = subscription, read-only).
|
|
9
|
+
repeat — recurrence. next_expected — next expected date. firefly_id — do not edit.`,
|
|
10
|
+
views: {
|
|
11
|
+
table: ['amount_min', 'repeat', 'next_expected', 'active'],
|
|
12
|
+
},
|
|
13
|
+
fields: [
|
|
14
|
+
field.title({ key: 'name', label: 'core.fields.name' }),
|
|
15
|
+
field.sheet({
|
|
16
|
+
fields: [
|
|
17
|
+
[
|
|
18
|
+
field.money({ key: 'amount_min', label: 'finance.bill.fields.amount_min' }),
|
|
19
|
+
field.money({ key: 'amount_max', label: 'finance.bill.fields.amount_max' }),
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
field.select({
|
|
23
|
+
key: 'repeat',
|
|
24
|
+
label: 'finance.bill.fields.repeat',
|
|
25
|
+
options: [
|
|
26
|
+
{ value: 'weekly', title: 'finance.bill.options.weekly' },
|
|
27
|
+
{ value: 'monthly', title: 'finance.bill.options.monthly' },
|
|
28
|
+
{ value: 'quarterly', title: 'finance.bill.options.quarterly' },
|
|
29
|
+
{ value: 'yearly', title: 'finance.bill.options.yearly' },
|
|
30
|
+
],
|
|
31
|
+
}),
|
|
32
|
+
field.date({ key: 'next_expected', label: 'finance.bill.fields.next_expected' }),
|
|
33
|
+
],
|
|
34
|
+
],
|
|
35
|
+
}),
|
|
36
|
+
field.boolean({ key: 'active', label: 'finance.bill.fields.active' }),
|
|
37
|
+
field.string({ key: 'firefly_id', label: 'finance.bill.fields.firefly_id', rules: { max: 32 } }),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type FieldMeta, type FieldItem, type StaticEl } from '@coffer-org/sdk/fields';
|
|
2
|
+
export interface IbanOpts {
|
|
3
|
+
key?: string;
|
|
4
|
+
value?: unknown;
|
|
5
|
+
label?: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
multiple?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function iban(o: IbanOpts & {
|
|
10
|
+
key: string;
|
|
11
|
+
}): FieldItem;
|
|
12
|
+
export declare function iban(o: IbanOpts & {
|
|
13
|
+
value: unknown;
|
|
14
|
+
key?: undefined;
|
|
15
|
+
}): StaticEl;
|
|
16
|
+
export declare function iban(o: IbanOpts & {
|
|
17
|
+
key?: undefined;
|
|
18
|
+
value?: undefined;
|
|
19
|
+
}): FieldMeta;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { vmsg, optionalize, applyMultiple, wrapKey } from '@coffer-org/sdk/fields';
|
|
3
|
+
const IBAN_RE = /^[A-Za-z]{2}\d{2}[A-Za-z0-9]{11,30}$/;
|
|
4
|
+
function ibanMod97(v) {
|
|
5
|
+
const s = v.toUpperCase().replace(/\s+/g, '');
|
|
6
|
+
const rearranged = s.slice(4) + s.slice(0, 4);
|
|
7
|
+
let digits = '';
|
|
8
|
+
for (const ch of rearranged) {
|
|
9
|
+
const code = ch.charCodeAt(0);
|
|
10
|
+
if (code >= 65 && code <= 90)
|
|
11
|
+
digits += (code - 55).toString();
|
|
12
|
+
else
|
|
13
|
+
digits += ch;
|
|
14
|
+
}
|
|
15
|
+
let rem = 0;
|
|
16
|
+
for (const d of digits)
|
|
17
|
+
rem = (rem * 10 + (d.charCodeAt(0) - 48)) % 97;
|
|
18
|
+
return rem === 1;
|
|
19
|
+
}
|
|
20
|
+
export function iban(o) {
|
|
21
|
+
const required = o.required ?? false;
|
|
22
|
+
const s = z
|
|
23
|
+
.string()
|
|
24
|
+
.regex(IBAN_RE, { message: vmsg('pattern', { messageKey: 'core.presets.iban' }) })
|
|
25
|
+
.refine(ibanMod97, { message: vmsg('pattern', { messageKey: 'core.presets.iban' }) });
|
|
26
|
+
const base = {
|
|
27
|
+
kind: 'text',
|
|
28
|
+
label: o.label ?? '',
|
|
29
|
+
required,
|
|
30
|
+
prim: 'text',
|
|
31
|
+
column: 'text',
|
|
32
|
+
hints: {},
|
|
33
|
+
zod: optionalize(s, required),
|
|
34
|
+
};
|
|
35
|
+
return wrapKey(o, applyMultiple(base, o.multiple ?? false));
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineVault } from '@coffer-org/sdk/vault';
|
|
2
|
+
import { definePlugin } from '@coffer-org/sdk/plugin';
|
|
3
|
+
import { field } from '@coffer-org/sdk/fields';
|
|
4
|
+
import { defineSettings } from '@coffer-org/sdk/settings';
|
|
5
|
+
import account from "./account/index.js";
|
|
6
|
+
import transaction from "./transaction/index.js";
|
|
7
|
+
import bill from "./bill/index.js";
|
|
8
|
+
import itemFinancing from "./item_financing/index.js";
|
|
9
|
+
import { iban } from "./fields/iban.js";
|
|
10
|
+
export { iban } from "./fields/iban.js";
|
|
11
|
+
export const fin = { iban };
|
|
12
|
+
export default definePlugin({
|
|
13
|
+
id: 'finance',
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
dependsOn: ['things', 'people'],
|
|
16
|
+
vaults: [
|
|
17
|
+
{
|
|
18
|
+
meta: defineVault({ id: 'finance', label: 'finance.vault.label', icon: 'lucide:wallet' }),
|
|
19
|
+
modules: [account, transaction, bill],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
extends_: [itemFinancing],
|
|
23
|
+
settings: defineSettings({
|
|
24
|
+
label: 'finance.settings.label',
|
|
25
|
+
fields: [
|
|
26
|
+
field.string({ key: 'firefly_url', label: 'finance.settings.firefly_url', required: true }),
|
|
27
|
+
field.password({ key: 'token', label: 'finance.settings.token', required: true }),
|
|
28
|
+
field.button({ label: 'finance.settings.test_button', value: 'finance.testConnection', icon: 'lucide:wifi' }),
|
|
29
|
+
field.button({ label: 'finance.settings.sync_button', value: 'finance.sync', icon: 'lucide:refresh-cw' }),
|
|
30
|
+
field.file({ key: 'wise_csv', label: 'finance.settings.wise_csv' }),
|
|
31
|
+
field.relation({ key: 'wise_account', label: 'finance.settings.wise_account', options: { vault: 'finance', module: 'account' } }),
|
|
32
|
+
field.button({ label: 'finance.settings.wise_import_button', value: 'finance.importWise', icon: 'lucide:upload' }),
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineExtend } from '@coffer-org/sdk/extend';
|
|
2
|
+
import { field } from '@coffer-org/sdk/fields';
|
|
3
|
+
export default defineExtend({
|
|
4
|
+
id: 'item_financing',
|
|
5
|
+
label: 'finance.item_financing.label',
|
|
6
|
+
icon: 'lucide:credit-card',
|
|
7
|
+
attachTo: [{ vault: 'things', module: 'item' }],
|
|
8
|
+
showWhen: { paymentStatus: { $in: ['installment', 'credit'] } },
|
|
9
|
+
claude: `Installment/credit details for an item. creditor — who is owed (bank/store).
|
|
10
|
+
holder — which person the debt is registered to (relation people/person).
|
|
11
|
+
total — full debt amount. monthly — monthly payment. termMonths/paidMonths — schedule.
|
|
12
|
+
remaining — how much is left (manual). nextPayment — payment reminder. paidOff — settled.`,
|
|
13
|
+
fields: [
|
|
14
|
+
field.string({ key: 'creditor', label: 'finance.item_financing.fields.creditor', rules: { max: 120 } }),
|
|
15
|
+
field.relation({ key: 'holder', label: 'finance.item_financing.fields.holder', options: { vault: 'people', module: 'person' } }),
|
|
16
|
+
field.sheet({
|
|
17
|
+
fields: [
|
|
18
|
+
[
|
|
19
|
+
field.money({ key: 'total', label: 'finance.item_financing.fields.total', rules: { defaultCurrency: 'UAH' } }),
|
|
20
|
+
field.money({ key: 'downPayment', label: 'finance.item_financing.fields.downPayment', rules: { defaultCurrency: 'UAH' } }),
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
field.money({ key: 'monthly', label: 'finance.item_financing.fields.monthly', rules: { defaultCurrency: 'UAH' } }),
|
|
24
|
+
field.money({ key: 'remaining', label: 'finance.item_financing.fields.remaining', rules: { defaultCurrency: 'UAH' } }),
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
field.int({ key: 'termMonths', label: 'finance.item_financing.fields.termMonths', rules: { min: 0 } }),
|
|
28
|
+
field.int({ key: 'paidMonths', label: 'finance.item_financing.fields.paidMonths', rules: { min: 0 } }),
|
|
29
|
+
field.real({ key: 'rate', label: 'finance.item_financing.fields.rate', rules: { min: 0 } }),
|
|
30
|
+
],
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
field.date({ key: 'startDate', label: 'finance.item_financing.fields.startDate' }),
|
|
34
|
+
field.reminder({ key: 'nextPayment', label: 'finance.item_financing.fields.nextPayment', rules: { lead: 3 } }),
|
|
35
|
+
field.boolean({ key: 'paidOff', label: 'finance.item_financing.fields.paidOff' }),
|
|
36
|
+
],
|
|
37
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function checkFireflyConnection(url, token, fetchFn = fetch) {
|
|
2
|
+
const endpoint = `${url.replace(/\/$/, '')}/api/v1/about`;
|
|
3
|
+
try {
|
|
4
|
+
const resp = await fetchFn(endpoint, {
|
|
5
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
6
|
+
});
|
|
7
|
+
if (resp.ok)
|
|
8
|
+
return { ok: true };
|
|
9
|
+
return { ok: false, error: `Firefly returned ${resp.status}` };
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return { ok: false, error: e.message };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface FireflyCreds {
|
|
2
|
+
url: string;
|
|
3
|
+
token: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FireflyItem<A> {
|
|
6
|
+
id: string;
|
|
7
|
+
attributes: A;
|
|
8
|
+
}
|
|
9
|
+
export interface AccountAttr {
|
|
10
|
+
name: string;
|
|
11
|
+
type: string;
|
|
12
|
+
account_role?: string | null;
|
|
13
|
+
currency_code?: string | null;
|
|
14
|
+
current_balance?: string | null;
|
|
15
|
+
iban?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface TxnSplit {
|
|
18
|
+
transaction_journal_id?: string;
|
|
19
|
+
date: string;
|
|
20
|
+
amount: string;
|
|
21
|
+
type: string;
|
|
22
|
+
description: string;
|
|
23
|
+
source_id?: string | null;
|
|
24
|
+
destination_id?: string | null;
|
|
25
|
+
category_name?: string | null;
|
|
26
|
+
currency_code?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface TxnAttr {
|
|
29
|
+
transactions: TxnSplit[];
|
|
30
|
+
}
|
|
31
|
+
export interface BillAttr {
|
|
32
|
+
name: string;
|
|
33
|
+
amount_min?: string | null;
|
|
34
|
+
amount_max?: string | null;
|
|
35
|
+
repeat_freq?: string | null;
|
|
36
|
+
next_expected_match?: string | null;
|
|
37
|
+
active?: boolean | null;
|
|
38
|
+
currency_code?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export declare function fetchAccounts(creds: FireflyCreds, fetchFn?: typeof fetch): Promise<FireflyItem<AccountAttr>[]>;
|
|
41
|
+
export declare function fetchTransactions(creds: FireflyCreds, start: string, end: string, fetchFn?: typeof fetch): Promise<FireflyItem<TxnAttr>[]>;
|
|
42
|
+
export declare function fetchBills(creds: FireflyCreds, fetchFn?: typeof fetch): Promise<FireflyItem<BillAttr>[]>;
|
|
43
|
+
export declare function createTransaction(creds: FireflyCreds, split: TxnSplit, fetchFn?: typeof fetch): Promise<{
|
|
44
|
+
ok: true;
|
|
45
|
+
} | {
|
|
46
|
+
skip: true;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
error: string;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
async function fetchAllPages(creds, path, fetchFn) {
|
|
2
|
+
const base = `${creds.url.replace(/\/$/, '')}${path}`;
|
|
3
|
+
const out = [];
|
|
4
|
+
let page = 1;
|
|
5
|
+
let totalPages = 1;
|
|
6
|
+
do {
|
|
7
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
8
|
+
const resp = await fetchFn(`${base}${sep}page=${page}&limit=100`, {
|
|
9
|
+
headers: { Authorization: `Bearer ${creds.token}`, Accept: 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
if (!resp.ok)
|
|
12
|
+
throw new Error(`Firefly ${path} returned ${resp.status}`);
|
|
13
|
+
const body = (await resp.json());
|
|
14
|
+
out.push(...body.data);
|
|
15
|
+
totalPages = body.meta?.pagination?.total_pages ?? 1;
|
|
16
|
+
page += 1;
|
|
17
|
+
} while (page <= totalPages);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export function fetchAccounts(creds, fetchFn = fetch) {
|
|
21
|
+
return fetchAllPages(creds, '/api/v1/accounts', fetchFn);
|
|
22
|
+
}
|
|
23
|
+
export function fetchTransactions(creds, start, end, fetchFn = fetch) {
|
|
24
|
+
return fetchAllPages(creds, `/api/v1/transactions?start=${start}&end=${end}`, fetchFn);
|
|
25
|
+
}
|
|
26
|
+
export function fetchBills(creds, fetchFn = fetch) {
|
|
27
|
+
return fetchAllPages(creds, '/api/v1/bills', fetchFn);
|
|
28
|
+
}
|
|
29
|
+
const MAX_RETRIES = 3;
|
|
30
|
+
const RETRY_DELAY_MS = 2000;
|
|
31
|
+
export async function createTransaction(creds, split, fetchFn = fetch) {
|
|
32
|
+
const url = `${creds.url.replace(/\/$/, '')}/api/v1/transactions`;
|
|
33
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetchFn(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${creds.token}`,
|
|
39
|
+
Accept: 'application/json',
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({ transactions: [split] }),
|
|
43
|
+
});
|
|
44
|
+
if (res.status === 200 || res.status === 201)
|
|
45
|
+
return { ok: true };
|
|
46
|
+
if (res.status === 422)
|
|
47
|
+
return { skip: true };
|
|
48
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
49
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * 2 ** attempt));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const txt = await res.text().catch(() => '');
|
|
53
|
+
return { ok: false, error: `Firefly ${res.status}: ${txt.slice(0, 100)}` };
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
57
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * 2 ** attempt));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
return { ok: false, error: e.message.slice(0, 100) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { ok: false, error: 'Max retries reached' };
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
|
|
4
|
+
import { moduleTableName } from '@coffer-org/server/entity-schema';
|
|
5
|
+
import { listRecords } from '@coffer-org/server/mutate';
|
|
6
|
+
import { uploadsDir } from '@coffer-org/server/uploads';
|
|
7
|
+
import { createTransaction } from "./firefly.js";
|
|
8
|
+
import { parseWiseCsv, buildSplits } from "./wise.js";
|
|
9
|
+
import { runSync } from "./sync.js";
|
|
10
|
+
const VAULT = 'finance';
|
|
11
|
+
export async function importWise(fetchFn = fetch) {
|
|
12
|
+
const settings = await getPluginSettings(VAULT);
|
|
13
|
+
const url = settings['firefly_url'];
|
|
14
|
+
const token = settings['token'];
|
|
15
|
+
if (!url || !token)
|
|
16
|
+
throw new Error('Finance settings not configured. Save Firefly URL and token first.');
|
|
17
|
+
const csv = settings['wise_csv'];
|
|
18
|
+
if (!csv?.name)
|
|
19
|
+
throw new Error('No Wise CSV uploaded. Pick a file in finance settings first.');
|
|
20
|
+
const accId = settings['wise_account'];
|
|
21
|
+
if (accId == null)
|
|
22
|
+
throw new Error('No target account selected.');
|
|
23
|
+
const accounts = (await listRecords(moduleTableName(VAULT, 'account')));
|
|
24
|
+
const acc = accounts.find((r) => String(r['id']) === String(accId));
|
|
25
|
+
const fireflyAccountId = acc?.['firefly_id'];
|
|
26
|
+
if (typeof fireflyAccountId !== 'string' || !fireflyAccountId) {
|
|
27
|
+
throw new Error('Selected account has no firefly_id — run a sync first.');
|
|
28
|
+
}
|
|
29
|
+
const content = readFileSync(join(uploadsDir(), csv.name), 'utf-8');
|
|
30
|
+
const { splits, skipped: convSkipped } = buildSplits(parseWiseCsv(content), fireflyAccountId);
|
|
31
|
+
const creds = { url, token };
|
|
32
|
+
let imported = 0;
|
|
33
|
+
let skipped = convSkipped;
|
|
34
|
+
const errors = [];
|
|
35
|
+
for (const split of splits) {
|
|
36
|
+
const r = await createTransaction(creds, split, fetchFn);
|
|
37
|
+
if ('ok' in r && r.ok)
|
|
38
|
+
imported++;
|
|
39
|
+
else if ('skip' in r)
|
|
40
|
+
skipped++;
|
|
41
|
+
else
|
|
42
|
+
errors.push(r.error);
|
|
43
|
+
}
|
|
44
|
+
await runSync(fetchFn);
|
|
45
|
+
return { imported, skipped, errors };
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
|
|
2
|
+
export { checkFireflyConnection } from './connection.ts';
|
|
3
|
+
export { runSync } from './sync.ts';
|
|
4
|
+
export { importWise } from './import-wise.ts';
|
|
5
|
+
export declare function startSync(): Promise<void>;
|
|
6
|
+
export declare function stopSync(): void;
|
|
7
|
+
export declare const serverHooks: PluginHooks;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { runSync } from "./sync.js";
|
|
2
|
+
export { checkFireflyConnection } from "./connection.js";
|
|
3
|
+
export { runSync } from "./sync.js";
|
|
4
|
+
export { importWise } from "./import-wise.js";
|
|
5
|
+
export async function startSync() {
|
|
6
|
+
const r = await runSync();
|
|
7
|
+
console.log(`[finance] sync: ${r.accounts} accounts, ${r.transactions} transactions, ${r.bills} bills`);
|
|
8
|
+
}
|
|
9
|
+
export function stopSync() { }
|
|
10
|
+
export const serverHooks = {
|
|
11
|
+
init: () => {
|
|
12
|
+
void startSync().catch((e) => console.warn(`[finance] sync failed to start: ${e.message}`));
|
|
13
|
+
},
|
|
14
|
+
teardown: () => {
|
|
15
|
+
stopSync();
|
|
16
|
+
},
|
|
17
|
+
agent: {
|
|
18
|
+
instructions: 'Vault finance — accounts (account), bills to pay (bill), transactions (transaction). ' +
|
|
19
|
+
'account.current_balance — current balance in account.currency. transaction: amount + kind ' +
|
|
20
|
+
'(withdrawal/deposit/transfer), date (ISO), category. bill.next_expected — next payment date. ' +
|
|
21
|
+
'Data is mirrored from Firefly (firefly_id).' +
|
|
22
|
+
' Do NOT edit current_balance manually — it is mirrored from Firefly; new operations live as transaction.',
|
|
23
|
+
tools: [
|
|
24
|
+
{
|
|
25
|
+
name: 'account_balances',
|
|
26
|
+
description: 'Current balances of all finance accounts.',
|
|
27
|
+
inputSchema: {},
|
|
28
|
+
handler: async (_args, { em }) => {
|
|
29
|
+
const rows = (await em.fork().find('finance__account', {}));
|
|
30
|
+
return rows.map((r) => ({ id: r.id, name: r.name, kind: r.kind, currency: r.currency, current_balance: r.current_balance }));
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AccountAttr, BillAttr, FireflyItem, TxnAttr } from './firefly.ts';
|
|
2
|
+
export interface CofferRecord {
|
|
3
|
+
firefly_id: string;
|
|
4
|
+
[k: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export declare function mapAccount(item: FireflyItem<AccountAttr>): CofferRecord;
|
|
7
|
+
export declare function mapBill(item: FireflyItem<BillAttr>): CofferRecord;
|
|
8
|
+
export declare function mapTransactionSplits(item: FireflyItem<TxnAttr>, accIdByFireflyId: Map<string, number>): CofferRecord[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function money(amount, currency) {
|
|
2
|
+
if (amount == null || amount === '')
|
|
3
|
+
return null;
|
|
4
|
+
const value = Number(amount);
|
|
5
|
+
if (Number.isNaN(value))
|
|
6
|
+
return null;
|
|
7
|
+
return { value, currency: currency ?? 'EUR' };
|
|
8
|
+
}
|
|
9
|
+
function accountKind(type, role) {
|
|
10
|
+
const t = (type || '').toLowerCase();
|
|
11
|
+
if (t === 'asset')
|
|
12
|
+
return role === 'cashWalletAsset' ? 'cash' : 'asset';
|
|
13
|
+
if (t === 'expense')
|
|
14
|
+
return 'expense';
|
|
15
|
+
if (t === 'revenue')
|
|
16
|
+
return 'revenue';
|
|
17
|
+
if (t === 'cash')
|
|
18
|
+
return 'cash';
|
|
19
|
+
if (['liability', 'loan', 'debt', 'mortgage'].includes(t))
|
|
20
|
+
return 'liability';
|
|
21
|
+
return 'asset';
|
|
22
|
+
}
|
|
23
|
+
function billRepeat(freq) {
|
|
24
|
+
const f = (freq || '').toLowerCase();
|
|
25
|
+
if (['weekly', 'monthly', 'quarterly', 'yearly'].includes(f))
|
|
26
|
+
return f;
|
|
27
|
+
return 'monthly';
|
|
28
|
+
}
|
|
29
|
+
const TXN_KINDS = new Set(['withdrawal', 'deposit', 'transfer']);
|
|
30
|
+
function txnKind(type) {
|
|
31
|
+
const t = (type || '').toLowerCase();
|
|
32
|
+
return TXN_KINDS.has(t) ? t : 'withdrawal';
|
|
33
|
+
}
|
|
34
|
+
export function mapAccount(item) {
|
|
35
|
+
const a = item.attributes;
|
|
36
|
+
return {
|
|
37
|
+
firefly_id: item.id,
|
|
38
|
+
name: a.name,
|
|
39
|
+
kind: accountKind(a.type, a.account_role),
|
|
40
|
+
currency: a.currency_code ?? '',
|
|
41
|
+
current_balance: money(a.current_balance, a.currency_code),
|
|
42
|
+
iban: a.iban ?? '',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function mapBill(item) {
|
|
46
|
+
const a = item.attributes;
|
|
47
|
+
return {
|
|
48
|
+
firefly_id: item.id,
|
|
49
|
+
name: a.name,
|
|
50
|
+
amount_min: money(a.amount_min, a.currency_code),
|
|
51
|
+
amount_max: money(a.amount_max, a.currency_code),
|
|
52
|
+
repeat: billRepeat(a.repeat_freq),
|
|
53
|
+
next_expected: a.next_expected_match ?? null,
|
|
54
|
+
active: a.active ?? false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function mapTransactionSplits(item, accIdByFireflyId) {
|
|
58
|
+
return item.attributes.transactions.map((s, i) => ({
|
|
59
|
+
firefly_id: s.transaction_journal_id ?? `${item.id}-${i}`,
|
|
60
|
+
description: s.description,
|
|
61
|
+
date: s.date.slice(0, 10),
|
|
62
|
+
amount: money(s.amount, s.currency_code),
|
|
63
|
+
kind: txnKind(s.type),
|
|
64
|
+
source: s.source_id != null ? (accIdByFireflyId.get(s.source_id) ?? null) : null,
|
|
65
|
+
destination: s.destination_id != null ? (accIdByFireflyId.get(s.destination_id) ?? null) : null,
|
|
66
|
+
category: s.category_name ?? '',
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
|
|
2
|
+
import { getModule } from '@coffer-org/server/registry-context';
|
|
3
|
+
import { moduleTableName } from '@coffer-org/server/entity-schema';
|
|
4
|
+
import { listRecords } from '@coffer-org/server/mutate';
|
|
5
|
+
import { localPost, localPatch } from '@coffer-org/server/local-api';
|
|
6
|
+
import { fetchAccounts, fetchBills, fetchTransactions } from "./firefly.js";
|
|
7
|
+
import { mapAccount, mapBill, mapTransactionSplits } from "./mapping.js";
|
|
8
|
+
const VAULT = 'finance';
|
|
9
|
+
async function existingMap(type) {
|
|
10
|
+
const ename = moduleTableName(VAULT, type);
|
|
11
|
+
const rows = (await listRecords(ename));
|
|
12
|
+
const m = new Map();
|
|
13
|
+
for (const r of rows) {
|
|
14
|
+
const fid = r['firefly_id'];
|
|
15
|
+
if (typeof fid === 'string')
|
|
16
|
+
m.set(fid, r['id']);
|
|
17
|
+
}
|
|
18
|
+
return m;
|
|
19
|
+
}
|
|
20
|
+
async function upsertAll(type, recs) {
|
|
21
|
+
const map = await existingMap(type);
|
|
22
|
+
for (const rec of recs) {
|
|
23
|
+
const existingId = map.get(rec.firefly_id);
|
|
24
|
+
if (existingId != null) {
|
|
25
|
+
await localPatch(VAULT, type, existingId, rec);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const row = await localPost(VAULT, type, rec);
|
|
29
|
+
map.set(rec.firefly_id, row['id']);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
function lastYearWindow(today) {
|
|
35
|
+
const [y, m, d] = today.split('-');
|
|
36
|
+
return { start: `${Number(y) - 1}-${m}-${d}`, end: today };
|
|
37
|
+
}
|
|
38
|
+
export async function runSync(fetchFn = fetch) {
|
|
39
|
+
const settings = await getPluginSettings('finance');
|
|
40
|
+
const url = settings['firefly_url'];
|
|
41
|
+
const token = settings['token'];
|
|
42
|
+
if (!url || !token)
|
|
43
|
+
throw new Error('Finance settings not configured. Save Firefly URL and token first.');
|
|
44
|
+
if (!getModule(VAULT, 'account'))
|
|
45
|
+
throw new Error('finance vault not registered');
|
|
46
|
+
const creds = { url, token };
|
|
47
|
+
const accounts = await fetchAccounts(creds, fetchFn);
|
|
48
|
+
const accMap = await upsertAll('account', accounts.map(mapAccount));
|
|
49
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
50
|
+
const { start, end } = lastYearWindow(today);
|
|
51
|
+
const txns = await fetchTransactions(creds, start, end, fetchFn);
|
|
52
|
+
const txnRecs = txns.flatMap((t) => mapTransactionSplits(t, accMap));
|
|
53
|
+
await upsertAll('transaction', txnRecs);
|
|
54
|
+
const bills = await fetchBills(creds, fetchFn);
|
|
55
|
+
await upsertAll('bill', bills.map(mapBill));
|
|
56
|
+
return { accounts: accounts.length, transactions: txnRecs.length, bills: bills.length };
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TxnSplit } from './firefly.ts';
|
|
2
|
+
export type WiseRow = Record<string, string>;
|
|
3
|
+
export declare function getCategory(merchant: string, description: string): string;
|
|
4
|
+
export declare function parseWiseCsv(content: string): WiseRow[];
|
|
5
|
+
export declare function buildSplits(rows: WiseRow[], fireflyAccountId: string): {
|
|
6
|
+
splits: TxnSplit[];
|
|
7
|
+
skipped: number;
|
|
8
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const MERCHANT_CATEGORIES = {
|
|
2
|
+
Anthropic: 'ПО и подписки',
|
|
3
|
+
Hetzner: 'ПО и подписки',
|
|
4
|
+
Booking: 'Путешествия',
|
|
5
|
+
Kiwi: 'Путешествия',
|
|
6
|
+
Aliexpress: 'Покупки',
|
|
7
|
+
Temu: 'Покупки',
|
|
8
|
+
Banggood: 'Покупки',
|
|
9
|
+
Metro: 'Продукты',
|
|
10
|
+
Floreni: 'Продукты',
|
|
11
|
+
Rompetrol: 'Автомобиль',
|
|
12
|
+
'La Placinte': 'Продукты',
|
|
13
|
+
'Maib Farm': 'Продукты',
|
|
14
|
+
'Www Letz Md': 'Покупки',
|
|
15
|
+
'Alina Cosmetics': 'Косметика',
|
|
16
|
+
};
|
|
17
|
+
export function getCategory(merchant, description) {
|
|
18
|
+
const m = merchant.toLowerCase();
|
|
19
|
+
const d = description.toLowerCase();
|
|
20
|
+
for (const [key, category] of Object.entries(MERCHANT_CATEGORIES)) {
|
|
21
|
+
const k = key.toLowerCase();
|
|
22
|
+
if (m.includes(k) || d.includes(k))
|
|
23
|
+
return category;
|
|
24
|
+
}
|
|
25
|
+
if (description.includes('Card transaction'))
|
|
26
|
+
return 'Другое';
|
|
27
|
+
if (description.includes('Converted'))
|
|
28
|
+
return 'Конвертация';
|
|
29
|
+
if (description.includes('Sent money') || description.includes('Received'))
|
|
30
|
+
return 'Переводы';
|
|
31
|
+
return 'Другое';
|
|
32
|
+
}
|
|
33
|
+
export function parseWiseCsv(content) {
|
|
34
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
35
|
+
if (!lines.length)
|
|
36
|
+
return [];
|
|
37
|
+
const header = (lines[0] ?? '').split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
|
38
|
+
const rows = [];
|
|
39
|
+
for (const line of lines.slice(1)) {
|
|
40
|
+
if (!line.trim())
|
|
41
|
+
continue;
|
|
42
|
+
const cells = [];
|
|
43
|
+
let current = '';
|
|
44
|
+
let inQuote = false;
|
|
45
|
+
for (const ch of line) {
|
|
46
|
+
if (ch === '"')
|
|
47
|
+
inQuote = !inQuote;
|
|
48
|
+
else if (ch === ',' && !inQuote) {
|
|
49
|
+
cells.push(current.trim());
|
|
50
|
+
current = '';
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
current += ch;
|
|
54
|
+
}
|
|
55
|
+
cells.push(current.trim());
|
|
56
|
+
if (cells.length !== header.length)
|
|
57
|
+
continue;
|
|
58
|
+
const row = {};
|
|
59
|
+
header.forEach((h, i) => {
|
|
60
|
+
row[h] = cells[i] || '';
|
|
61
|
+
});
|
|
62
|
+
if (!row['Date'] || !row['Amount'])
|
|
63
|
+
continue;
|
|
64
|
+
rows.push(row);
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
|
68
|
+
function convertDate(dateStr) {
|
|
69
|
+
const m = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})$/);
|
|
70
|
+
return m ? `${m[3]}-${m[2]}-${m[1]}` : dateStr;
|
|
71
|
+
}
|
|
72
|
+
export function buildSplits(rows, fireflyAccountId) {
|
|
73
|
+
const main = {};
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const id = row['TransferWise ID'] || '';
|
|
76
|
+
if (id.startsWith('FEE-'))
|
|
77
|
+
continue;
|
|
78
|
+
main[id || `${row['Date']}|${row['Amount']}`] = row;
|
|
79
|
+
}
|
|
80
|
+
const splits = [];
|
|
81
|
+
let skipped = 0;
|
|
82
|
+
for (const row of Object.values(main)) {
|
|
83
|
+
const raw = parseFloat(row['Amount'] || '0');
|
|
84
|
+
const type = (row['Transaction Type'] || '').toUpperCase();
|
|
85
|
+
if (type === 'CONVERSION') {
|
|
86
|
+
skipped++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const amount = Math.abs(raw);
|
|
90
|
+
const kind = raw > 0 ? 'deposit' : 'withdrawal';
|
|
91
|
+
const description = (row['Description'] || '').trim();
|
|
92
|
+
const merchant = (row['Merchant'] || '').trim();
|
|
93
|
+
const split = {
|
|
94
|
+
type: kind,
|
|
95
|
+
date: convertDate(row['Date'] || ''),
|
|
96
|
+
amount: String(amount),
|
|
97
|
+
description,
|
|
98
|
+
category_name: getCategory(merchant, description),
|
|
99
|
+
};
|
|
100
|
+
if (kind === 'withdrawal')
|
|
101
|
+
split.source_id = fireflyAccountId;
|
|
102
|
+
else
|
|
103
|
+
split.destination_id = fireflyAccountId;
|
|
104
|
+
splits.push(split);
|
|
105
|
+
}
|
|
106
|
+
return { splits, skipped };
|
|
107
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineModule } from '@coffer-org/sdk/module';
|
|
2
|
+
import { field } from '@coffer-org/sdk/fields';
|
|
3
|
+
export default defineModule({
|
|
4
|
+
module: 'transaction',
|
|
5
|
+
vault: 'finance',
|
|
6
|
+
label: 'finance.transaction.label',
|
|
7
|
+
icon: 'lucide:arrow-left-right',
|
|
8
|
+
claude: `Firefly III transaction (read-only). Each split = a separate record.
|
|
9
|
+
source/destination — accounts. firefly_id — split journal id (do not edit).`,
|
|
10
|
+
views: {
|
|
11
|
+
table: ['date', 'amount', 'kind', 'description'],
|
|
12
|
+
},
|
|
13
|
+
fields: [
|
|
14
|
+
field.title({ key: 'description', label: 'finance.transaction.fields.description' }),
|
|
15
|
+
field.sheet({
|
|
16
|
+
fields: [
|
|
17
|
+
[
|
|
18
|
+
field.date({ key: 'date', label: 'finance.transaction.fields.date' }),
|
|
19
|
+
field.money({ key: 'amount', label: 'finance.transaction.fields.amount' }),
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
field.select({
|
|
23
|
+
key: 'kind',
|
|
24
|
+
label: 'finance.transaction.fields.kind',
|
|
25
|
+
options: [
|
|
26
|
+
{ value: 'withdrawal', title: 'finance.transaction.options.withdrawal' },
|
|
27
|
+
{ value: 'deposit', title: 'finance.transaction.options.deposit' },
|
|
28
|
+
{ value: 'transfer', title: 'finance.transaction.options.transfer' },
|
|
29
|
+
],
|
|
30
|
+
}),
|
|
31
|
+
field.string({ key: 'category', label: 'finance.transaction.fields.category', rules: { max: 100 } }),
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
field.relation({ key: 'source', label: 'finance.transaction.fields.source', options: { vault: 'finance', module: 'account' } }),
|
|
35
|
+
field.relation({ key: 'destination', label: 'finance.transaction.fields.destination', options: { vault: 'finance', module: 'account' } }),
|
|
36
|
+
],
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
field.string({ key: 'firefly_id', label: 'finance.transaction.fields.firefly_id', rules: { max: 32 } }),
|
|
40
|
+
],
|
|
41
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coffer-org/plugin-finance",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./runtime": {
|
|
17
|
+
"types": "./dist/runtime/index.d.ts",
|
|
18
|
+
"default": "./dist/runtime/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -b tsconfig.build.json",
|
|
23
|
+
"prepack": "npm run build && node ../../scripts/swap-exports.mjs dist",
|
|
24
|
+
"postpack": "node ../../scripts/swap-exports.mjs src",
|
|
25
|
+
"test": "node --import tsx/esm --test 'src/runtime/*.test.ts'"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@coffer-org/sdk": "^1.2.0",
|
|
29
|
+
"@coffer-org/server": "^1.2.0",
|
|
30
|
+
"zod": "^4.4.3"
|
|
31
|
+
}
|
|
32
|
+
}
|