@things-factory/accounting 10.1.168 → 10.1.169

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.
@@ -18,18 +18,18 @@ export interface BankLine {
18
18
  raw: any[];
19
19
  }
20
20
  export declare function parseBankWorkbook(data: Buffer, timezoneFor?: (currency: string) => string): {
21
- bankName: string;
22
- accountNumber: string;
23
- periodStart: string;
24
- periodEnd: string;
25
21
  rawRows: any[][];
26
22
  lines: BankLine[];
27
- };
28
- export declare function parseBankRows(rows: any[][], timezoneFor?: (currency: string) => string): {
23
+ periodStart: string | null;
24
+ periodEnd: string | null;
29
25
  bankName: string;
30
26
  accountNumber: string;
31
- periodStart: string;
32
- periodEnd: string;
27
+ };
28
+ export declare function parseBankRows(rows: any[][], timezoneFor?: (currency: string) => string): {
33
29
  rawRows: any[][];
34
30
  lines: BankLine[];
31
+ periodStart: string | null;
32
+ periodEnd: string | null;
33
+ bankName: string;
34
+ accountNumber: string;
35
35
  };
@@ -12,22 +12,59 @@ function parseBankWorkbook(data, timezoneFor = () => 'Asia/Seoul') {
12
12
  const rows = xlsx_1.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { header: 1, defval: null });
13
13
  return parseBankRows(rows, timezoneFor);
14
14
  }
15
+ const BANK_FORMATS = [
16
+ /* 기업은행 거래내역조회 (입출식 예금) */
17
+ {
18
+ bankName: '기업은행', time: '거래일시', withdrawal: '출금', deposit: '입금', balance: '거래후 잔액', description: '거래내용',
19
+ kind: { column: '거래구분' }, counterpartyName: '상대계좌예금주명', counterpartyAccount: '상대계좌번호', counterpartyBank: '상대은행',
20
+ memo: '메모', newestFirst: true, totalRow: true
21
+ },
22
+ /* 기업은행 외화 거래내역조회 */
23
+ {
24
+ bankName: '기업은행', time: '거래일시', currency: '통화', withdrawal: '출금', deposit: '입금', balance: '거래후잔액', description: '적요',
25
+ kind: { fixed: '외화' }, counterpartyName: '해외수입업자', newestFirst: false
26
+ },
27
+ /* 우리은행 최근거래내역조회 */
28
+ {
29
+ bankName: '우리은행', time: '거래일시', withdrawal: '지급(원)', deposit: '입금(원)', balance: '거래후 잔액(원)', description: '기재내용',
30
+ kind: { column: '적요' }, counterpartyName: '기재내용', memo: '메모', newestFirst: true
31
+ },
32
+ /* 우리은행 외화거래내역조회 (예금거래내역) */
33
+ {
34
+ bankName: '우리은행', time: '거래일', currency: '통화', withdrawal: '찾으신 금액', deposit: '맡기신 금액', balance: '잔액', description: '기재내용',
35
+ kind: { column: '적요' }, counterpartyName: '기재내용', newestFirst: true
36
+ }
37
+ ];
38
+ const columnsOf = (format) => [format.time, format.currency, format.withdrawal, format.deposit, format.balance, format.description, 'column' in format.kind ? format.kind.column : undefined,
39
+ format.counterpartyName, format.counterpartyAccount, format.counterpartyBank, format.memo].filter((column) => !!column);
40
+ /** The period the statement was queried for, as the bank labels it: 「조회기간 A ~ B」 or 「조회시작일자 A … 조회종료일자 B」. */
41
+ function statementPeriod(metadata) {
42
+ const date = String.raw `(\d{4})[-.](\d{2})[-.](\d{2})`;
43
+ const iso = (match, at) => (match ? `${match[at]}-${match[at + 1]}-${match[at + 2]}` : null);
44
+ const range = new RegExp(String.raw `조회기간\s*:\s*${date}\s*~\s*${date}`).exec(metadata);
45
+ if (range)
46
+ return { periodStart: iso(range, 1), periodEnd: iso(range, 4) };
47
+ return {
48
+ periodStart: iso(new RegExp(String.raw `조회시작일자\s*:\s*${date}`).exec(metadata), 1),
49
+ periodEnd: iso(new RegExp(String.raw `조회종료일자\s*:\s*${date}`).exec(metadata), 1)
50
+ };
51
+ }
15
52
  function parseBankRows(rows, timezoneFor = () => 'Asia/Seoul') {
16
- const headerIndex = rows.findIndex(row => row.includes('거래일시'));
53
+ const headerOf = (row) => row.map(cell => (typeof cell === 'string' ? cell.trim() : cell));
54
+ const headerIndex = rows.findIndex(row => headerOf(row).some(cell => cell === '거래일시' || cell === '거래일'));
17
55
  if (headerIndex < 0)
18
56
  throw new shell_1.Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format');
19
- const header = rows[headerIndex];
57
+ const header = headerOf(rows[headerIndex]);
58
+ const format = BANK_FORMATS.find(one => columnsOf(one).every(column => header.includes(column)));
59
+ if (!format)
60
+ throw new shell_1.Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format');
20
61
  const metadata = rows.slice(0, headerIndex).map(row => row.join(' ')).join('\n');
21
62
  const accountNumber = /계좌번호\s*:\s*([\d-]+)/.exec(metadata)?.[1];
22
63
  if (!accountNumber)
23
64
  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);
65
+ const bankName = format.bankName;
66
+ const at = (raw, column) => (column ? raw[header.indexOf(column)] : null);
67
+ const text = (value) => (value == null || String(value).trim() === '' ? null : String(value).trim());
31
68
  const money = (value) => {
32
69
  if (value == null || value === '')
33
70
  throw new shell_1.Refusal('bank-invalid-money', {}, 'Check the statement amounts and balances');
@@ -42,22 +79,23 @@ function parseBankRows(rows, timezoneFor = () => 'Asia/Seoul') {
42
79
  if (!sourceRows.length)
43
80
  throw new shell_1.Refusal('bank-empty-statement', {}, 'The statement has no transactions');
44
81
  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]);
82
+ const currency = format.currency ? String(text(at(raw, format.currency))) : 'KRW';
83
+ const time = (0, bank_source_1.bankTimestamp)(String(at(raw, format.time)), timezoneFor(currency));
84
+ const deposit = money(at(raw, format.deposit));
85
+ const withdrawal = money(at(raw, format.withdrawal));
48
86
  if (deposit < 0 || withdrawal < 0 || (deposit && withdrawal))
49
87
  throw new shell_1.Refusal('bank-invalid-direction', { row }, 'Check the bank deposit and withdrawal direction');
50
88
  const line = {
51
- row, bankSequence: foreign ? raw[0] : sourceRows.length + 1 - raw[0],
52
- currency: foreign ? String(raw[2]) : 'KRW', ...time,
89
+ row, bankSequence: format.newestFirst ? sourceRows.length + 1 - raw[0] : raw[0],
90
+ currency, ...time,
53
91
  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
92
+ balance: money(at(raw, format.balance)),
93
+ bankDescription: text(at(raw, format.description)),
94
+ bankKind: 'fixed' in format.kind ? format.kind.fixed : text(at(raw, format.kind.column)),
95
+ bankCounterpartyName: text(at(raw, format.counterpartyName)),
96
+ bankCounterpartyAccount: text(at(raw, format.counterpartyAccount)),
97
+ bankCounterpartyBank: text(at(raw, format.counterpartyBank)),
98
+ bankMemo: text(at(raw, format.memo)), fingerprint: '', raw
61
99
  };
62
100
  if (!/^[A-Z]{3}$/.test(line.currency))
63
101
  throw new shell_1.Refusal('bank-invalid-currency', { row }, 'Check the bank currency');
@@ -76,14 +114,14 @@ function parseBankRows(rows, timezoneFor = () => 'Asia/Seoul') {
76
114
  throw new shell_1.Refusal('bank-balance-gap', { row: line.row }, 'The bank balance does not follow the previous transaction');
77
115
  previous.set(line.currency, line);
78
116
  }
79
- const total = rows.find(row => row[1] === '합계');
80
- if (total && !foreign && !woori) {
81
- for (const [type, index] of [['EXPENSE', 2], ['INCOME', 3]]) {
117
+ const total = rows.find(row => row[header.indexOf(format.time)] === '합계');
118
+ if (total && format.totalRow) {
119
+ for (const [type, column] of [['EXPENSE', format.withdrawal], ['INCOME', format.deposit]]) {
82
120
  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)
121
+ if (Math.abs(sum - money(at(total, column))) > 0.005)
84
122
  throw new shell_1.Refusal('bank-total-mismatch', {}, 'The bank transaction total does not match');
85
123
  }
86
124
  }
87
- return { bankName, accountNumber, periodStart: periods[0]?.replace(/\./g, '-') ?? null, periodEnd: periods[1]?.replace(/\./g, '-') ?? null, rawRows: rows, lines };
125
+ return { bankName, accountNumber, ...statementPeriod(metadata), rawRows: rows, lines };
88
126
  }
89
127
  //# sourceMappingURL=bank-parser.js.map
@@ -1 +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"]}
1
+ {"version":3,"file":"bank-parser.js","sourceRoot":"","sources":["../../../server/service/bank-statement/bank-parser.ts"],"names":[],"mappings":";;AAwBA,8CAKC;AAiED,sCAiEC;AA/JD,+BAAkC;AAClC,+CAA8D;AAC9D,iDAA+C;AAsB/C,SAAgB,iBAAiB,CAAC,IAAY,EAAE,cAA4C,GAAG,EAAE,CAAC,YAAY;IAC5G,MAAM,QAAQ,GAAG,IAAA,WAAI,EAAC,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAA;IAC/C,IAAI,QAAQ,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,eAAO,CAAC,mBAAmB,EAAE,EAAE,EAAE,mCAAmC,CAAC,CAAA;IACrH,MAAM,IAAI,GAAG,YAAK,CAAC,aAAa,CAAQ,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;IAC7G,OAAO,aAAa,CAAC,IAAI,EAAE,WAAW,CAAC,CAAA;AACzC,CAAC;AAyBD,MAAM,YAAY,GAAiB;IACjC,0BAA0B;IAC1B;QACE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM;QACvG,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,gBAAgB,EAAE,UAAU,EAAE,mBAAmB,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM;QAC/G,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI;KAC9C;IACD,oBAAoB;IACpB;QACE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,IAAI;QACpH,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,gBAAgB,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK;KACtE;IACD,mBAAmB;IACnB;QACE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM;QAChH,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,gBAAgB,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI;KAChF;IACD,4BAA4B;IAC5B;QACE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM;QAC1H,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,gBAAgB,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI;KACpE;CACF,CAAA;AAED,MAAM,SAAS,GAAG,CAAC,MAAkB,EAAE,EAAE,CACvC,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS;IAC5J,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAoB,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;AAE7I,8GAA8G;AAC9G,SAAS,eAAe,CAAC,QAAgB;IACvC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAA,+BAA+B,CAAA;IACtD,MAAM,GAAG,GAAG,CAAC,KAA6B,EAAE,EAAU,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAC5H,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,cAAc,IAAI,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACrF,IAAI,KAAK;QAAE,OAAO,EAAE,WAAW,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,CAAA;IAC1E,OAAO;QACL,WAAW,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,gBAAgB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QAChF,SAAS,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,gBAAgB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;KAC/E,CAAA;AACH,CAAC;AAED,SAAgB,aAAa,CAAC,IAAa,EAAE,cAA4C,GAAG,EAAE,CAAC,YAAY;IACzG,MAAM,QAAQ,GAAG,CAAC,GAAU,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IACjG,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,KAAK,CAAC,CAAC,CAAA;IACxG,IAAI,WAAW,GAAG,CAAC;QAAE,MAAM,IAAI,eAAO,CAAC,yBAAyB,EAAE,EAAE,EAAE,mCAAmC,CAAC,CAAA;IAC1G,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAA;IAC1C,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAChG,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,eAAO,CAAC,yBAAyB,EAAE,EAAE,EAAE,mCAAmC,CAAC,CAAA;IAClG,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAChF,MAAM,aAAa,GAAG,qBAAqB,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/D,IAAI,CAAC,aAAa;QAAE,MAAM,IAAI,eAAO,CAAC,sBAAsB,EAAE,EAAE,EAAE,0CAA0C,CAAC,CAAA;IAC7G,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAA;IAChC,MAAM,EAAE,GAAG,CAAC,GAAU,EAAE,MAAe,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IACzF,MAAM,IAAI,GAAG,CAAC,KAAU,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,CAAA;IACzG,MAAM,KAAK,GAAG,CAAC,KAAU,EAAE,EAAE;QAC3B,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,EAAE;YAAE,MAAM,IAAI,eAAO,CAAC,oBAAoB,EAAE,EAAE,EAAE,0CAA0C,CAAC,CAAA;QAC1H,MAAM,CAAC,GAAG,MAAM,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;QACpF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,MAAM;YAAE,MAAM,IAAI,eAAO,CAAC,oBAAoB,EAAE,EAAE,EAAE,0CAA0C,CAAC,CAAA;QACpK,OAAO,CAAC,CAAA;IACV,CAAC,CAAA;IACD,MAAM,KAAK,GAAe,EAAE,CAAA;IAC5B,MAAM,WAAW,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC7C,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,GAAG,WAAW,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAA;IACnJ,IAAI,CAAC,UAAU,CAAC,MAAM;QAAE,MAAM,IAAI,eAAO,CAAC,sBAAsB,EAAE,EAAE,EAAE,mCAAmC,CAAC,CAAA;IAC1G,KAAK,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,UAAU,EAAE,CAAC;QACtC,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;QACjF,MAAM,IAAI,GAAG,IAAA,2BAAa,EAAC,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAA;QAC/E,MAAM,OAAO,GAAG,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAA;QAC9C,MAAM,UAAU,GAAG,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAA;QACpD,IAAI,OAAO,GAAG,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,CAAC,OAAO,IAAI,UAAU,CAAC;YAAE,MAAM,IAAI,eAAO,CAAC,wBAAwB,EAAE,EAAE,GAAG,EAAE,EAAE,iDAAiD,CAAC,CAAA;QACrK,MAAM,IAAI,GAAa;YACrB,GAAG,EAAE,YAAY,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC/E,QAAQ,EAAE,GAAG,IAAI;YACjB,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,IAAI,UAAU;YACnE,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;YACvC,eAAe,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC;YAClD,QAAQ,EAAE,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACxF,oBAAoB,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,gBAAgB,CAAC,CAAC;YAC5D,uBAAuB,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,mBAAmB,CAAC,CAAC;YAClE,oBAAoB,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,gBAAgB,CAAC,CAAC;YAC5D,QAAQ,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,WAAW,EAAE,EAAE,EAAE,GAAG;SAC3D,CAAA;QACD,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC;YAAE,MAAM,IAAI,eAAO,CAAC,uBAAuB,EAAE,EAAE,GAAG,EAAE,EAAE,yBAAyB,CAAC,CAAA;QACrH,MAAM,IAAI,GAAG,IAAA,6BAAe,EAAC,EAAE,GAAG,IAAI,EAAE,aAAa,EAAE,CAAC,CAAA;QACxD,MAAM,UAAU,GAAG,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC7C,WAAW,CAAC,GAAG,CAAC,IAAI,EAAE,UAAU,GAAG,CAAC,CAAC,CAAA;QACrC,IAAI,CAAC,WAAW,GAAG,IAAA,6BAAe,EAAC,EAAE,GAAG,IAAI,EAAE,aAAa,EAAE,EAAE,UAAU,CAAC,CAAA;QAC1E,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClB,CAAC;IACD,oFAAoF;IACpF,MAAM,MAAM,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,IAAI,CAAC,CAAC,eAAe,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,aAAa,IAAI,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,YAAY,GAAG,CAAC,CAAC,YAAY,CAAC,CAAA;IACvK,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAoB,CAAA;IAC5C,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;QAC1B,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QACzC,IAAI,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,KAAK;YACjH,MAAM,IAAI,eAAO,CAAC,kBAAkB,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,2DAA2D,CAAC,CAAA;QACvH,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;IACnC,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,CAAC,CAAA;IACzE,IAAI,KAAK,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC7B,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,OAAO,CAAC,CAAU,EAAE,CAAC;YACnG,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAA;YAC5F,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,KAAK;gBAAE,MAAM,IAAI,eAAO,CAAC,qBAAqB,EAAE,EAAE,EAAE,2CAA2C,CAAC,CAAA;QACjJ,CAAC;IACH,CAAC;IACD,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,GAAG,eAAe,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;AACxF,CAAC","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\n/**\n * A statement layout, known by its column headers. A column is read by its header, never its place; `kind` is a\n * header or, where the bank prints none, the fixed word the line is known by. `newestFirst`: the bank numbers its\n * latest line 1.\n */\ninterface BankFormat {\n bankName: string\n time: string\n currency?: string\n withdrawal: string\n deposit: string\n balance: string\n description: string\n kind: { column: string } | { fixed: string }\n counterpartyName?: string\n counterpartyAccount?: string\n counterpartyBank?: string\n memo?: string\n newestFirst: boolean\n /** The statement ends with a 합계 row whose withdrawal and deposit columns the lines must add up to. */\n totalRow?: boolean\n}\n\nconst BANK_FORMATS: BankFormat[] = [\n /* 기업은행 거래내역조회 (입출식 예금) */\n {\n bankName: '기업은행', time: '거래일시', withdrawal: '출금', deposit: '입금', balance: '거래후 잔액', description: '거래내용',\n kind: { column: '거래구분' }, counterpartyName: '상대계좌예금주명', counterpartyAccount: '상대계좌번호', counterpartyBank: '상대은행',\n memo: '메모', newestFirst: true, totalRow: true\n },\n /* 기업은행 외화 거래내역조회 */\n {\n bankName: '기업은행', time: '거래일시', currency: '통화', withdrawal: '출금', deposit: '입금', balance: '거래후잔액', description: '적요',\n kind: { fixed: '외화' }, counterpartyName: '해외수입업자', newestFirst: false\n },\n /* 우리은행 최근거래내역조회 */\n {\n bankName: '우리은행', time: '거래일시', withdrawal: '지급(원)', deposit: '입금(원)', balance: '거래후 잔액(원)', description: '기재내용',\n kind: { column: '적요' }, counterpartyName: '기재내용', memo: '메모', newestFirst: true\n },\n /* 우리은행 외화거래내역조회 (예금거래내역) */\n {\n bankName: '우리은행', time: '거래일', currency: '통화', withdrawal: '찾으신 금액', deposit: '맡기신 금액', balance: '잔액', description: '기재내용',\n kind: { column: '적요' }, counterpartyName: '기재내용', newestFirst: true\n }\n]\n\nconst columnsOf = (format: BankFormat) =>\n [format.time, format.currency, format.withdrawal, format.deposit, format.balance, format.description, 'column' in format.kind ? format.kind.column : undefined,\n format.counterpartyName, format.counterpartyAccount, format.counterpartyBank, format.memo].filter((column): column is string => !!column)\n\n/** The period the statement was queried for, as the bank labels it: 「조회기간 A ~ B」 or 「조회시작일자 A … 조회종료일자 B」. */\nfunction statementPeriod(metadata: string): { periodStart: string | null; periodEnd: string | null } {\n const date = String.raw`(\\d{4})[-.](\\d{2})[-.](\\d{2})`\n const iso = (match: RegExpExecArray | null, at: number) => (match ? `${match[at]}-${match[at + 1]}-${match[at + 2]}` : null)\n const range = new RegExp(String.raw`조회기간\\s*:\\s*${date}\\s*~\\s*${date}`).exec(metadata)\n if (range) return { periodStart: iso(range, 1), periodEnd: iso(range, 4) }\n return {\n periodStart: iso(new RegExp(String.raw`조회시작일자\\s*:\\s*${date}`).exec(metadata), 1),\n periodEnd: iso(new RegExp(String.raw`조회종료일자\\s*:\\s*${date}`).exec(metadata), 1)\n }\n}\n\nexport function parseBankRows(rows: any[][], timezoneFor: (currency: string) => string = () => 'Asia/Seoul') {\n const headerOf = (row: any[]) => row.map(cell => (typeof cell === 'string' ? cell.trim() : cell))\n const headerIndex = rows.findIndex(row => headerOf(row).some(cell => cell === '거래일시' || cell === '거래일'))\n if (headerIndex < 0) throw new Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format')\n const header = headerOf(rows[headerIndex])\n const format = BANK_FORMATS.find(one => columnsOf(one).every(column => header.includes(column)))\n if (!format) throw new Refusal('bank-unsupported-format', {}, 'Unsupported bank statement format')\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 bankName = format.bankName\n const at = (raw: any[], column?: string) => (column ? raw[header.indexOf(column)] : null)\n const text = (value: any) => (value == null || String(value).trim() === '' ? null : String(value).trim())\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 currency = format.currency ? String(text(at(raw, format.currency))) : 'KRW'\n const time = bankTimestamp(String(at(raw, format.time)), timezoneFor(currency))\n const deposit = money(at(raw, format.deposit))\n const withdrawal = money(at(raw, format.withdrawal))\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: format.newestFirst ? sourceRows.length + 1 - raw[0] : raw[0],\n currency, ...time,\n type: deposit ? 'INCOME' : 'EXPENSE', amount: deposit || withdrawal,\n balance: money(at(raw, format.balance)),\n bankDescription: text(at(raw, format.description)),\n bankKind: 'fixed' in format.kind ? format.kind.fixed : text(at(raw, format.kind.column)),\n bankCounterpartyName: text(at(raw, format.counterpartyName)),\n bankCounterpartyAccount: text(at(raw, format.counterpartyAccount)),\n bankCounterpartyBank: text(at(raw, format.counterpartyBank)),\n bankMemo: text(at(raw, format.memo)), 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[header.indexOf(format.time)] === '합계')\n if (total && format.totalRow) {\n for (const [type, column] of [['EXPENSE', format.withdrawal], ['INCOME', format.deposit]] as const) {\n const sum = lines.filter(line => line.type === type).reduce((n, line) => n + line.amount, 0)\n if (Math.abs(sum - money(at(total, column))) > 0.005) throw new Refusal('bank-total-mismatch', {}, 'The bank transaction total does not match')\n }\n }\n return { bankName, accountNumber, ...statementPeriod(metadata), rawRows: rows, lines }\n}\n"]}
@@ -3,6 +3,6 @@ import { ExpenditureRequestItem } from './expenditure-request-item';
3
3
  import { ExpenditureRequestItemQuery, ExpenditureRequestQuery } from './expenditure-request-query';
4
4
  import { ExpenditureRequestMutation } from './expenditure-request-mutation';
5
5
  import './expenditure-target';
6
- export declare const entities: (typeof ExpenditureRequestItem | typeof ExpenditureRequest)[];
6
+ export declare const entities: (typeof ExpenditureRequest | typeof ExpenditureRequestItem)[];
7
7
  export declare const resolvers: (typeof ExpenditureRequestQuery | typeof ExpenditureRequestItemQuery | typeof ExpenditureRequestMutation)[];
8
8
  export declare const subscribers: any[];