@things-factory/accounting 10.1.138 → 10.1.140
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-server/service/account/index.d.ts +1 -1
- package/dist-server/service/accounting-category/index.d.ts +1 -1
- package/dist-server/service/accounting-document/index.d.ts +1 -1
- package/dist-server/service/bank-account/index.d.ts +1 -1
- package/dist-server/service/bank-statement/bank-import-mutation.d.ts +5 -0
- package/dist-server/service/bank-statement/bank-import-mutation.js +43 -0
- package/dist-server/service/bank-statement/bank-import-mutation.js.map +1 -0
- package/dist-server/service/bank-statement/bank-import-type.d.ts +31 -0
- package/dist-server/service/bank-statement/bank-import-type.js +122 -0
- package/dist-server/service/bank-statement/bank-import-type.js.map +1 -0
- package/dist-server/service/bank-statement/bank-import.d.ts +16 -0
- package/dist-server/service/bank-statement/bank-import.js +141 -0
- package/dist-server/service/bank-statement/bank-import.js.map +1 -0
- package/dist-server/service/bank-statement/bank-parser.d.ts +35 -0
- package/dist-server/service/bank-statement/bank-parser.js +89 -0
- package/dist-server/service/bank-statement/bank-parser.js.map +1 -0
- package/dist-server/service/bank-statement/bank-reconcile.d.ts +35 -0
- package/dist-server/service/bank-statement/bank-reconcile.js +71 -0
- package/dist-server/service/bank-statement/bank-reconcile.js.map +1 -0
- package/dist-server/service/bank-statement/bank-source.d.ts +25 -0
- package/dist-server/service/bank-statement/bank-source.js +55 -0
- package/dist-server/service/bank-statement/bank-source.js.map +1 -0
- package/dist-server/service/bank-statement/index.d.ts +2 -1
- package/dist-server/service/bank-statement/index.js +2 -1
- package/dist-server/service/bank-statement/index.js.map +1 -1
- package/dist-server/service/financial-institution/index.d.ts +1 -1
- package/dist-server/service/financial-statement/index.d.ts +2 -2
- package/dist-server/service/fiscal-month/index.d.ts +1 -1
- package/dist-server/service/fiscal-quarter/index.d.ts +1 -1
- package/dist-server/service/fiscal-year/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +7 -2
- package/dist-server/service/index.js +5 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/payment/payment-service.d.ts +3 -1
- package/dist-server/service/payment/payment-service.js +5 -2
- package/dist-server/service/payment/payment-service.js.map +1 -1
- package/dist-server/service/transaction/transaction-service.d.ts +5 -0
- package/dist-server/service/transaction/transaction-service.js +22 -0
- package/dist-server/service/transaction/transaction-service.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -3
- package/translations/en.json +15 -1
- package/translations/ko.json +15 -1
|
@@ -3,5 +3,5 @@ import { AccountQuery } from './account-query';
|
|
|
3
3
|
import { AccountMutation } from './account-mutation';
|
|
4
4
|
import { AccountHistoryEntitySubscriber } from './event-subscriber';
|
|
5
5
|
export declare const entities: (typeof Account)[];
|
|
6
|
-
export declare const resolvers: (typeof
|
|
6
|
+
export declare const resolvers: (typeof AccountMutation | typeof AccountQuery)[];
|
|
7
7
|
export declare const subscribers: (typeof AccountHistoryEntitySubscriber)[];
|
|
@@ -3,5 +3,5 @@ import { AccountingCategoryQuery } from './accounting-category-query';
|
|
|
3
3
|
import { AccountingCategoryMutation } from './accounting-category-mutation';
|
|
4
4
|
import { AccountingCategoryHistoryEntitySubscriber } from './event-subscriber';
|
|
5
5
|
export declare const entities: (typeof AccountingCategory)[];
|
|
6
|
-
export declare const resolvers: (typeof
|
|
6
|
+
export declare const resolvers: (typeof AccountingCategoryMutation | typeof AccountingCategoryQuery)[];
|
|
7
7
|
export declare const subscribers: (typeof AccountingCategoryHistoryEntitySubscriber)[];
|
|
@@ -3,5 +3,5 @@ import { AccountingDocumentHistoryEntitySubscriber } from './event-subscriber';
|
|
|
3
3
|
import { AccountingDocumentQuery } from './accounting-document-query';
|
|
4
4
|
import { AccountingDocumentMutation } from './accounting-document-mutation';
|
|
5
5
|
export declare const entities: (typeof AccountingDocument)[];
|
|
6
|
-
export declare const resolvers: (typeof
|
|
6
|
+
export declare const resolvers: (typeof AccountingDocumentMutation | typeof AccountingDocumentQuery)[];
|
|
7
7
|
export declare const subscribers: (typeof AccountingDocumentHistoryEntitySubscriber)[];
|
|
@@ -3,5 +3,5 @@ import { BankAccountHistoryEntitySubscriber } from './event-subscriber';
|
|
|
3
3
|
import { BankAccountQuery } from './bank-account-query';
|
|
4
4
|
import { BankAccountMutation } from './bank-account-mutation';
|
|
5
5
|
export declare const entities: (typeof BankAccount)[];
|
|
6
|
-
export declare const resolvers: (typeof
|
|
6
|
+
export declare const resolvers: (typeof BankAccountMutation | typeof BankAccountQuery)[];
|
|
7
7
|
export declare const subscribers: (typeof BankAccountHistoryEntitySubscriber)[];
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseBankWorkbook = parseBankWorkbook;
|
|
4
|
+
exports.parseBankRows = parseBankRows;
|
|
5
|
+
const xlsx_1 = require("xlsx");
|
|
6
|
+
const bank_source_1 = require("./bank-source");
|
|
7
|
+
const shell_1 = require("@things-factory/shell");
|
|
8
|
+
function parseBankWorkbook(data, timezoneFor = () => 'Asia/Seoul') {
|
|
9
|
+
const workbook = (0, xlsx_1.read)(data, { type: 'buffer' });
|
|
10
|
+
if (workbook.SheetNames.length !== 1)
|
|
11
|
+
throw new shell_1.Refusal('bank-single-sheet', {}, 'Upload a statement with one sheet');
|
|
12
|
+
const rows = xlsx_1.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { header: 1, defval: null });
|
|
13
|
+
return parseBankRows(rows, timezoneFor);
|
|
14
|
+
}
|
|
15
|
+
function parseBankRows(rows, timezoneFor = () => 'Asia/Seoul') {
|
|
16
|
+
const headerIndex = rows.findIndex(row => row.includes('거래일시'));
|
|
17
|
+
if (headerIndex < 0)
|
|
18
|
+
throw new shell_1.Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format');
|
|
19
|
+
const header = rows[headerIndex];
|
|
20
|
+
const metadata = rows.slice(0, headerIndex).map(row => row.join(' ')).join('\n');
|
|
21
|
+
const accountNumber = /계좌번호\s*:\s*([\d-]+)/.exec(metadata)?.[1];
|
|
22
|
+
if (!accountNumber)
|
|
23
|
+
throw new shell_1.Refusal('bank-missing-account', {}, 'The bank statement has no account number');
|
|
24
|
+
const woori = header.includes('지급(원)');
|
|
25
|
+
const foreign = header.includes('통화');
|
|
26
|
+
if (!woori && !foreign && !(header.includes('출금') && header.includes('입금') && header.includes('거래후 잔액')))
|
|
27
|
+
throw new shell_1.Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format');
|
|
28
|
+
const bankName = woori ? '우리은행' : '기업은행';
|
|
29
|
+
const periods = metadata.match(/\d{4}[-.]\d{2}[-.]\d{2}/g) ?? [];
|
|
30
|
+
const text = (value) => value == null || value === '' ? null : String(value);
|
|
31
|
+
const money = (value) => {
|
|
32
|
+
if (value == null || value === '')
|
|
33
|
+
throw new shell_1.Refusal('bank-invalid-money', {}, 'Check the statement amounts and balances');
|
|
34
|
+
const n = Number(typeof value === 'string' ? value.replace(/,/g, '').trim() : value);
|
|
35
|
+
if (!Number.isFinite(n) || Math.abs(n * 100 - Math.round(n * 100)) > 0.0001)
|
|
36
|
+
throw new shell_1.Refusal('bank-invalid-money', {}, 'Check the statement amounts and balances');
|
|
37
|
+
return n;
|
|
38
|
+
};
|
|
39
|
+
const lines = [];
|
|
40
|
+
const occurrences = new Map();
|
|
41
|
+
const sourceRows = rows.slice(headerIndex + 1).map((raw, i) => ({ raw, row: i + headerIndex + 2 })).filter(({ raw }) => typeof raw[0] === 'number');
|
|
42
|
+
if (!sourceRows.length)
|
|
43
|
+
throw new shell_1.Refusal('bank-empty-statement', {}, 'The statement has no transactions');
|
|
44
|
+
for (const { raw, row } of sourceRows) {
|
|
45
|
+
const time = (0, bank_source_1.bankTimestamp)(String(raw[1]), timezoneFor(foreign ? String(raw[2]) : 'KRW'));
|
|
46
|
+
const deposit = money(raw[woori ? 5 : foreign ? 3 : 3]);
|
|
47
|
+
const withdrawal = money(raw[woori ? 4 : foreign ? 4 : 2]);
|
|
48
|
+
if (deposit < 0 || withdrawal < 0 || (deposit && withdrawal))
|
|
49
|
+
throw new shell_1.Refusal('bank-invalid-direction', { row }, 'Check the bank deposit and withdrawal direction');
|
|
50
|
+
const line = {
|
|
51
|
+
row, bankSequence: foreign ? raw[0] : sourceRows.length + 1 - raw[0],
|
|
52
|
+
currency: foreign ? String(raw[2]) : 'KRW', ...time,
|
|
53
|
+
type: deposit ? 'INCOME' : 'EXPENSE', amount: deposit || withdrawal,
|
|
54
|
+
balance: money(raw[woori ? 6 : foreign ? 5 : 4]),
|
|
55
|
+
bankDescription: text(raw[woori ? 3 : foreign ? 6 : 5]),
|
|
56
|
+
bankKind: foreign ? '외화' : text(raw[woori ? 2 : 9]),
|
|
57
|
+
bankCounterpartyName: text(raw[woori ? 3 : foreign ? 8 : 12]),
|
|
58
|
+
bankCounterpartyAccount: foreign || woori ? null : text(raw[6]),
|
|
59
|
+
bankCounterpartyBank: foreign || woori ? null : text(raw[7]),
|
|
60
|
+
bankMemo: foreign ? null : text(raw[8]), fingerprint: '', raw
|
|
61
|
+
};
|
|
62
|
+
if (!/^[A-Z]{3}$/.test(line.currency))
|
|
63
|
+
throw new shell_1.Refusal('bank-invalid-currency', { row }, 'Check the bank currency');
|
|
64
|
+
const base = (0, bank_source_1.bankFingerprint)({ ...line, accountNumber });
|
|
65
|
+
const occurrence = occurrences.get(base) ?? 0;
|
|
66
|
+
occurrences.set(base, occurrence + 1);
|
|
67
|
+
line.fingerprint = (0, bank_source_1.bankFingerprint)({ ...line, accountNumber }, occurrence);
|
|
68
|
+
lines.push(line);
|
|
69
|
+
}
|
|
70
|
+
// The bank's chronological row order resolves ties in second-resolution timestamps.
|
|
71
|
+
const sorted = [...lines].sort((a, b) => (a.transactionAt ?? a.transactionDate).localeCompare(b.transactionAt ?? b.transactionDate) || a.bankSequence - b.bankSequence);
|
|
72
|
+
const previous = new Map();
|
|
73
|
+
for (const line of sorted) {
|
|
74
|
+
const prior = previous.get(line.currency);
|
|
75
|
+
if (prior && Math.abs(line.balance - prior.balance - (line.type === 'INCOME' ? line.amount : -line.amount)) > 0.005)
|
|
76
|
+
throw new shell_1.Refusal('bank-balance-gap', { row: line.row }, 'The bank balance does not follow the previous transaction');
|
|
77
|
+
previous.set(line.currency, line);
|
|
78
|
+
}
|
|
79
|
+
const total = rows.find(row => row[1] === '합계');
|
|
80
|
+
if (total && !foreign && !woori) {
|
|
81
|
+
for (const [type, index] of [['EXPENSE', 2], ['INCOME', 3]]) {
|
|
82
|
+
const sum = lines.filter(line => line.type === type).reduce((n, line) => n + line.amount, 0);
|
|
83
|
+
if (Math.abs(sum - money(total[index])) > 0.005)
|
|
84
|
+
throw new shell_1.Refusal('bank-total-mismatch', {}, 'The bank transaction total does not match');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { bankName, accountNumber, periodStart: periods[0]?.replace(/\./g, '-') ?? null, periodEnd: periods[1]?.replace(/\./g, '-') ?? null, rawRows: rows, lines };
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=bank-parser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bank-parser.js","sourceRoot":"","sources":["../../../server/service/bank-statement/bank-parser.ts"],"names":[],"mappings":"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","sourcesContent":["import { read, utils } from 'xlsx'\nimport { bankFingerprint, bankTimestamp } from './bank-source'\nimport { Refusal } from '@things-factory/shell'\n\nexport interface BankLine {\n row: number\n bankSequence: number\n currency: string\n transactionDate: string\n transactionAt: string | null\n bankLocalTime: string\n type: 'INCOME' | 'EXPENSE'\n amount: number\n balance: number\n bankDescription: string | null\n bankKind: string | null\n bankCounterpartyName: string | null\n bankCounterpartyAccount: string | null\n bankCounterpartyBank: string | null\n bankMemo: string | null\n fingerprint: string\n raw: any[]\n}\n\nexport function parseBankWorkbook(data: Buffer, timezoneFor: (currency: string) => string = () => 'Asia/Seoul') {\n const workbook = read(data, { type: 'buffer' })\n if (workbook.SheetNames.length !== 1) throw new Refusal('bank-single-sheet', {}, 'Upload a statement with one sheet')\n const rows = utils.sheet_to_json<any[]>(workbook.Sheets[workbook.SheetNames[0]], { header: 1, defval: null })\n return parseBankRows(rows, timezoneFor)\n}\n\nexport function parseBankRows(rows: any[][], timezoneFor: (currency: string) => string = () => 'Asia/Seoul') {\n const headerIndex = rows.findIndex(row => row.includes('거래일시'))\n if (headerIndex < 0) throw new Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format')\n const header = rows[headerIndex]\n const metadata = rows.slice(0, headerIndex).map(row => row.join(' ')).join('\\n')\n const accountNumber = /계좌번호\\s*:\\s*([\\d-]+)/.exec(metadata)?.[1]\n if (!accountNumber) throw new Refusal('bank-missing-account', {}, 'The bank statement has no account number')\n const woori = header.includes('지급(원)')\n const foreign = header.includes('통화')\n if (!woori && !foreign && !(header.includes('출금') && header.includes('입금') && header.includes('거래후 잔액'))) throw new Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format')\n const bankName = woori ? '우리은행' : '기업은행'\n const periods = metadata.match(/\\d{4}[-.]\\d{2}[-.]\\d{2}/g) ?? []\n const text = (value: any) => value == null || value === '' ? null : String(value)\n const money = (value: any) => {\n if (value == null || value === '') throw new Refusal('bank-invalid-money', {}, 'Check the statement amounts and balances')\n const n = Number(typeof value === 'string' ? value.replace(/,/g, '').trim() : value)\n if (!Number.isFinite(n) || Math.abs(n * 100 - Math.round(n * 100)) > 0.0001) throw new Refusal('bank-invalid-money', {}, 'Check the statement amounts and balances')\n return n\n }\n const lines: BankLine[] = []\n const occurrences = new Map<string, number>()\n const sourceRows = rows.slice(headerIndex + 1).map((raw, i) => ({ raw, row: i + headerIndex + 2 })).filter(({ raw }) => typeof raw[0] === 'number')\n if (!sourceRows.length) throw new Refusal('bank-empty-statement', {}, 'The statement has no transactions')\n for (const { raw, row } of sourceRows) {\n const time = bankTimestamp(String(raw[1]), timezoneFor(foreign ? String(raw[2]) : 'KRW'))\n const deposit = money(raw[woori ? 5 : foreign ? 3 : 3])\n const withdrawal = money(raw[woori ? 4 : foreign ? 4 : 2])\n if (deposit < 0 || withdrawal < 0 || (deposit && withdrawal)) throw new Refusal('bank-invalid-direction', { row }, 'Check the bank deposit and withdrawal direction')\n const line: BankLine = {\n row, bankSequence: foreign ? raw[0] : sourceRows.length + 1 - raw[0],\n currency: foreign ? String(raw[2]) : 'KRW', ...time,\n type: deposit ? 'INCOME' : 'EXPENSE', amount: deposit || withdrawal,\n balance: money(raw[woori ? 6 : foreign ? 5 : 4]),\n bankDescription: text(raw[woori ? 3 : foreign ? 6 : 5]),\n bankKind: foreign ? '외화' : text(raw[woori ? 2 : 9]),\n bankCounterpartyName: text(raw[woori ? 3 : foreign ? 8 : 12]),\n bankCounterpartyAccount: foreign || woori ? null : text(raw[6]),\n bankCounterpartyBank: foreign || woori ? null : text(raw[7]),\n bankMemo: foreign ? null : text(raw[8]), fingerprint: '', raw\n }\n if (!/^[A-Z]{3}$/.test(line.currency)) throw new Refusal('bank-invalid-currency', { row }, 'Check the bank currency')\n const base = bankFingerprint({ ...line, accountNumber })\n const occurrence = occurrences.get(base) ?? 0\n occurrences.set(base, occurrence + 1)\n line.fingerprint = bankFingerprint({ ...line, accountNumber }, occurrence)\n lines.push(line)\n }\n // The bank's chronological row order resolves ties in second-resolution timestamps.\n const sorted = [...lines].sort((a, b) => (a.transactionAt ?? a.transactionDate).localeCompare(b.transactionAt ?? b.transactionDate) || a.bankSequence - b.bankSequence)\n const previous = new Map<string, BankLine>()\n for (const line of sorted) {\n const prior = previous.get(line.currency)\n if (prior && Math.abs(line.balance - prior.balance - (line.type === 'INCOME' ? line.amount : -line.amount)) > 0.005)\n throw new Refusal('bank-balance-gap', { row: line.row }, 'The bank balance does not follow the previous transaction')\n previous.set(line.currency, line)\n }\n const total = rows.find(row => row[1] === '합계')\n if (total && !foreign && !woori) {\n for (const [type, index] of [['EXPENSE', 2], ['INCOME', 3]] as const) {\n const sum = lines.filter(line => line.type === type).reduce((n, line) => n + line.amount, 0)\n if (Math.abs(sum - money(total[index])) > 0.005) throw new Refusal('bank-total-mismatch', {}, 'The bank transaction total does not match')\n }\n }\n return { bankName, accountNumber, periodStart: periods[0]?.replace(/\\./g, '-') ?? null, periodEnd: periods[1]?.replace(/\\./g, '-') ?? null, rawRows: rows, lines }\n}\n"]}
|