@accounter/scraper-app 0.0.1

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 (96) hide show
  1. package/README.md +90 -0
  2. package/docs/plan.md +76 -0
  3. package/index.html +12 -0
  4. package/package.json +40 -0
  5. package/src/env.template +2 -0
  6. package/src/server/__tests__/accounts-routes.test.ts +133 -0
  7. package/src/server/__tests__/check-accounts.test.ts +305 -0
  8. package/src/server/__tests__/filter-payload.test.ts +193 -0
  9. package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
  10. package/src/server/__tests__/graphql-client.test.ts +508 -0
  11. package/src/server/__tests__/healthz.test.ts +22 -0
  12. package/src/server/__tests__/history.test.ts +111 -0
  13. package/src/server/__tests__/otp-manager.test.ts +132 -0
  14. package/src/server/__tests__/scrape-runner.test.ts +144 -0
  15. package/src/server/__tests__/settings-routes.test.ts +117 -0
  16. package/src/server/__tests__/sources-routes.test.ts +149 -0
  17. package/src/server/__tests__/validate-payload.test.ts +193 -0
  18. package/src/server/__tests__/vault-routes.test.ts +174 -0
  19. package/src/server/__tests__/vault.test.ts +33 -0
  20. package/src/server/__tests__/websocket.test.ts +151 -0
  21. package/src/server/account-discovery.ts +49 -0
  22. package/src/server/accounts-routes.ts +74 -0
  23. package/src/server/check-accounts.ts +79 -0
  24. package/src/server/filter-payload.ts +145 -0
  25. package/src/server/graphql/client.ts +103 -0
  26. package/src/server/graphql/mutations.ts +518 -0
  27. package/src/server/history-routes.ts +11 -0
  28. package/src/server/history.ts +53 -0
  29. package/src/server/index.ts +40 -0
  30. package/src/server/otp-manager.ts +63 -0
  31. package/src/server/payload-schemas/amex.schema.ts +2 -0
  32. package/src/server/payload-schemas/cal.schema.ts +27 -0
  33. package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
  34. package/src/server/payload-schemas/discount.schema.ts +26 -0
  35. package/src/server/payload-schemas/isracard.schema.ts +58 -0
  36. package/src/server/payload-schemas/max.schema.ts +27 -0
  37. package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
  38. package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
  39. package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
  40. package/src/server/scrape-runner.ts +165 -0
  41. package/src/server/scrapers/__tests__/amex.test.ts +142 -0
  42. package/src/server/scrapers/__tests__/cal.test.ts +135 -0
  43. package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
  44. package/src/server/scrapers/__tests__/discount.test.ts +160 -0
  45. package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
  46. package/src/server/scrapers/__tests__/max.test.ts +115 -0
  47. package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
  48. package/src/server/scrapers/amex.ts +63 -0
  49. package/src/server/scrapers/cal.ts +56 -0
  50. package/src/server/scrapers/currency-rates.ts +64 -0
  51. package/src/server/scrapers/discount.ts +62 -0
  52. package/src/server/scrapers/isracard.ts +68 -0
  53. package/src/server/scrapers/max.ts +32 -0
  54. package/src/server/scrapers/poalim.ts +103 -0
  55. package/src/server/settings-routes.ts +27 -0
  56. package/src/server/sources-routes.ts +182 -0
  57. package/src/server/validate-payload.ts +74 -0
  58. package/src/server/vault-routes.ts +99 -0
  59. package/src/server/vault-store.ts +42 -0
  60. package/src/server/vault.ts +216 -0
  61. package/src/server/websocket.ts +454 -0
  62. package/src/shared/source-types.ts +10 -0
  63. package/src/shared/types.ts +20 -0
  64. package/src/shared/ws-protocol.ts +177 -0
  65. package/src/test-setup.ts +6 -0
  66. package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
  67. package/src/ui/__tests__/config.test.tsx +99 -0
  68. package/src/ui/__tests__/history.test.tsx +94 -0
  69. package/src/ui/__tests__/run.test.tsx +195 -0
  70. package/src/ui/__tests__/settings-tab.test.tsx +79 -0
  71. package/src/ui/__tests__/sources-tab.test.tsx +139 -0
  72. package/src/ui/__tests__/vault-setup.test.tsx +105 -0
  73. package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
  74. package/src/ui/app.tsx +109 -0
  75. package/src/ui/components/error-boundary.tsx +54 -0
  76. package/src/ui/components/otp-modal.tsx +82 -0
  77. package/src/ui/components/skeleton.tsx +58 -0
  78. package/src/ui/components/task-row.tsx +241 -0
  79. package/src/ui/contexts/vault-context.tsx +77 -0
  80. package/src/ui/lib/api.ts +117 -0
  81. package/src/ui/lib/ws.ts +137 -0
  82. package/src/ui/main.tsx +9 -0
  83. package/src/ui/screens/config/accounts-tab.tsx +185 -0
  84. package/src/ui/screens/config/config.tsx +163 -0
  85. package/src/ui/screens/config/settings-tab.tsx +167 -0
  86. package/src/ui/screens/config/source-forms.tsx +518 -0
  87. package/src/ui/screens/config/source-types.ts +91 -0
  88. package/src/ui/screens/config/sources-tab.tsx +176 -0
  89. package/src/ui/screens/history.tsx +234 -0
  90. package/src/ui/screens/run.tsx +266 -0
  91. package/src/ui/screens/vault-setup.tsx +120 -0
  92. package/src/ui/screens/vault-unlock.tsx +38 -0
  93. package/tsconfig.json +15 -0
  94. package/tsup.config.ts +10 -0
  95. package/vite.config.ts +24 -0
  96. package/vitest.config.ts +7 -0
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+
3
+ // Matches CalTransaction from @accounter/modern-poalim-scraper
4
+ const CalTransactionSchema = z
5
+ .object({
6
+ trnIntId: z.string(),
7
+ merchantName: z.string(),
8
+ trnPurchaseDate: z.string(),
9
+ trnAmt: z.number(),
10
+ trnCurrencySymbol: z.string(),
11
+ trnType: z.string(),
12
+ debCrdDate: z.string(),
13
+ amtBeforeConvAndIndex: z.number(),
14
+ debCrdCurrencySymbol: z.string(),
15
+ })
16
+ .loose();
17
+
18
+ // One entry per (card, month) pair scraped
19
+ const CalMonthResultSchema = z.object({
20
+ card: z.string(),
21
+ month: z.string(),
22
+ transactions: z.array(CalTransactionSchema),
23
+ });
24
+
25
+ export const CalPayloadSchema = z.array(CalMonthResultSchema);
26
+
27
+ export type CalPayload = z.infer<typeof CalPayloadSchema>;
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+
3
+ const CurrencyRateEntrySchema = z.object({
4
+ date: z.string(),
5
+ currency: z.enum(['USD', 'EUR', 'GBP', 'CAD', 'JPY', 'AUD', 'SEK']),
6
+ rate: z.number(),
7
+ });
8
+
9
+ export const CurrencyRatesPayloadSchema = z.array(CurrencyRateEntrySchema);
10
+
11
+ export type CurrencyRatesPayload = z.infer<typeof CurrencyRatesPayloadSchema>;
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+
3
+ // Matches DiscountTransaction from @accounter/modern-poalim-scraper
4
+ const DiscountTransactionSchema = z
5
+ .object({
6
+ OperationDate: z.string(),
7
+ ValueDate: z.string(),
8
+ OperationCode: z.string(),
9
+ OperationDescription: z.string(),
10
+ OperationAmount: z.number(),
11
+ BalanceAfterOperation: z.number(),
12
+ OperationNumber: z.number(),
13
+ })
14
+ .loose();
15
+
16
+ // One entry per (accountNumber, month) pair scraped
17
+ const DiscountMonthResultSchema = z.object({
18
+ accountNumber: z.string(),
19
+ month: z.string(),
20
+ balance: z.number(),
21
+ transactions: z.array(DiscountTransactionSchema),
22
+ });
23
+
24
+ export const DiscountPayloadSchema = z.array(DiscountMonthResultSchema);
25
+
26
+ export type DiscountPayload = z.infer<typeof DiscountPayloadSchema>;
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+
3
+ const TxnIsraelSchema = z
4
+ .object({
5
+ cardIndex: z.string(),
6
+ supplierName: z.string().nullable(),
7
+ dealSum: z.string().nullable(),
8
+ fullPurchaseDate: z.string().nullable(),
9
+ purchaseDate: z.string().nullable(),
10
+ voucherNumber: z.string().nullable(),
11
+ voucherNumberRatz: z.string(),
12
+ })
13
+ .loose();
14
+
15
+ const TxnAbroadSchema = z
16
+ .object({
17
+ cardIndex: z.string(),
18
+ fullSupplierNameOutbound: z.string(),
19
+ dealSumOutbound: z.string().nullable(),
20
+ fullPurchaseDateOutbound: z.string().nullable(),
21
+ paymentSumOutbound: z.string(),
22
+ voucherNumberRatzOutbound: z.string(),
23
+ })
24
+ .loose();
25
+
26
+ const CurrentCardTransactionsSchema = z
27
+ .object({
28
+ txnIsrael: z.array(TxnIsraelSchema).nullable().optional(),
29
+ txnAbroad: z.array(TxnAbroadSchema).nullable().optional(),
30
+ })
31
+ .loose();
32
+
33
+ const IndexSchema = z
34
+ .object({
35
+ '@AllCards': z.string(),
36
+ CurrentCardTransactions: z.array(CurrentCardTransactionsSchema),
37
+ })
38
+ .loose();
39
+
40
+ const HeaderSchema = z
41
+ .object({
42
+ Status: z.string(),
43
+ Message: z.string().nullable(),
44
+ })
45
+ .loose();
46
+
47
+ // CardsTransactionsListBean contains Index0, Index1, Index2, … for each card.
48
+ // We require Index0 (always present) and capture additional Index* keys via catchall.
49
+ const CardsTransactionsListBeanSchema = z
50
+ .object({ cardNumberList: z.array(z.string()), Index0: IndexSchema })
51
+ .catchall(z.union([IndexSchema, z.unknown()]));
52
+
53
+ export const IsracardPayloadSchema = z
54
+ .object({
55
+ Header: HeaderSchema,
56
+ CardsTransactionsListBean: CardsTransactionsListBeanSchema,
57
+ })
58
+ .loose();
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+
3
+ // Matches MaxTransaction from @accounter/modern-poalim-scraper
4
+ const MaxTransactionSchema = z
5
+ .object({
6
+ cardIndex: z.number(),
7
+ categoryId: z.number(),
8
+ merchantName: z.string(),
9
+ originalAmount: z.number(),
10
+ originalCurrency: z.string(),
11
+ purchaseDate: z.string(),
12
+ uid: z.string(),
13
+ planName: z.string(),
14
+ planTypeId: z.number(),
15
+ })
16
+ .loose();
17
+
18
+ // Matches TransactionsAccount (MaxScrapingResult element)
19
+ const MaxAccountSchema = z.object({
20
+ accountNumber: z.string(),
21
+ txns: z.array(MaxTransactionSchema),
22
+ });
23
+
24
+ // MaxScrapingResult = TransactionsAccount[]
25
+ export const MaxPayloadSchema = z.array(MaxAccountSchema);
26
+
27
+ export type MaxPayload = z.infer<typeof MaxPayloadSchema>;
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+
3
+ const ForeignTransactionSchema = z
4
+ .object({
5
+ activityDescription: z.string(),
6
+ activityTypeCode: z.number(),
7
+ eventAmount: z.number(),
8
+ currencySwiftCode: z.string(),
9
+ currencyRate: z.number(),
10
+ currentBalance: z.number(),
11
+ referenceNumber: z.number(),
12
+ transactionType: z.string(),
13
+ })
14
+ .loose();
15
+
16
+ const BalancesAndLimitsItemSchema = z
17
+ .object({
18
+ currencySwiftCode: z.string(),
19
+ currencyCode: z.number(),
20
+ transactions: z.array(ForeignTransactionSchema),
21
+ })
22
+ .loose();
23
+
24
+ export const PoalimForeignPayloadSchema = z
25
+ .object({
26
+ balancesAndLimitsDataList: z.array(BalancesAndLimitsItemSchema),
27
+ })
28
+ .loose();
29
+
30
+ export type PoalimForeignPayload = z.infer<typeof PoalimForeignPayloadSchema>;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+
3
+ const IlsTransactionSchema = z
4
+ .object({
5
+ activityDescription: z.string(),
6
+ activityTypeCode: z.number(),
7
+ eventAmount: z.number(),
8
+ eventDate: z.number(),
9
+ serialNumber: z.number(),
10
+ transactionType: z.enum(['REGULAR', 'TODAY', 'FUTURE']),
11
+ currentBalance: z.number(),
12
+ referenceNumber: z.number(),
13
+ })
14
+ .loose();
15
+
16
+ const RetrievalTransactionDataSchema = z
17
+ .object({
18
+ accountNumber: z.number(),
19
+ branchNumber: z.number(),
20
+ bankNumber: z.number(),
21
+ })
22
+ .loose();
23
+
24
+ export const PoalimIlsPayloadSchema = z
25
+ .object({
26
+ transactions: z.array(IlsTransactionSchema),
27
+ retrievalTransactionData: RetrievalTransactionDataSchema,
28
+ })
29
+ .loose();
30
+
31
+ export type PoalimIlsPayload = z.infer<typeof PoalimIlsPayloadSchema>;
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+
3
+ const SwiftItemSchema = z
4
+ .object({
5
+ startDate: z.number(),
6
+ swiftStatusCode: z.string(),
7
+ amount: z.number(),
8
+ currencyCodeCatenatedKey: z.string(),
9
+ chargePartyName: z.string(),
10
+ referenceNumber: z.string(),
11
+ transferCatenatedId: z.string(),
12
+ })
13
+ .loose();
14
+
15
+ export const PoalimSwiftPayloadSchema = z
16
+ .object({
17
+ swiftsList: z.array(SwiftItemSchema),
18
+ })
19
+ .loose();
20
+
21
+ export type PoalimSwiftPayload = z.infer<typeof PoalimSwiftPayloadSchema>;
@@ -0,0 +1,165 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { RunRecord as SerializedRunRecord, SourceRunRecord } from '../shared/types.js';
3
+ import type { ServerMessage } from '../shared/ws-protocol.js';
4
+ import type { ScraperUploadResult } from './gql/index.js';
5
+
6
+ export const ERR_RUN_IN_PROGRESS = 'Run already in progress';
7
+
8
+ /**
9
+ * Thrown by buildTask when a source is blocked (unknown accounts).
10
+ * Signals runTask to skip task-done and task-error — task-blocked was already emitted.
11
+ */
12
+ export class BlockedError extends Error {
13
+ readonly blockedAccounts: string[];
14
+ constructor(blockedAccounts: string[]) {
15
+ super('blocked');
16
+ this.name = 'BlockedError';
17
+ this.blockedAccounts = blockedAccounts;
18
+ }
19
+ }
20
+
21
+ // ── Types ─────────────────────────────────────────────────────────────────────
22
+
23
+ export type ScrapeTask = {
24
+ sourceId: string;
25
+ nickname: string;
26
+ type: string;
27
+ run: () => Promise<ScraperUploadResult>;
28
+ };
29
+
30
+ export type { SourceRunRecord };
31
+ export type RunRecord = Omit<SerializedRunRecord, 'startedAt' | 'completedAt'> & {
32
+ startedAt: Date;
33
+ completedAt: Date;
34
+ };
35
+
36
+ // ── State ─────────────────────────────────────────────────────────────────────
37
+
38
+ let _running = false;
39
+
40
+ export function isRunning(): boolean {
41
+ return _running;
42
+ }
43
+
44
+ export function _resetRunState(): void {
45
+ _running = false;
46
+ }
47
+
48
+ // ── Runner ────────────────────────────────────────────────────────────────────
49
+
50
+ export async function startRun(
51
+ tasks: ScrapeTask[],
52
+ concurrent: boolean,
53
+ emit: (msg: ServerMessage) => void,
54
+ ): Promise<RunRecord> {
55
+ if (_running) throw new Error(ERR_RUN_IN_PROGRESS);
56
+ _running = true;
57
+
58
+ const id = randomUUID();
59
+ const startedAt = new Date();
60
+ let errorCount = 0;
61
+
62
+ try {
63
+ for (const task of tasks) {
64
+ emit({ type: 'task-pending', sourceId: task.sourceId });
65
+ }
66
+
67
+ type TaskOutcome = {
68
+ task: ScrapeTask;
69
+ result: ScraperUploadResult;
70
+ status: 'done' | 'error' | 'blocked';
71
+ error?: string;
72
+ blockedAccounts?: string[];
73
+ };
74
+
75
+ const runTask = async (task: ScrapeTask): Promise<TaskOutcome> => {
76
+ emit({ type: 'task-running', sourceId: task.sourceId });
77
+ try {
78
+ const result = await task.run();
79
+ emit({
80
+ type: 'task-done',
81
+ sourceId: task.sourceId,
82
+ inserted: result.inserted,
83
+ skipped: result.skipped,
84
+ insertedIds: result.insertedIds,
85
+ ...(result.insertedTransactions != null && {
86
+ insertedTransactions: result.insertedTransactions,
87
+ }),
88
+ ...(result.changedTransactions != null && {
89
+ changedTransactions: result.changedTransactions,
90
+ }),
91
+ });
92
+ return { task, result, status: 'done' };
93
+ } catch (e) {
94
+ // task-blocked was already emitted by buildTask — skip task-done and task-error
95
+ if (e instanceof BlockedError) {
96
+ return {
97
+ task,
98
+ result: {
99
+ inserted: 0,
100
+ skipped: 0,
101
+ insertedIds: [],
102
+ insertedTransactions: [],
103
+ changedTransactions: [],
104
+ },
105
+ status: 'blocked',
106
+ blockedAccounts: e.blockedAccounts,
107
+ };
108
+ }
109
+ errorCount++;
110
+ const message = e instanceof Error ? e.message : String(e);
111
+ const stack = e instanceof Error ? e.stack : undefined;
112
+ emit({ type: 'task-error', sourceId: task.sourceId, message, ...(stack && { stack }) });
113
+ return {
114
+ task,
115
+ result: {
116
+ inserted: 0,
117
+ skipped: 0,
118
+ insertedIds: [],
119
+ insertedTransactions: [],
120
+ changedTransactions: [],
121
+ },
122
+ status: 'error',
123
+ error: message,
124
+ };
125
+ }
126
+ };
127
+
128
+ let outcomes: TaskOutcome[];
129
+ if (concurrent) {
130
+ outcomes = await Promise.all(tasks.map(runTask));
131
+ } else {
132
+ outcomes = [];
133
+ for (const task of tasks) {
134
+ outcomes.push(await runTask(task));
135
+ }
136
+ }
137
+
138
+ const totalInserted = outcomes.reduce((sum, o) => sum + o.result.inserted, 0);
139
+ const totalSkipped = outcomes.reduce((sum, o) => sum + o.result.skipped, 0);
140
+ const sources: SourceRunRecord[] = outcomes.map(o => ({
141
+ sourceId: o.task.sourceId,
142
+ nickname: o.task.nickname,
143
+ sourceType: o.task.type,
144
+ status: o.status,
145
+ inserted: o.result.inserted,
146
+ skipped: o.result.skipped,
147
+ ...(o.error != null && { error: o.error }),
148
+ ...(o.blockedAccounts != null && { blockedAccounts: o.blockedAccounts }),
149
+ }));
150
+
151
+ emit({ type: 'run-complete', totalInserted, totalSkipped, errors: errorCount });
152
+
153
+ return {
154
+ id,
155
+ startedAt,
156
+ completedAt: new Date(),
157
+ totalInserted,
158
+ totalSkipped,
159
+ errorCount,
160
+ sources,
161
+ };
162
+ } finally {
163
+ _running = false;
164
+ }
165
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PayloadValidationError } from '../../validate-payload.js';
3
+ import { scrapeAmex } from '../amex.js';
4
+
5
+ const CREDS = { id: 'src-1', ownerId: '123456789', password: 'pass', last6Digits: '123456' };
6
+
7
+ const VALID_PAYLOAD = {
8
+ Header: { Status: '1', Message: null },
9
+ CardsTransactionsListBean: {
10
+ cardNumberList: ['010101'],
11
+ Index0: { '@AllCards': 'AllCards', CurrentCardTransactions: [] },
12
+ },
13
+ };
14
+
15
+ const DATE_FROM = new Date('2024-01-01');
16
+ const DATE_TO = new Date('2024-01-31');
17
+ const noop = () => {};
18
+
19
+ vi.mock('@accounter/modern-poalim-scraper', () => ({
20
+ init: vi.fn(),
21
+ }));
22
+
23
+ async function getInitMock() {
24
+ const mod = await import('@accounter/modern-poalim-scraper');
25
+ return mod.init as ReturnType<typeof vi.fn>;
26
+ }
27
+
28
+ describe('scrapeAmex — happy path', () => {
29
+ it('resolves with an array of validated AmexPayloads', async () => {
30
+ const initMock = await getInitMock();
31
+ initMock.mockResolvedValue({
32
+ amex: vi.fn().mockResolvedValue({
33
+ getMonthTransactions: vi.fn().mockResolvedValue({ data: VALID_PAYLOAD, isValid: true }),
34
+ }),
35
+ close: vi.fn().mockResolvedValue(undefined),
36
+ });
37
+
38
+ const result = await scrapeAmex(CREDS, DATE_FROM, DATE_TO, noop);
39
+ expect(Array.isArray(result)).toBe(true);
40
+ expect(result).toHaveLength(1);
41
+ expect(result[0]!.Header.Status).toBe('1');
42
+ });
43
+
44
+ it('fetches one month per month in the date range', async () => {
45
+ const getMonthTransactions = vi
46
+ .fn()
47
+ .mockResolvedValue({ data: VALID_PAYLOAD, isValid: true });
48
+ const initMock = await getInitMock();
49
+ initMock.mockResolvedValue({
50
+ amex: vi.fn().mockResolvedValue({ getMonthTransactions }),
51
+ close: vi.fn().mockResolvedValue(undefined),
52
+ });
53
+
54
+ const threeMonthsFrom = new Date('2024-01-01');
55
+ const threeMonthsTo = new Date('2024-03-31');
56
+ await scrapeAmex(CREDS, threeMonthsFrom, threeMonthsTo, noop);
57
+
58
+ expect(getMonthTransactions).toHaveBeenCalledTimes(3);
59
+ });
60
+
61
+ it('skips months where data is null', async () => {
62
+ const initMock = await getInitMock();
63
+ initMock.mockResolvedValue({
64
+ amex: vi.fn().mockResolvedValue({
65
+ getMonthTransactions: vi.fn().mockResolvedValue({ data: null, isValid: null }),
66
+ }),
67
+ close: vi.fn().mockResolvedValue(undefined),
68
+ });
69
+
70
+ const result = await scrapeAmex(CREDS, DATE_FROM, DATE_TO, noop);
71
+ expect(result).toEqual([]);
72
+ });
73
+
74
+ it('calls close() even on success', async () => {
75
+ const close = vi.fn().mockResolvedValue(undefined);
76
+ const initMock = await getInitMock();
77
+ initMock.mockResolvedValue({
78
+ amex: vi.fn().mockResolvedValue({
79
+ getMonthTransactions: vi.fn().mockResolvedValue({ data: VALID_PAYLOAD, isValid: true }),
80
+ }),
81
+ close,
82
+ });
83
+
84
+ await scrapeAmex(CREDS, DATE_FROM, DATE_TO, noop);
85
+ expect(close).toHaveBeenCalledOnce();
86
+ });
87
+ });
88
+
89
+ describe('scrapeAmex — Header.Status check', () => {
90
+ it('throws when Header.Status is not "1"', async () => {
91
+ const initMock = await getInitMock();
92
+ initMock.mockResolvedValue({
93
+ amex: vi.fn().mockResolvedValue({
94
+ getMonthTransactions: vi.fn().mockResolvedValue({
95
+ data: { ...VALID_PAYLOAD, Header: { Status: '2', Message: 'Replace password' } },
96
+ isValid: true,
97
+ }),
98
+ }),
99
+ close: vi.fn().mockResolvedValue(undefined),
100
+ });
101
+
102
+ await expect(scrapeAmex(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow(
103
+ 'login/password issue',
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('scrapeAmex — invalid payload', () => {
109
+ it('throws PayloadValidationError when data fails schema validation', async () => {
110
+ const initMock = await getInitMock();
111
+ initMock.mockResolvedValue({
112
+ amex: vi.fn().mockResolvedValue({
113
+ getMonthTransactions: vi.fn().mockResolvedValue({
114
+ data: { Header: { Status: '1', Message: null }, unexpected: 'shape' },
115
+ isValid: true,
116
+ }),
117
+ }),
118
+ close: vi.fn().mockResolvedValue(undefined),
119
+ });
120
+
121
+ await expect(scrapeAmex(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toBeInstanceOf(
122
+ PayloadValidationError,
123
+ );
124
+ });
125
+
126
+ it('calls close() even on validation error', async () => {
127
+ const close = vi.fn().mockResolvedValue(undefined);
128
+ const initMock = await getInitMock();
129
+ initMock.mockResolvedValue({
130
+ amex: vi.fn().mockResolvedValue({
131
+ getMonthTransactions: vi.fn().mockResolvedValue({
132
+ data: { Header: { Status: '1', Message: null }, unexpected: 'shape' },
133
+ isValid: true,
134
+ }),
135
+ }),
136
+ close,
137
+ });
138
+
139
+ await expect(scrapeAmex(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow();
140
+ expect(close).toHaveBeenCalledOnce();
141
+ });
142
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PayloadValidationError } from '../../validate-payload.js';
3
+ import { scrapeCal } from '../cal.js';
4
+
5
+ const CREDS = { id: 'src-1', username: 'user', password: 'pass', last4Digits: '1234' };
6
+
7
+ const DATE_FROM = new Date('2024-01-01');
8
+ const DATE_TO = new Date('2024-01-31');
9
+ const noop = () => {};
10
+
11
+ vi.mock('@accounter/modern-poalim-scraper', () => ({
12
+ init: vi.fn(),
13
+ }));
14
+
15
+ async function getInitMock() {
16
+ const mod = await import('@accounter/modern-poalim-scraper');
17
+ return mod.init as ReturnType<typeof vi.fn>;
18
+ }
19
+
20
+ describe('scrapeCal — happy path', () => {
21
+ it('resolves with a validated CalPayload containing one entry per month', async () => {
22
+ const initMock = await getInitMock();
23
+ initMock.mockResolvedValue({
24
+ cal: vi.fn().mockResolvedValue({
25
+ getMonthTransactions: vi.fn().mockResolvedValue([]),
26
+ }),
27
+ close: vi.fn().mockResolvedValue(undefined),
28
+ });
29
+
30
+ const result = await scrapeCal(CREDS, DATE_FROM, DATE_TO, noop);
31
+ expect(Array.isArray(result)).toBe(true);
32
+ expect(result).toHaveLength(1);
33
+ expect(result[0]).toMatchObject({ card: '1234', month: '2024-01', transactions: [] });
34
+ });
35
+
36
+ it('fetches one month per month in the date range', async () => {
37
+ const getMonthTransactions = vi.fn().mockResolvedValue([]);
38
+ const initMock = await getInitMock();
39
+ initMock.mockResolvedValue({
40
+ cal: vi.fn().mockResolvedValue({ getMonthTransactions }),
41
+ close: vi.fn().mockResolvedValue(undefined),
42
+ });
43
+
44
+ const threeMonthsFrom = new Date('2024-01-01');
45
+ const threeMonthsTo = new Date('2024-03-31');
46
+ const result = await scrapeCal(CREDS, threeMonthsFrom, threeMonthsTo, noop);
47
+
48
+ expect(getMonthTransactions).toHaveBeenCalledTimes(3);
49
+ expect(result).toHaveLength(3);
50
+ expect(result.map(r => r.month)).toEqual(['2024-01', '2024-02', '2024-03']);
51
+ });
52
+
53
+ it('passes last4Digits and month correctly to getMonthTransactions', async () => {
54
+ const getMonthTransactions = vi.fn().mockResolvedValue([]);
55
+ const initMock = await getInitMock();
56
+ initMock.mockResolvedValue({
57
+ cal: vi.fn().mockResolvedValue({ getMonthTransactions }),
58
+ close: vi.fn().mockResolvedValue(undefined),
59
+ });
60
+
61
+ await scrapeCal(CREDS, DATE_FROM, DATE_TO, noop);
62
+
63
+ expect(getMonthTransactions).toHaveBeenCalledWith('1234', expect.any(Date));
64
+ });
65
+
66
+ it('calls close() even on success', async () => {
67
+ const close = vi.fn().mockResolvedValue(undefined);
68
+ const initMock = await getInitMock();
69
+ initMock.mockResolvedValue({
70
+ cal: vi.fn().mockResolvedValue({ getMonthTransactions: vi.fn().mockResolvedValue([]) }),
71
+ close,
72
+ });
73
+
74
+ await scrapeCal(CREDS, DATE_FROM, DATE_TO, noop);
75
+ expect(close).toHaveBeenCalledOnce();
76
+ });
77
+ });
78
+
79
+ describe('scrapeCal — transactions included', () => {
80
+ it('includes transactions returned by getMonthTransactions', async () => {
81
+ const mockTxn = {
82
+ trnIntId: 'txn-1',
83
+ merchantName: 'Shop',
84
+ trnPurchaseDate: '2024-01-15',
85
+ trnAmt: 100,
86
+ trnCurrencySymbol: 'ILS',
87
+ trnType: 'normal',
88
+ debCrdDate: '2024-02-01',
89
+ amtBeforeConvAndIndex: 100,
90
+ debCrdCurrencySymbol: 'ILS',
91
+ };
92
+ const initMock = await getInitMock();
93
+ initMock.mockResolvedValue({
94
+ cal: vi.fn().mockResolvedValue({
95
+ getMonthTransactions: vi.fn().mockResolvedValue([mockTxn]),
96
+ }),
97
+ close: vi.fn().mockResolvedValue(undefined),
98
+ });
99
+
100
+ const result = await scrapeCal(CREDS, DATE_FROM, DATE_TO, noop);
101
+ expect(result[0]!.transactions).toHaveLength(1);
102
+ expect(result[0]!.transactions[0]!.merchantName).toBe('Shop');
103
+ });
104
+ });
105
+
106
+ describe('scrapeCal — invalid payload', () => {
107
+ it('throws PayloadValidationError when a transaction fails schema validation', async () => {
108
+ const initMock = await getInitMock();
109
+ initMock.mockResolvedValue({
110
+ cal: vi.fn().mockResolvedValue({
111
+ // Return a transaction missing required fields
112
+ getMonthTransactions: vi.fn().mockResolvedValue([{ trnIntId: 'x' }]),
113
+ }),
114
+ close: vi.fn().mockResolvedValue(undefined),
115
+ });
116
+
117
+ await expect(scrapeCal(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toBeInstanceOf(
118
+ PayloadValidationError,
119
+ );
120
+ });
121
+
122
+ it('calls close() even on validation error', async () => {
123
+ const close = vi.fn().mockResolvedValue(undefined);
124
+ const initMock = await getInitMock();
125
+ initMock.mockResolvedValue({
126
+ cal: vi.fn().mockResolvedValue({
127
+ getMonthTransactions: vi.fn().mockResolvedValue([{ trnIntId: 'x' }]),
128
+ }),
129
+ close,
130
+ });
131
+
132
+ await expect(scrapeCal(CREDS, DATE_FROM, DATE_TO, noop)).rejects.toThrow();
133
+ expect(close).toHaveBeenCalledOnce();
134
+ });
135
+ });