@things-factory/accounting 10.1.139 → 10.1.141

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.
Files changed (44) hide show
  1. package/dist-server/service/account/account-match.d.ts +32 -0
  2. package/dist-server/service/account/account-match.js +74 -0
  3. package/dist-server/service/account/account-match.js.map +1 -0
  4. package/dist-server/service/bank-statement/bank-import-mutation.d.ts +5 -0
  5. package/dist-server/service/bank-statement/bank-import-mutation.js +43 -0
  6. package/dist-server/service/bank-statement/bank-import-mutation.js.map +1 -0
  7. package/dist-server/service/bank-statement/bank-import-type.d.ts +31 -0
  8. package/dist-server/service/bank-statement/bank-import-type.js +122 -0
  9. package/dist-server/service/bank-statement/bank-import-type.js.map +1 -0
  10. package/dist-server/service/bank-statement/bank-import.d.ts +16 -0
  11. package/dist-server/service/bank-statement/bank-import.js +141 -0
  12. package/dist-server/service/bank-statement/bank-import.js.map +1 -0
  13. package/dist-server/service/bank-statement/bank-parser.d.ts +35 -0
  14. package/dist-server/service/bank-statement/bank-parser.js +89 -0
  15. package/dist-server/service/bank-statement/bank-parser.js.map +1 -0
  16. package/dist-server/service/bank-statement/bank-reconcile.d.ts +35 -0
  17. package/dist-server/service/bank-statement/bank-reconcile.js +71 -0
  18. package/dist-server/service/bank-statement/bank-reconcile.js.map +1 -0
  19. package/dist-server/service/bank-statement/bank-source.d.ts +25 -0
  20. package/dist-server/service/bank-statement/bank-source.js +55 -0
  21. package/dist-server/service/bank-statement/bank-source.js.map +1 -0
  22. package/dist-server/service/bank-statement/index.d.ts +2 -1
  23. package/dist-server/service/bank-statement/index.js +2 -1
  24. package/dist-server/service/bank-statement/index.js.map +1 -1
  25. package/dist-server/service/index.d.ts +8 -1
  26. package/dist-server/service/index.js +7 -0
  27. package/dist-server/service/index.js.map +1 -1
  28. package/dist-server/service/payment/document-targets.d.ts +16 -0
  29. package/dist-server/service/payment/document-targets.js +99 -0
  30. package/dist-server/service/payment/document-targets.js.map +1 -0
  31. package/dist-server/service/payment/payment-service.js +3 -0
  32. package/dist-server/service/payment/payment-service.js.map +1 -1
  33. package/dist-server/service/payment/payment-targets.d.ts +12 -0
  34. package/dist-server/service/payment/payment-targets.js +9 -0
  35. package/dist-server/service/payment/payment-targets.js.map +1 -1
  36. package/dist-server/service/transaction/transaction-query.js +13 -1
  37. package/dist-server/service/transaction/transaction-query.js.map +1 -1
  38. package/dist-server/service/transaction/transaction-service.d.ts +5 -0
  39. package/dist-server/service/transaction/transaction-service.js +24 -0
  40. package/dist-server/service/transaction/transaction-service.js.map +1 -1
  41. package/dist-server/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +12 -7
  43. package/translations/en.json +24 -1
  44. package/translations/ko.json +24 -1
@@ -0,0 +1,32 @@
1
+ import { EntityManager } from 'typeorm';
2
+ export interface AccountForMatch {
3
+ id: string;
4
+ name: string;
5
+ type: string;
6
+ aliases?: string | null;
7
+ }
8
+ /** An account's name and its aliases (one per line, or comma separated). */
9
+ export declare function aliasesOf(account: Pick<AccountForMatch, 'name' | 'aliases'>): string[];
10
+ /**
11
+ * A function telling which account some words mean. `type` limits it to that direction - an expense is never filed
12
+ * under an income account because a word happens to match.
13
+ */
14
+ export declare function accountMatcher<T extends AccountForMatch>(accounts: readonly T[]): (texts: readonly (string | null | undefined)[], type?: string | null) => T | undefined;
15
+ export interface AccountLinking {
16
+ /** Rows given an account, per kind: confirmed transactions, forecasts, recurring items. */
17
+ linked: {
18
+ transactions: number;
19
+ forecasts: number;
20
+ recurring: number;
21
+ };
22
+ /** The words of confirmed transactions no account matched. */
23
+ unmatched: string[];
24
+ }
25
+ /**
26
+ * Give every row without an account the one its words mean: a confirmed transaction by its description then its
27
+ * ledger category, a forecast by its description, a recurring item by its name. `apply` false writes nothing. Rows a
28
+ * person already filed are left alone.
29
+ */
30
+ export declare function linkAccounts(tx: EntityManager, domain: {
31
+ id: string;
32
+ }, match: ReturnType<typeof accountMatcher>, apply: boolean): Promise<AccountLinking>;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.aliasesOf = aliasesOf;
4
+ exports.accountMatcher = accountMatcher;
5
+ exports.linkAccounts = linkAccounts;
6
+ const typeorm_1 = require("typeorm");
7
+ const recurring_cash_item_1 = require("../recurring-cash-item/recurring-cash-item");
8
+ const transaction_1 = require("../transaction/transaction");
9
+ const key = (text) => String(text ?? '')
10
+ .normalize('NFC')
11
+ .replace(/[\s\-_.·,()]+/g, '')
12
+ .toLowerCase();
13
+ /** An account's name and its aliases (one per line, or comma separated). */
14
+ function aliasesOf(account) {
15
+ return [account.name, ...String(account.aliases ?? '').split(/[\n,]/)].map(one => one.trim()).filter(Boolean);
16
+ }
17
+ /**
18
+ * A function telling which account some words mean. `type` limits it to that direction - an expense is never filed
19
+ * under an income account because a word happens to match.
20
+ */
21
+ function accountMatcher(accounts) {
22
+ const words = [];
23
+ for (const account of accounts)
24
+ for (const alias of aliasesOf(account))
25
+ if (key(alias).length >= 2)
26
+ words.push({ word: key(alias), account });
27
+ words.sort((a, b) => b.word.length - a.word.length);
28
+ return (texts, type) => {
29
+ for (const text of texts) {
30
+ const hay = key(text);
31
+ if (!hay)
32
+ continue;
33
+ const hits = words.filter(one => (!type || one.account.type === type) && hay.includes(one.word));
34
+ if (!hits.length)
35
+ continue;
36
+ const longest = hits[0].word.length;
37
+ const top = new Set(hits.filter(one => one.word.length === longest).map(one => one.account.id));
38
+ return top.size === 1 ? hits[0].account : undefined;
39
+ }
40
+ return undefined;
41
+ };
42
+ }
43
+ /**
44
+ * Give every row without an account the one its words mean: a confirmed transaction by its description then its
45
+ * ledger category, a forecast by its description, a recurring item by its name. `apply` false writes nothing. Rows a
46
+ * person already filed are left alone.
47
+ */
48
+ async function linkAccounts(tx, domain, match, apply) {
49
+ const report = { linked: { transactions: 0, forecasts: 0, recurring: 0 }, unmatched: [] };
50
+ const run = async (label, entity, texts, typeOf, scope = {}) => {
51
+ const rows = (await tx.getRepository(entity).find({ where: { domain: { id: domain.id }, ...scope, account: (0, typeorm_1.IsNull)() } }));
52
+ const groups = new Map();
53
+ for (const row of rows) {
54
+ const account = match(texts(row), typeOf(row));
55
+ if (!account) {
56
+ if (label === 'transactions')
57
+ report.unmatched.push(String(texts(row)[0] ?? ''));
58
+ continue;
59
+ }
60
+ groups.set(account.id, [...(groups.get(account.id) ?? []), row.id]);
61
+ }
62
+ report.linked[label] = [...groups.values()].reduce((sum, ids) => sum + ids.length, 0);
63
+ if (!apply)
64
+ return;
65
+ for (const [id, ids] of groups)
66
+ for (let i = 0; i < ids.length; i += 500)
67
+ await tx.getRepository(entity).update(ids.slice(i, i + 500), { account: { id } });
68
+ };
69
+ await run('transactions', transaction_1.Transaction, row => [row.description, row.ledgerCategory], row => row.type, { category: transaction_1.TransactionCategory.CONFIRMED });
70
+ await run('forecasts', transaction_1.Transaction, row => [row.description], row => row.type, { category: transaction_1.TransactionCategory.FORECAST });
71
+ await run('recurring', recurring_cash_item_1.RecurringCashItem, row => [row.name], row => row.type);
72
+ return report;
73
+ }
74
+ //# sourceMappingURL=account-match.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-match.js","sourceRoot":"","sources":["../../../server/service/account/account-match.ts"],"names":[],"mappings":";;AAyBA,8BAEC;AAMD,wCAiBC;AAcD,oCAmCC;AAnGD,qCAA+C;AAE/C,oFAA8E;AAC9E,4DAA6E;AAe7E,MAAM,GAAG,GAAG,CAAC,IAA+B,EAAE,EAAE,CAC9C,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;KACf,SAAS,CAAC,KAAK,CAAC;KAChB,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC;KAC7B,WAAW,EAAE,CAAA;AAElB,4EAA4E;AAC5E,SAAgB,SAAS,CAAC,OAAkD;IAC1E,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;AAC/G,CAAC;AAED;;;GAGG;AACH,SAAgB,cAAc,CAA4B,QAAsB;IAC9E,MAAM,KAAK,GAAmC,EAAE,CAAA;IAChD,KAAK,MAAM,OAAO,IAAI,QAAQ;QAAE,KAAK,MAAM,KAAK,IAAI,SAAS,CAAC,OAAO,CAAC;YAAE,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAA;IAC7I,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IAEnD,OAAO,CAAC,KAA6C,EAAE,IAAoB,EAAiB,EAAE;QAC5F,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,GAAG,GAAG,GAAG,CAAC,IAAI,CAAC,CAAA;YACrB,IAAI,CAAC,GAAG;gBAAE,SAAQ;YAClB,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,GAAG,CAAC,OAAO,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;YAChG,IAAI,CAAC,IAAI,CAAC,MAAM;gBAAE,SAAQ;YAC1B,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAA;YACnC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAA;YAC/F,OAAO,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;QACrD,CAAC;QACD,OAAO,SAAS,CAAA;IAClB,CAAC,CAAA;AACH,CAAC;AASD;;;;GAIG;AACI,KAAK,UAAU,YAAY,CAChC,EAAiB,EACjB,MAAsB,EACtB,KAAwC,EACxC,KAAc;IAEd,MAAM,MAAM,GAAmB,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,CAAA;IAEzG,MAAM,GAAG,GAAG,KAAK,EACf,KAAqC,EACrC,MAAmB,EACnB,KAAgD,EAChD,MAAiC,EACjC,QAA6B,EAAE,EAC/B,EAAE;QACF,MAAM,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC,aAAa,CAAC,MAAa,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,IAAA,gBAAM,GAAE,EAAS,EAAE,CAAC,CAAQ,CAAA;QAC9I,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAA;QAC1C,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;YAC9C,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,IAAI,KAAK,KAAK,cAAc;oBAAE,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;gBAChF,SAAQ;YACV,CAAC;YACD,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;QACrE,CAAC;QACD,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAA;QACrF,IAAI,CAAC,KAAK;YAAE,OAAM;QAClB,KAAK,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,MAAM;YAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,IAAI,GAAG;gBAAE,MAAM,EAAE,CAAC,aAAa,CAAC,MAAa,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,EAAS,CAAC,CAAA;IAC7I,CAAC,CAAA;IAED,MAAM,GAAG,CAAC,cAAc,EAAE,yBAAW,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,EAAE,GAAG,CAAC,cAAc,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,iCAAmB,CAAC,SAAS,EAAE,CAAC,CAAA;IAClJ,MAAM,GAAG,CAAC,WAAW,EAAE,yBAAW,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,iCAAmB,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC1H,MAAM,GAAG,CAAC,WAAW,EAAE,uCAAiB,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IAC7E,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["import { EntityManager, IsNull } from 'typeorm'\n\nimport { RecurringCashItem } from '../recurring-cash-item/recurring-cash-item'\nimport { Transaction, TransactionCategory } from '../transaction/transaction'\n\n/*\n * Which account some words mean. An account is known by its name and its aliases; the longest one contained in the\n * text wins, among accounts of the row's direction. Two different accounts matching equally long is no answer, and\n * nothing matching is no answer - a row nobody can place stays unplaced, to be listed, not filed under a catch-all.\n */\n\nexport interface AccountForMatch {\n id: string\n name: string\n type: string\n aliases?: string | null\n}\n\nconst key = (text: string | null | undefined) =>\n String(text ?? '')\n .normalize('NFC')\n .replace(/[\\s\\-_.·,()]+/g, '')\n .toLowerCase()\n\n/** An account's name and its aliases (one per line, or comma separated). */\nexport function aliasesOf(account: Pick<AccountForMatch, 'name' | 'aliases'>): string[] {\n return [account.name, ...String(account.aliases ?? '').split(/[\\n,]/)].map(one => one.trim()).filter(Boolean)\n}\n\n/**\n * A function telling which account some words mean. `type` limits it to that direction - an expense is never filed\n * under an income account because a word happens to match.\n */\nexport function accountMatcher<T extends AccountForMatch>(accounts: readonly T[]) {\n const words: { word: string; account: T }[] = []\n for (const account of accounts) for (const alias of aliasesOf(account)) if (key(alias).length >= 2) words.push({ word: key(alias), account })\n words.sort((a, b) => b.word.length - a.word.length)\n\n return (texts: readonly (string | null | undefined)[], type?: string | null): T | undefined => {\n for (const text of texts) {\n const hay = key(text)\n if (!hay) continue\n const hits = words.filter(one => (!type || one.account.type === type) && hay.includes(one.word))\n if (!hits.length) continue\n const longest = hits[0].word.length\n const top = new Set(hits.filter(one => one.word.length === longest).map(one => one.account.id))\n return top.size === 1 ? hits[0].account : undefined\n }\n return undefined\n }\n}\n\nexport interface AccountLinking {\n /** Rows given an account, per kind: confirmed transactions, forecasts, recurring items. */\n linked: { transactions: number; forecasts: number; recurring: number }\n /** The words of confirmed transactions no account matched. */\n unmatched: string[]\n}\n\n/**\n * Give every row without an account the one its words mean: a confirmed transaction by its description then its\n * ledger category, a forecast by its description, a recurring item by its name. `apply` false writes nothing. Rows a\n * person already filed are left alone.\n */\nexport async function linkAccounts(\n tx: EntityManager,\n domain: { id: string },\n match: ReturnType<typeof accountMatcher>,\n apply: boolean\n): Promise<AccountLinking> {\n const report: AccountLinking = { linked: { transactions: 0, forecasts: 0, recurring: 0 }, unmatched: [] }\n\n const run = async <T extends { id: string }>(\n label: keyof AccountLinking['linked'],\n entity: new () => T,\n texts: (row: T) => (string | null | undefined)[],\n typeOf: (row: T) => string | null,\n scope: Record<string, any> = {}\n ) => {\n const rows = (await tx.getRepository(entity as any).find({ where: { domain: { id: domain.id }, ...scope, account: IsNull() } as any })) as T[]\n const groups = new Map<string, string[]>()\n for (const row of rows) {\n const account = match(texts(row), typeOf(row))\n if (!account) {\n if (label === 'transactions') report.unmatched.push(String(texts(row)[0] ?? ''))\n continue\n }\n groups.set(account.id, [...(groups.get(account.id) ?? []), row.id])\n }\n report.linked[label] = [...groups.values()].reduce((sum, ids) => sum + ids.length, 0)\n if (!apply) return\n for (const [id, ids] of groups)\n for (let i = 0; i < ids.length; i += 500) await tx.getRepository(entity as any).update(ids.slice(i, i + 500), { account: { id } } as any)\n }\n\n await run('transactions', Transaction, row => [row.description, row.ledgerCategory], row => row.type, { category: TransactionCategory.CONFIRMED })\n await run('forecasts', Transaction, row => [row.description], row => row.type, { category: TransactionCategory.FORECAST })\n await run('recurring', RecurringCashItem, row => [row.name], row => row.type)\n return report\n}\n"]}
@@ -0,0 +1,5 @@
1
+ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js';
2
+ import { BankImportResult, BankRowAssignment } from './bank-import-type';
3
+ export declare class BankImportMutation {
4
+ importBankStatement(file: FileUpload, preview: boolean, assignments: BankRowAssignment[] | null, context: ResolverContext): Promise<BankImportResult>;
5
+ }
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BankImportMutation = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const GraphQLUpload_js_1 = tslib_1.__importDefault(require("graphql-upload/GraphQLUpload.js"));
6
+ const type_graphql_1 = require("type-graphql");
7
+ const shell_1 = require("@things-factory/shell");
8
+ const bank_import_1 = require("./bank-import");
9
+ const bank_import_type_1 = require("./bank-import-type");
10
+ const bank_source_1 = require("./bank-source");
11
+ let BankImportMutation = class BankImportMutation {
12
+ async importBankStatement(file, preview, assignments, context) {
13
+ const { domain, user, tx } = context.state;
14
+ const upload = await file;
15
+ const chunks = [];
16
+ let size = 0;
17
+ for await (const chunk of upload.createReadStream()) {
18
+ size += chunk.length;
19
+ if (size > bank_import_1.BANK_FILE_LIMIT)
20
+ throw new shell_1.Refusal('bank-file-too-large', {}, 'Upload a bank file smaller than 10MB');
21
+ chunks.push(Buffer.from(chunk));
22
+ }
23
+ return (0, bank_import_1.importBankStatement)(tx, domain, user, { data: Buffer.concat(chunks), filename: (0, bank_source_1.bankFilename)(upload.filename) }, preview, assignments);
24
+ }
25
+ };
26
+ exports.BankImportMutation = BankImportMutation;
27
+ tslib_1.__decorate([
28
+ (0, type_graphql_1.Directive)('@transaction'),
29
+ (0, type_graphql_1.Directive)('@privilege(category: "accounting", privilege: "mutation", domainOwnerGranted: true)'),
30
+ (0, type_graphql_1.Mutation)(returns => bank_import_type_1.BankImportResult, { description: 'To bring a bank statement file into the books, or to preview what it would do' }),
31
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('file', type => GraphQLUpload_js_1.default)),
32
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('preview', { defaultValue: true })),
33
+ tslib_1.__param(2, (0, type_graphql_1.Arg)('assignments', type => [bank_import_type_1.BankRowAssignment], { nullable: true })),
34
+ tslib_1.__param(3, (0, type_graphql_1.Ctx)()),
35
+ tslib_1.__metadata("design:type", Function),
36
+ tslib_1.__metadata("design:paramtypes", [Object, Boolean, Array, Object]),
37
+ tslib_1.__metadata("design:returntype", Promise)
38
+ ], BankImportMutation.prototype, "importBankStatement", null);
39
+ exports.BankImportMutation = BankImportMutation = tslib_1.__decorate([
40
+ (0, type_graphql_1.Resolver)(),
41
+ (0, type_graphql_1.UseMiddleware)(shell_1.SpeakRefusals)
42
+ ], BankImportMutation);
43
+ //# sourceMappingURL=bank-import-mutation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bank-import-mutation.js","sourceRoot":"","sources":["../../../server/service/bank-statement/bank-import-mutation.ts"],"names":[],"mappings":";;;;AACA,+FAA2D;AAC3D,+CAAqF;AAErF,iDAA8D;AAE9D,+CAAoE;AACpE,yDAAwE;AACxE,+CAA4C;AAIrC,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;IAIvB,AAAN,KAAK,CAAC,mBAAmB,CACa,IAAgB,EACZ,OAAgB,EACa,WAAuC,EACrG,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAC1C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAA;QACzB,MAAM,MAAM,GAAa,EAAE,CAAA;QAC3B,IAAI,IAAI,GAAG,CAAC,CAAA;QACZ,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,CAAC,gBAAgB,EAAE,EAAE,CAAC;YACpD,IAAI,IAAI,KAAK,CAAC,MAAM,CAAA;YACpB,IAAI,IAAI,GAAG,6BAAe;gBAAE,MAAM,IAAI,eAAO,CAAC,qBAAqB,EAAE,EAAE,EAAE,sCAAsC,CAAC,CAAA;YAChH,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;QACjC,CAAC;QACD,OAAO,IAAA,iCAAmB,EAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAA,0BAAY,EAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,EAAE,WAAW,CAAC,CAAA;IAC9I,CAAC;CACF,CAAA;AArBY,gDAAkB;AAIvB;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,qFAAqF,CAAC;IAChG,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,mCAAgB,EAAE,EAAE,WAAW,EAAE,+EAA+E,EAAE,CAAC;IAErI,mBAAA,IAAA,kBAAG,EAAC,MAAM,EAAE,IAAI,CAAC,EAAE,CAAC,0BAAa,CAAC,CAAA;IAClC,mBAAA,IAAA,kBAAG,EAAC,SAAS,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAA;IACtC,mBAAA,IAAA,kBAAG,EAAC,aAAa,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,oCAAiB,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACnE,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;6DAYP;6BApBU,kBAAkB;IAF9B,IAAA,uBAAQ,GAAE;IACV,IAAA,4BAAa,EAAC,qBAAa,CAAC;GAChB,kBAAkB,CAqB9B","sourcesContent":["import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'\nimport GraphQLUpload from 'graphql-upload/GraphQLUpload.js'\nimport { Arg, Ctx, Directive, Mutation, Resolver, UseMiddleware } from 'type-graphql'\n\nimport { Refusal, SpeakRefusals } from '@things-factory/shell'\n\nimport { BANK_FILE_LIMIT, importBankStatement } from './bank-import'\nimport { BankImportResult, BankRowAssignment } from './bank-import-type'\nimport { bankFilename } from './bank-source'\n\n@Resolver()\n@UseMiddleware(SpeakRefusals)\nexport class BankImportMutation {\n @Directive('@transaction')\n @Directive('@privilege(category: \"accounting\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => BankImportResult, { description: 'To bring a bank statement file into the books, or to preview what it would do' })\n async importBankStatement(\n @Arg('file', type => GraphQLUpload) file: FileUpload,\n @Arg('preview', { defaultValue: true }) preview: boolean,\n @Arg('assignments', type => [BankRowAssignment], { nullable: true }) assignments: BankRowAssignment[] | null,\n @Ctx() context: ResolverContext\n ): Promise<BankImportResult> {\n const { domain, user, tx } = context.state\n const upload = await file\n const chunks: Buffer[] = []\n let size = 0\n for await (const chunk of upload.createReadStream()) {\n size += chunk.length\n if (size > BANK_FILE_LIMIT) throw new Refusal('bank-file-too-large', {}, 'Upload a bank file smaller than 10MB')\n chunks.push(Buffer.from(chunk))\n }\n return importBankStatement(tx, domain, user, { data: Buffer.concat(chunks), filename: bankFilename(upload.filename) }, preview, assignments)\n }\n}\n"]}
@@ -0,0 +1,31 @@
1
+ export declare class BankCandidate {
2
+ id: string;
3
+ transactionDate: string;
4
+ description?: string;
5
+ amount: number;
6
+ }
7
+ export declare class BankHeldRow {
8
+ fingerprint: string;
9
+ row: number;
10
+ transactionDate: string;
11
+ type: string;
12
+ amount: number;
13
+ currency: string;
14
+ bankDescription?: string;
15
+ candidates: BankCandidate[];
16
+ }
17
+ export declare class BankRowAssignment {
18
+ fingerprint: string;
19
+ transactionId?: string;
20
+ }
21
+ export declare class BankImportResult {
22
+ added: number;
23
+ existing: number;
24
+ zeroEvents: number;
25
+ updated: number;
26
+ review: number;
27
+ details: string;
28
+ filename: string;
29
+ accountNumber: string;
30
+ held: BankHeldRow[];
31
+ }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BankImportResult = exports.BankRowAssignment = exports.BankHeldRow = exports.BankCandidate = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const type_graphql_1 = require("type-graphql");
6
+ let BankCandidate = class BankCandidate {
7
+ };
8
+ exports.BankCandidate = BankCandidate;
9
+ tslib_1.__decorate([
10
+ (0, type_graphql_1.Field)({ description: 'The transaction id' }),
11
+ tslib_1.__metadata("design:type", String)
12
+ ], BankCandidate.prototype, "id", void 0);
13
+ tslib_1.__decorate([
14
+ (0, type_graphql_1.Field)({ description: 'Its date' }),
15
+ tslib_1.__metadata("design:type", String)
16
+ ], BankCandidate.prototype, "transactionDate", void 0);
17
+ tslib_1.__decorate([
18
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Its description' }),
19
+ tslib_1.__metadata("design:type", String)
20
+ ], BankCandidate.prototype, "description", void 0);
21
+ tslib_1.__decorate([
22
+ (0, type_graphql_1.Field)(type => type_graphql_1.Float, { description: 'Its amount' }),
23
+ tslib_1.__metadata("design:type", Number)
24
+ ], BankCandidate.prototype, "amount", void 0);
25
+ exports.BankCandidate = BankCandidate = tslib_1.__decorate([
26
+ (0, type_graphql_1.ObjectType)({ description: 'A transaction a held bank line could be' })
27
+ ], BankCandidate);
28
+ let BankHeldRow = class BankHeldRow {
29
+ };
30
+ exports.BankHeldRow = BankHeldRow;
31
+ tslib_1.__decorate([
32
+ (0, type_graphql_1.Field)({ description: 'The line key, to assign it' }),
33
+ tslib_1.__metadata("design:type", String)
34
+ ], BankHeldRow.prototype, "fingerprint", void 0);
35
+ tslib_1.__decorate([
36
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Row of the statement' }),
37
+ tslib_1.__metadata("design:type", Number)
38
+ ], BankHeldRow.prototype, "row", void 0);
39
+ tslib_1.__decorate([
40
+ (0, type_graphql_1.Field)({ description: 'Bank date' }),
41
+ tslib_1.__metadata("design:type", String)
42
+ ], BankHeldRow.prototype, "transactionDate", void 0);
43
+ tslib_1.__decorate([
44
+ (0, type_graphql_1.Field)({ description: 'INCOME or EXPENSE' }),
45
+ tslib_1.__metadata("design:type", String)
46
+ ], BankHeldRow.prototype, "type", void 0);
47
+ tslib_1.__decorate([
48
+ (0, type_graphql_1.Field)(type => type_graphql_1.Float, { description: 'Amount' }),
49
+ tslib_1.__metadata("design:type", Number)
50
+ ], BankHeldRow.prototype, "amount", void 0);
51
+ tslib_1.__decorate([
52
+ (0, type_graphql_1.Field)({ description: 'Currency' }),
53
+ tslib_1.__metadata("design:type", String)
54
+ ], BankHeldRow.prototype, "currency", void 0);
55
+ tslib_1.__decorate([
56
+ (0, type_graphql_1.Field)({ nullable: true, description: 'What the bank printed' }),
57
+ tslib_1.__metadata("design:type", String)
58
+ ], BankHeldRow.prototype, "bankDescription", void 0);
59
+ tslib_1.__decorate([
60
+ (0, type_graphql_1.Field)(type => [BankCandidate], { description: 'Transactions it could be' }),
61
+ tslib_1.__metadata("design:type", Array)
62
+ ], BankHeldRow.prototype, "candidates", void 0);
63
+ exports.BankHeldRow = BankHeldRow = tslib_1.__decorate([
64
+ (0, type_graphql_1.ObjectType)({ description: 'A bank line the import could not place by itself' })
65
+ ], BankHeldRow);
66
+ let BankRowAssignment = class BankRowAssignment {
67
+ };
68
+ exports.BankRowAssignment = BankRowAssignment;
69
+ tslib_1.__decorate([
70
+ (0, type_graphql_1.Field)({ description: 'The line key from the preview' }),
71
+ tslib_1.__metadata("design:type", String)
72
+ ], BankRowAssignment.prototype, "fingerprint", void 0);
73
+ tslib_1.__decorate([
74
+ (0, type_graphql_1.Field)({ nullable: true, description: 'The transaction it is; empty to add it as a new transaction' }),
75
+ tslib_1.__metadata("design:type", String)
76
+ ], BankRowAssignment.prototype, "transactionId", void 0);
77
+ exports.BankRowAssignment = BankRowAssignment = tslib_1.__decorate([
78
+ (0, type_graphql_1.InputType)({ description: 'What a person decided for a held bank line' })
79
+ ], BankRowAssignment);
80
+ let BankImportResult = class BankImportResult {
81
+ };
82
+ exports.BankImportResult = BankImportResult;
83
+ tslib_1.__decorate([
84
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Lines added as new transactions' }),
85
+ tslib_1.__metadata("design:type", Number)
86
+ ], BankImportResult.prototype, "added", void 0);
87
+ tslib_1.__decorate([
88
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Lines already in the books' }),
89
+ tslib_1.__metadata("design:type", Number)
90
+ ], BankImportResult.prototype, "existing", void 0);
91
+ tslib_1.__decorate([
92
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Lines that moved no money' }),
93
+ tslib_1.__metadata("design:type", Number)
94
+ ], BankImportResult.prototype, "zeroEvents", void 0);
95
+ tslib_1.__decorate([
96
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Transactions the statement gave its facts to' }),
97
+ tslib_1.__metadata("design:type", Number)
98
+ ], BankImportResult.prototype, "updated", void 0);
99
+ tslib_1.__decorate([
100
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Lines held for a person to place' }),
101
+ tslib_1.__metadata("design:type", Number)
102
+ ], BankImportResult.prototype, "review", void 0);
103
+ tslib_1.__decorate([
104
+ (0, type_graphql_1.Field)({ description: 'One line per row worth reading' }),
105
+ tslib_1.__metadata("design:type", String)
106
+ ], BankImportResult.prototype, "details", void 0);
107
+ tslib_1.__decorate([
108
+ (0, type_graphql_1.Field)({ description: 'The file name' }),
109
+ tslib_1.__metadata("design:type", String)
110
+ ], BankImportResult.prototype, "filename", void 0);
111
+ tslib_1.__decorate([
112
+ (0, type_graphql_1.Field)({ description: 'The account number the statement is of' }),
113
+ tslib_1.__metadata("design:type", String)
114
+ ], BankImportResult.prototype, "accountNumber", void 0);
115
+ tslib_1.__decorate([
116
+ (0, type_graphql_1.Field)(type => [BankHeldRow], { description: 'Lines held for a person to place' }),
117
+ tslib_1.__metadata("design:type", Array)
118
+ ], BankImportResult.prototype, "held", void 0);
119
+ exports.BankImportResult = BankImportResult = tslib_1.__decorate([
120
+ (0, type_graphql_1.ObjectType)({ description: 'What a bank statement import did, or would do in a preview' })
121
+ ], BankImportResult);
122
+ //# sourceMappingURL=bank-import-type.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bank-import-type.js","sourceRoot":"","sources":["../../../server/service/bank-statement/bank-import-type.ts"],"names":[],"mappings":"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","sourcesContent":["import { Field, Float, InputType, Int, ObjectType } from 'type-graphql'\n\n@ObjectType({ description: 'A transaction a held bank line could be' })\nexport class BankCandidate {\n @Field({ description: 'The transaction id' }) id: string\n @Field({ description: 'Its date' }) transactionDate: string\n @Field({ nullable: true, description: 'Its description' }) description?: string\n @Field(type => Float, { description: 'Its amount' }) amount: number\n}\n\n@ObjectType({ description: 'A bank line the import could not place by itself' })\nexport class BankHeldRow {\n @Field({ description: 'The line key, to assign it' }) fingerprint: string\n @Field(type => Int, { description: 'Row of the statement' }) row: number\n @Field({ description: 'Bank date' }) transactionDate: string\n @Field({ description: 'INCOME or EXPENSE' }) type: string\n @Field(type => Float, { description: 'Amount' }) amount: number\n @Field({ description: 'Currency' }) currency: string\n @Field({ nullable: true, description: 'What the bank printed' }) bankDescription?: string\n @Field(type => [BankCandidate], { description: 'Transactions it could be' }) candidates: BankCandidate[]\n}\n\n@InputType({ description: 'What a person decided for a held bank line' })\nexport class BankRowAssignment {\n @Field({ description: 'The line key from the preview' }) fingerprint: string\n @Field({ nullable: true, description: 'The transaction it is; empty to add it as a new transaction' }) transactionId?: string\n}\n\n@ObjectType({ description: 'What a bank statement import did, or would do in a preview' })\nexport class BankImportResult {\n @Field(type => Int, { description: 'Lines added as new transactions' }) added: number\n @Field(type => Int, { description: 'Lines already in the books' }) existing: number\n @Field(type => Int, { description: 'Lines that moved no money' }) zeroEvents: number\n @Field(type => Int, { description: 'Transactions the statement gave its facts to' }) updated: number\n @Field(type => Int, { description: 'Lines held for a person to place' }) review: number\n @Field({ description: 'One line per row worth reading' }) details: string\n @Field({ description: 'The file name' }) filename: string\n @Field({ description: 'The account number the statement is of' }) accountNumber: string\n @Field(type => [BankHeldRow], { description: 'Lines held for a person to place' }) held: BankHeldRow[]\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import { EntityManager } from 'typeorm';
2
+ import { BankImportResult, BankRowAssignment } from './bank-import-type';
3
+ export declare const BANK_FILE_LIMIT: number;
4
+ /**
5
+ * Bring a bank statement into the books. A line already in them (its fingerprint) is left; a line a typed transaction
6
+ * already tells is given to that transaction - its bank facts replace the typed ones, the typed ones kept beside them
7
+ * (`bankReconciliationBefore`); a line that could be more than one transaction is held for a person to place; the rest
8
+ * are added. The lines are written through `saveTransactions`, so what a transaction paid is checked again and the
9
+ * owners of its targets follow a changed day or amount. A preview writes nothing.
10
+ */
11
+ export declare function importBankStatement(tx: EntityManager, domain: {
12
+ id: string;
13
+ }, user: any, file: {
14
+ data: Buffer;
15
+ filename: string;
16
+ }, preview: boolean, assignments: BankRowAssignment[] | null): Promise<BankImportResult>;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BANK_FILE_LIMIT = void 0;
4
+ exports.importBankStatement = importBankStatement;
5
+ const crypto_1 = require("crypto");
6
+ const typeorm_1 = require("typeorm");
7
+ const shell_1 = require("@things-factory/shell");
8
+ const bank_account_1 = require("../bank-account/bank-account");
9
+ const transaction_1 = require("../transaction/transaction");
10
+ const transaction_service_1 = require("../transaction/transaction-service");
11
+ const bank_statement_1 = require("./bank-statement");
12
+ const bank_parser_1 = require("./bank-parser");
13
+ const bank_reconcile_1 = require("./bank-reconcile");
14
+ exports.BANK_FILE_LIMIT = 10 * 1024 * 1024;
15
+ /**
16
+ * Bring a bank statement into the books. A line already in them (its fingerprint) is left; a line a typed transaction
17
+ * already tells is given to that transaction - its bank facts replace the typed ones, the typed ones kept beside them
18
+ * (`bankReconciliationBefore`); a line that could be more than one transaction is held for a person to place; the rest
19
+ * are added. The lines are written through `saveTransactions`, so what a transaction paid is checked again and the
20
+ * owners of its targets follow a changed day or amount. A preview writes nothing.
21
+ */
22
+ async function importBankStatement(tx, domain, user, file, preview, assignments) {
23
+ const { data, filename } = file;
24
+ if (data.length > exports.BANK_FILE_LIMIT)
25
+ throw new shell_1.Refusal('bank-file-too-large', {}, 'Upload a bank file smaller than 10MB');
26
+ let parsed = (0, bank_parser_1.parseBankWorkbook)(data);
27
+ const accounts = await tx.getRepository(bank_account_1.BankAccount).find({ where: { domain: { id: domain.id }, active: true } });
28
+ const digits = (value) => (value ?? '').replace(/\D/g, '');
29
+ const accountFor = (currency) => {
30
+ const found = accounts.filter(account => account.currency === currency && account.bankName === parsed.bankName && digits(account.accountNumber) === digits(parsed.accountNumber));
31
+ if (found.length !== 1)
32
+ throw new shell_1.Refusal('bank-account-not-registered', { bank: parsed.bankName, account: parsed.accountNumber, currency }, 'Register the statement account and currency first');
33
+ return found[0];
34
+ };
35
+ /* Every moving line has its account before anything is written; a line that moved nothing needs none. */
36
+ const moving = parsed.lines.filter(line => line.amount > 0);
37
+ for (const line of moving)
38
+ accountFor(line.currency);
39
+ const timezoneOf = (currency) => accountFor(currency).timezone || 'Asia/Seoul';
40
+ parsed = (0, bank_parser_1.parseBankWorkbook)(data, currency => (moving.some(line => line.currency === currency) ? timezoneOf(currency) : 'Asia/Seoul'));
41
+ const statements = tx.getRepository(bank_statement_1.BankStatement);
42
+ const fileHash = (0, crypto_1.createHash)('sha256').update(data).digest('hex');
43
+ let statement = await statements.findOne({ where: { domain: { id: domain.id }, fileHash }, withDeleted: true });
44
+ if (!statement && !preview)
45
+ statement = await statements.save(statements.create({
46
+ domain,
47
+ creator: user,
48
+ updater: user,
49
+ fileHash,
50
+ filename,
51
+ bankName: parsed.bankName,
52
+ accountNumber: parsed.accountNumber,
53
+ rawRows: parsed.rawRows,
54
+ timezones: Object.fromEntries([...new Set(moving.map(line => line.currency))].map(currency => [currency, timezoneOf(currency)])),
55
+ periodStart: parsed.periodStart ?? undefined,
56
+ periodEnd: parsed.periodEnd ?? undefined
57
+ }));
58
+ const transactions = tx.getRepository(transaction_1.Transaction);
59
+ const ledger = await transactions.find({ where: { domain: { id: domain.id }, category: transaction_1.TransactionCategory.CONFIRMED } });
60
+ const known = await transactions.find({ where: { domain: { id: domain.id }, bankFingerprint: (0, typeorm_1.Not)((0, typeorm_1.IsNull)()) }, withDeleted: true, select: ['id', 'bankFingerprint'] });
61
+ const fingerprints = new Set(known.map(row => row.bankFingerprint));
62
+ const plan = (0, bank_reconcile_1.reconcileBankLines)(parsed.lines.filter(line => line.amount > 0 && !fingerprints.has(line.fingerprint)), ledger /* confirmed: each has its day */);
63
+ if (!(0, bank_reconcile_1.placeHeldLines)(plan, (assignments ?? []).map(one => ({ fingerprint: one.fingerprint, transactionId: one.transactionId }))))
64
+ throw new shell_1.Refusal('bank-assignment-invalid', {}, 'A held bank line can only be placed on one of its candidates, once');
65
+ const byFingerprint = new Map(plan.map(item => [item.line.fingerprint, item]));
66
+ const result = { added: 0, existing: 0, updated: 0, review: 0, details: '', zeroEvents: 0, filename, accountNumber: parsed.accountNumber, held: [] };
67
+ const details = [];
68
+ const inputs = [];
69
+ for (const line of parsed.lines) {
70
+ if (!line.amount) {
71
+ result.zeroEvents++;
72
+ continue;
73
+ }
74
+ if (fingerprints.has(line.fingerprint)) {
75
+ result.existing++;
76
+ continue;
77
+ }
78
+ const item = byFingerprint.get(line.fingerprint);
79
+ if (!item.match && item.candidates.length) {
80
+ result.held.push({
81
+ fingerprint: line.fingerprint,
82
+ row: line.row,
83
+ transactionDate: line.transactionDate,
84
+ type: line.type,
85
+ amount: line.amount,
86
+ currency: line.currency,
87
+ bankDescription: line.bankDescription ?? undefined,
88
+ candidates: item.candidates.map(old => ({ id: old.id, transactionDate: old.transactionDate, description: old.description, amount: Number(old.amount) }))
89
+ });
90
+ result.review++;
91
+ details.push(`${line.row}: ${line.transactionDate} ${line.type} ${line.amount} ${line.currency} ? ${item.candidates.map(old => `${old.transactionDate} ${old.description ?? ''} [${old.id}]`).join(', ')}`);
92
+ continue;
93
+ }
94
+ if (item.match)
95
+ result.updated++;
96
+ else
97
+ result.added++;
98
+ if (preview) {
99
+ if (item.match)
100
+ details.push(`${line.row}: ${item.match.transactionDate} ${item.match.description ?? ''} → ${line.transactionDate} ${line.amount} ${line.currency}`);
101
+ continue;
102
+ }
103
+ const { raw, row, fingerprint, bankLocalTime, ...facts } = line;
104
+ const matched = item.match ? ledger.find(old => old.id === item.match.id) : undefined;
105
+ inputs.push({
106
+ ...(matched
107
+ ? {
108
+ id: matched.id,
109
+ cuFlag: 'M',
110
+ bankReconciliationBefore: {
111
+ accountId: matched.bankAccountId,
112
+ transactionDate: matched.transactionDate,
113
+ transactionAt: matched.transactionAt,
114
+ amount: matched.amount,
115
+ balance: matched.balance,
116
+ currency: matched.currency,
117
+ type: matched.type,
118
+ source: matched.source,
119
+ reconciledAt: new Date().toISOString(),
120
+ reconciledBy: user?.id
121
+ }
122
+ }
123
+ : { cuFlag: '+', category: transaction_1.TransactionCategory.CONFIRMED }),
124
+ ...facts,
125
+ transactionAt: line.transactionAt ?? null,
126
+ bankAccount: { id: accountFor(line.currency).id },
127
+ source: transaction_1.TransactionSource.BANK_STATEMENT,
128
+ bankStatement: statement ? { id: statement.id } : null,
129
+ sourceFile: filename,
130
+ sourceRow: row,
131
+ bankFingerprint: fingerprint,
132
+ description: matched?.description ?? line.bankDescription ?? line.bankKind ?? null,
133
+ counterpartyName: matched?.counterpartyName ?? line.bankCounterpartyName ?? null
134
+ });
135
+ }
136
+ if (inputs.length)
137
+ await (0, transaction_service_1.saveTransactions)(tx, domain, user, inputs);
138
+ result.details = details.join('\n');
139
+ return result;
140
+ }
141
+ //# sourceMappingURL=bank-import.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bank-import.js","sourceRoot":"","sources":["../../../server/service/bank-statement/bank-import.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'crypto'\nimport { EntityManager, IsNull, Not } from 'typeorm'\n\nimport { Refusal } from '@things-factory/shell'\n\nimport { BankAccount } from '../bank-account/bank-account'\nimport { Transaction, TransactionCategory, TransactionSource } from '../transaction/transaction'\nimport { saveTransactions, TransactionInput } from '../transaction/transaction-service'\nimport { BankStatement } from './bank-statement'\nimport { parseBankWorkbook } from './bank-parser'\nimport { LedgerCandidate, placeHeldLines, reconcileBankLines } from './bank-reconcile'\nimport { BankHeldRow, BankImportResult, BankRowAssignment } from './bank-import-type'\n\nexport const BANK_FILE_LIMIT = 10 * 1024 * 1024\n\n/**\n * Bring a bank statement into the books. A line already in them (its fingerprint) is left; a line a typed transaction\n * already tells is given to that transaction - its bank facts replace the typed ones, the typed ones kept beside them\n * (`bankReconciliationBefore`); a line that could be more than one transaction is held for a person to place; the rest\n * are added. The lines are written through `saveTransactions`, so what a transaction paid is checked again and the\n * owners of its targets follow a changed day or amount. A preview writes nothing.\n */\nexport async function importBankStatement(\n tx: EntityManager,\n domain: { id: string },\n user: any,\n file: { data: Buffer; filename: string },\n preview: boolean,\n assignments: BankRowAssignment[] | null\n): Promise<BankImportResult> {\n const { data, filename } = file\n if (data.length > BANK_FILE_LIMIT) throw new Refusal('bank-file-too-large', {}, 'Upload a bank file smaller than 10MB')\n\n let parsed = parseBankWorkbook(data)\n const accounts = await tx.getRepository(BankAccount).find({ where: { domain: { id: domain.id }, active: true } })\n const digits = (value?: string | null) => (value ?? '').replace(/\\D/g, '')\n const accountFor = (currency: string) => {\n const found = accounts.filter(\n account => account.currency === currency && account.bankName === parsed.bankName && digits(account.accountNumber) === digits(parsed.accountNumber)\n )\n if (found.length !== 1)\n throw new Refusal('bank-account-not-registered', { bank: parsed.bankName, account: parsed.accountNumber, currency }, 'Register the statement account and currency first')\n return found[0]\n }\n /* Every moving line has its account before anything is written; a line that moved nothing needs none. */\n const moving = parsed.lines.filter(line => line.amount > 0)\n for (const line of moving) accountFor(line.currency)\n const timezoneOf = (currency: string) => accountFor(currency).timezone || 'Asia/Seoul'\n parsed = parseBankWorkbook(data, currency => (moving.some(line => line.currency === currency) ? timezoneOf(currency) : 'Asia/Seoul'))\n\n const statements = tx.getRepository(BankStatement)\n const fileHash = createHash('sha256').update(data).digest('hex')\n let statement = await statements.findOne({ where: { domain: { id: domain.id }, fileHash }, withDeleted: true })\n if (!statement && !preview)\n statement = await statements.save(\n statements.create({\n domain,\n creator: user,\n updater: user,\n fileHash,\n filename,\n bankName: parsed.bankName,\n accountNumber: parsed.accountNumber,\n rawRows: parsed.rawRows,\n timezones: Object.fromEntries([...new Set(moving.map(line => line.currency))].map(currency => [currency, timezoneOf(currency)])),\n periodStart: parsed.periodStart ?? undefined,\n periodEnd: parsed.periodEnd ?? undefined\n } as Partial<BankStatement>)\n )\n\n const transactions = tx.getRepository(Transaction)\n const ledger = await transactions.find({ where: { domain: { id: domain.id }, category: TransactionCategory.CONFIRMED } })\n const known = await transactions.find({ where: { domain: { id: domain.id }, bankFingerprint: Not(IsNull()) }, withDeleted: true, select: ['id', 'bankFingerprint'] })\n const fingerprints = new Set(known.map(row => row.bankFingerprint))\n const plan = reconcileBankLines(\n parsed.lines.filter(line => line.amount > 0 && !fingerprints.has(line.fingerprint)),\n ledger as LedgerCandidate[] /* confirmed: each has its day */\n )\n if (!placeHeldLines(plan, (assignments ?? []).map(one => ({ fingerprint: one.fingerprint, transactionId: one.transactionId }))))\n throw new Refusal('bank-assignment-invalid', {}, 'A held bank line can only be placed on one of its candidates, once')\n const byFingerprint = new Map(plan.map(item => [item.line.fingerprint, item]))\n\n const result: BankImportResult = { added: 0, existing: 0, updated: 0, review: 0, details: '', zeroEvents: 0, filename, accountNumber: parsed.accountNumber, held: [] as BankHeldRow[] }\n const details: string[] = []\n const inputs: TransactionInput[] = []\n for (const line of parsed.lines) {\n if (!line.amount) {\n result.zeroEvents++\n continue\n }\n if (fingerprints.has(line.fingerprint)) {\n result.existing++\n continue\n }\n const item = byFingerprint.get(line.fingerprint)!\n if (!item.match && item.candidates.length) {\n result.held.push({\n fingerprint: line.fingerprint,\n row: line.row,\n transactionDate: line.transactionDate,\n type: line.type,\n amount: line.amount,\n currency: line.currency,\n bankDescription: line.bankDescription ?? undefined,\n candidates: item.candidates.map(old => ({ id: old.id, transactionDate: old.transactionDate, description: old.description, amount: Number(old.amount) }))\n })\n result.review++\n details.push(`${line.row}: ${line.transactionDate} ${line.type} ${line.amount} ${line.currency} ? ${item.candidates.map(old => `${old.transactionDate} ${old.description ?? ''} [${old.id}]`).join(', ')}`)\n continue\n }\n if (item.match) result.updated++\n else result.added++\n if (preview) {\n if (item.match) details.push(`${line.row}: ${item.match.transactionDate} ${item.match.description ?? ''} → ${line.transactionDate} ${line.amount} ${line.currency}`)\n continue\n }\n\n const { raw, row, fingerprint, bankLocalTime, ...facts } = line\n const matched = item.match ? ledger.find(old => old.id === item.match!.id) : undefined\n inputs.push({\n ...(matched\n ? {\n id: matched.id,\n cuFlag: 'M',\n bankReconciliationBefore: {\n accountId: matched.bankAccountId,\n transactionDate: matched.transactionDate,\n transactionAt: matched.transactionAt,\n amount: matched.amount,\n balance: matched.balance,\n currency: matched.currency,\n type: matched.type,\n source: matched.source,\n reconciledAt: new Date().toISOString(),\n reconciledBy: user?.id\n }\n }\n : { cuFlag: '+', category: TransactionCategory.CONFIRMED }),\n ...(facts as any),\n transactionAt: line.transactionAt ?? null,\n bankAccount: { id: accountFor(line.currency).id },\n source: TransactionSource.BANK_STATEMENT,\n bankStatement: statement ? { id: statement.id } : null,\n sourceFile: filename,\n sourceRow: row,\n bankFingerprint: fingerprint,\n description: matched?.description ?? line.bankDescription ?? line.bankKind ?? null,\n counterpartyName: matched?.counterpartyName ?? line.bankCounterpartyName ?? null\n })\n }\n if (inputs.length) await saveTransactions(tx, domain, user, inputs)\n\n result.details = details.join('\\n')\n return result\n}\n"]}
@@ -0,0 +1,35 @@
1
+ export interface BankLine {
2
+ row: number;
3
+ bankSequence: number;
4
+ currency: string;
5
+ transactionDate: string;
6
+ transactionAt: string | null;
7
+ bankLocalTime: string;
8
+ type: 'INCOME' | 'EXPENSE';
9
+ amount: number;
10
+ balance: number;
11
+ bankDescription: string | null;
12
+ bankKind: string | null;
13
+ bankCounterpartyName: string | null;
14
+ bankCounterpartyAccount: string | null;
15
+ bankCounterpartyBank: string | null;
16
+ bankMemo: string | null;
17
+ fingerprint: string;
18
+ raw: any[];
19
+ }
20
+ export declare function parseBankWorkbook(data: Buffer, timezoneFor?: (currency: string) => string): {
21
+ bankName: string;
22
+ accountNumber: string;
23
+ periodStart: string;
24
+ periodEnd: string;
25
+ rawRows: any[][];
26
+ lines: BankLine[];
27
+ };
28
+ export declare function parseBankRows(rows: any[][], timezoneFor?: (currency: string) => string): {
29
+ bankName: string;
30
+ accountNumber: string;
31
+ periodStart: string;
32
+ periodEnd: string;
33
+ rawRows: any[][];
34
+ lines: BankLine[];
35
+ };