UNPKG

@accounter/scraper-app

Version:
505 lines (470 loc) 23.3 kB
import { z } from 'zod'; /** * Asserts the `Account.Execution[]` payload before `poalimSecuritiesTransactionsVars` * maps it onto the upload mutation. The object is `strictObject`, so an unmodelled * bank-side field fails here instead of reaching the mutation unchecked, and every * constraint carries a message naming the field, the offending value and the fix. * * This mirrors `HapoalimSecuritiesTransactionsSchema` in * `@accounter/modern-poalim-scraper` on purpose: the scraper validates what the bank * sent, this validates what we are about to upload. Keep the two in step — * `__tests__/validate-payload.test.ts` covers this copy. * * Field names are the bank's own, misspellings included (`IsraeTaxValue`, * `PeymentPecentage`, `TradeCurrnecyRate`, `LastTranactionDate`, * `FundPlusAccumulatedInerestValue`). */ /** Renders an unexpected value for an error message without ever throwing. */ function describeInput(input: unknown): string { if (input === undefined) return 'nothing (the key is missing)'; if (typeof input === 'string' || typeof input === 'number' || typeof input === 'boolean') { return JSON.stringify(input); } if (input === null) return 'null'; if (Array.isArray(input)) return `an array of ${input.length}`; return typeof input; } const quoted = (values: readonly string[]) => values.map(value => `"${value}"`).join(', '); /** * A closed set of labels the bank sends. Extending one of these lists is the * intended fix when the message below fires — the values are the bank's * vocabulary, not ours. */ function poalimEnum<const T extends readonly [string, ...string[]]>( field: string, values: T, constant: string, ) { return z.enum(values, { error: issue => `${field}: unexpected value ${describeInput(issue.input)}. Known values are ${quoted(values)} — if the bank started sending another, add it to ${constant} (here and in the scraper's schema).`, }); } /** `z.number()` already rejects NaN and Infinity, so a plain number is finite. */ function poalimNumber(field: string) { return z.number({ error: issue => `${field}: expected a finite number, got ${describeInput(issue.input)}.`, }); } /** Amounts, quantities and prices the bank never sends below zero. */ function nonNegativeNumber(field: string) { return poalimNumber(field).min(0, { error: issue => `${field}: expected a value of 0 or more, got ${describeInput(issue.input)}.`, }); } /** A rate expressed in percent — 23 means 23%, never 0.23. */ function percentNumber(field: string) { return poalimNumber(field) .min(0, { error: issue => `${field}: expected a percentage of 0 or more, got ${describeInput(issue.input)}.`, }) .max(100, { error: issue => `${field}: expected a percentage of at most 100 — ${describeInput(issue.input)} suggests the bank switched to a fraction (0-1) or another unit.`, }); } function nonEmptyString(field: string, description: string) { return z .string({ error: issue => `${field}: expected ${description}, got ${describeInput(issue.input)}.`, }) .min(1, { error: `${field}: expected ${description}, got an empty string.` }); } /** * `.NET` round-trip timestamps: `2026-08-11T00:00:00.0000000+03:00`. Only the * shape is enforced — `ExecutionDate` uses the offset-less * `0001-01-01T00:00:00.0000000` sentinel, hence the optional offset. */ const POALIM_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}([+-]\d{2}:\d{2})?$/; function poalimDateTime(field: string) { return z .string({ error: issue => `${field}: expected a date string, got ${describeInput(issue.input)}.`, }) .regex(POALIM_DATE_TIME_RE, { error: issue => `${field}: ${describeInput(issue.input)} is not a Poalim timestamp (expected yyyy-MM-ddTHH:mm:ss.fffffff with an optional ±hh:mm offset).`, }); } /** * Null in every observed row. Typed as `null` rather than "optional string" on * purpose: if the bank starts populating it we want to hear about it and decide * how to store it, not quietly drop the value. */ function alwaysNull(field: string) { return z.null({ error: issue => `${field}: was null in every observed response and is typed as null, but this one carries ${describeInput(issue.input)}. Give the field a real type here, in the scraper's schema, in PoalimSecurityTransactionInput and in the poalim_securities_transactions column.`, }); } // ── The bank's closed vocabularies ──────────────────────────────────────────── /** Answers to the bank's yes/no flags. */ const HEBREW_YES_NO = ['כן', 'לא'] as const; const TRANSACTION_TYPES = ['קניה', 'מכירה', 'תשלומים ואירועי חברה', 'העברות'] as const; const TRADE_TYPES = [ 'קניה', 'מכירה', 'דבידנד תשלום', 'ריבית תשלום', 'פדיון', 'הטבה חלוקת מניות', 'העברה לזכות הפקדון', 'העברה לחובת הפקדון', 'העברה לחובת הפקדון (דו צדדית)', // Not yet observed, but the bank pairs every "לחובת" transfer with a "לזכות" // one; listed so the credit side of a two-sided transfer does not fail a scrape. 'העברה לזכות הפקדון (דו צדדית)', ] as const; /** The `TradeType`s that mean a position was bought or sold outright. */ const TRADING_TRADE_TYPES: ReadonlySet<string> = new Set(['קניה', 'מכירה']); /** Set on corporate actions; note `פידיון` here vs `פדיון` in TRADE_TYPES. */ const PAYMENT_TYPES = [ 'דיבידנד', 'דיבידנד בעין', 'ריבית', 'פידיון', 'פקיעה', 'איחוד מניות', 'הצעת רכש כפויה', ] as const; /** * The bank spells currencies out in Hebrew. Every value here must also be * mapped in `formatCurrency` (`packages/server/src/shared/helpers/amount.ts`), * which turns the label into a `Currency` when the executions are read back — * widening this list alone only moves the failure downstream. */ const CURRENCIES = [ 'שקל חדש', // ILS 'דולר ארה"ב', // USD 'אירו', // EUR 'לירה שטרלינג', // GBP 'ין יפני', // JPY ] as const; const SECURITY_GROUPS = [ 'מניות ניע"ז', 'אג"ח ממשלתי ניע"ז', 'אג"ח קונצרני ניע"ז', 'תעודת סל ETF ניע"ז', 'קרן נאמנות', 'קרנות נאמנות זרות', ] as const; /** Only tradable securities were observed; see the message on the enum. */ const TRADABILITY = ['סחיר'] as const; const ExecutionFields = z.strictObject({ // ── identity (the fields the upload dedup key is built from) ──────────────── /** Poalim's security number; joins to the reference feed's `-Key`. */ Security: nonEmptyString('Security', "the bank's security number").regex(/^\d{7,8}$/, { error: issue => `Security: expected a 7- or 8-digit Poalim security number, got ${describeInput(issue.input)}.`, }), TradeDate: poalimDateTime('TradeDate'), ValueDate: poalimDateTime('ValueDate').nullable(), SettlementDate: poalimDateTime('SettlementDate').nullable(), TradeType: poalimEnum('TradeType', TRADE_TYPES, 'TRADE_TYPES'), TransactionType: poalimEnum('TransactionType', TRANSACTION_TYPES, 'TRANSACTION_TYPES'), /** Nominal value / quantity. Zero on corporate actions that move no units. */ NV: nonNegativeNumber('NV').nullable(), TradePrice: nonNegativeNumber('TradePrice').nullable(), /** Signed: negative on a buy, positive on a sale or a payment received. */ NetValueTradeCurrency: poalimNumber('NetValueTradeCurrency').nullable(), PaymentType: poalimEnum('PaymentType', PAYMENT_TYPES, 'PAYMENT_TYPES').nullable(), PaymentDate: poalimDateTime('PaymentDate').nullable(), ExDate: poalimDateTime('ExDate').nullable(), CancelDate: poalimDateTime('CancelDate').nullable(), // ── account ───────────────────────────────────────────────────────────────── // Branch/Account are asserted but not uploaded: the vars mapper stamps the // requested account instead, and the cross-field check below proves they agree. Branch: poalimNumber('Branch') .int() .positive({ error: issue => `Branch: expected a positive branch number, got ${describeInput(issue.input)}.`, }), Account: poalimNumber('Account') .int() .positive({ error: issue => `Account: expected a positive account number, got ${describeInput(issue.input)}.`, }), AccountName: nonEmptyString('AccountName', "the account holder's name").nullable(), /** The branch that executed the order — often a trading desk, not the account's. */ ExecutingBranch: nonEmptyString('ExecutingBranch', 'a numeric branch string') .regex(/^\d+$/, { error: issue => `ExecutingBranch: expected digits only, got ${describeInput(issue.input)}.`, }) .nullable(), FinancialAccountBranch: nonEmptyString('FinancialAccountBranch', 'a numeric branch string') .regex(/^\d+$/, { error: issue => `FinancialAccountBranch: expected digits only, got ${describeInput(issue.input)}.`, }) .nullable(), FinancialAccountNumber: nonEmptyString('FinancialAccountNumber', 'a numeric account string') .regex(/^\d+$/, { error: issue => `FinancialAccountNumber: expected digits only, got ${describeInput(issue.input)}.`, }) .nullable(), // ── security descriptors ──────────────────────────────────────────────────── /** ISO 6166: 2-letter prefix, 9 alphanumerics, check digit. */ ISIN: nonEmptyString('ISIN', 'an ISIN') .regex(/^[A-Z]{2}[A-Z0-9]{9}\d$/, { error: issue => `ISIN: ${describeInput(issue.input)} is not a 12-character ISIN (2 letters, 9 alphanumerics, 1 check digit).`, }) .nullable(), Symbol: nonEmptyString('Symbol', 'a ticker symbol').max(30, { error: issue => `Symbol: expected at most 30 characters, got ${describeInput(issue.input)}.`, }), SymbolOSI: nonEmptyString('SymbolOSI', 'an exchange-qualified ticker') .max(30, { error: issue => `SymbolOSI: expected at most 30 characters, got ${describeInput(issue.input)}.`, }) .nullable(), // The bank truncates names to 30 characters and sends the same text for the // Hebrew and English variants (see the cross-field check below). EngName: nonEmptyString('EngName', 'a security name').max(30, { error: issue => `EngName: expected at most 30 characters, got ${describeInput(issue.input)}.`, }), HebName: nonEmptyString('HebName', 'a security name').max(30, { error: issue => `HebName: expected at most 30 characters, got ${describeInput(issue.input)}.`, }), EngNameFull: nonEmptyString('EngNameFull', 'a security name').max(30, { error: issue => `EngNameFull: expected at most 30 characters, got ${describeInput(issue.input)}.`, }), HebNameFull: nonEmptyString('HebNameFull', 'a security name').max(30, { error: issue => `HebNameFull: expected at most 30 characters, got ${describeInput(issue.input)}.`, }), SecurityGroup: poalimEnum('SecurityGroup', SECURITY_GROUPS, 'SECURITY_GROUPS'), SecuritySubGroup: alwaysNull('SecuritySubGroup'), IssueCurrency: poalimEnum('IssueCurrency', CURRENCIES, 'CURRENCIES'), // Country and exchange names are open-ended (one per market the portfolio // reaches), so they are shape-checked rather than enumerated. IssuerCountry: nonEmptyString('IssuerCountry', 'a country name in Hebrew'), IssuerCountryCode: nonEmptyString('IssuerCountryCode', 'an ISO 3166-1 alpha-2 code').regex( /^[A-Z]{2}$/, { error: issue => `IssuerCountryCode: expected two upper-case letters, got ${describeInput(issue.input)}.`, }, ), IssuerExchange: nonEmptyString('IssuerExchange', 'an exchange name'), ExchangeCountry: nonEmptyString('ExchangeCountry', 'a country name in Hebrew'), IsTradable: poalimEnum('IsTradable', TRADABILITY, 'TRADABILITY'), IsUSEquity: poalimEnum('IsUSEquity', HEBREW_YES_NO, 'HEBREW_YES_NO'), IsJumbo: z.boolean({ error: issue => `IsJumbo: expected a boolean, got ${describeInput(issue.input)}.`, }), /** Units of the underlying per contract; 1 for everything but derivatives. */ ImpliedAssetMult: poalimNumber('ImpliedAssetMult').positive({ error: issue => `ImpliedAssetMult: expected a positive multiplier, got ${describeInput(issue.input)}.`, }), ExpiryDate: alwaysNull('ExpiryDate'), // ── values, commissions and taxes ─────────────────────────────────────────── TradeCurrency: poalimEnum('TradeCurrency', CURRENCIES, 'CURRENCIES'), SettlementCurrency: poalimEnum('SettlementCurrency', CURRENCIES, 'CURRENCIES'), CommissionsCurrency: poalimEnum('CommissionsCurrency', CURRENCIES, 'CURRENCIES'), PaymentCurrency: alwaysNull('PaymentCurrency'), TradeGrossValueTradeCurrency: nonNegativeNumber('TradeGrossValueTradeCurrency'), TradeGrossValueNIS: nonNegativeNumber('TradeGrossValueNIS'), /** Signed — the net cash effect of the execution. */ NetValueNIS: poalimNumber('NetValueNIS'), NetValueSettlementCurrency: poalimNumber('NetValueSettlementCurrency'), SettlementPrice: nonNegativeNumber('SettlementPrice'), TradeCommissionPercent: percentNumber('TradeCommissionPercent'), // Commission values go negative when the bank refunds one. TradeCommissionValueNIS: poalimNumber('TradeCommissionValueNIS'), TradeCommissionValueTradeCurrency: poalimNumber('TradeCommissionValueTradeCurrency'), AgentCommissionValueTradeCurrency: nonNegativeNumber('AgentCommissionValueTradeCurrency'), ManagementFeesPercent: percentNumber('ManagementFeesPercent'), ManagementFeesValueNIS: nonNegativeNumber('ManagementFeesValueNIS'), ManagementFeesValueTradeCurrency: nonNegativeNumber('ManagementFeesValueTradeCurrency'), // Tax values are signed: a refund of previously withheld tax is negative. IsraeTaxValue: poalimNumber('IsraeTaxValue'), IsraelTaxPercent: percentNumber('IsraelTaxPercent'), IsraelTaxValueByPaymentsSettlementCurrency: poalimNumber( 'IsraelTaxValueByPaymentsSettlementCurrency', ), ForeignTaxPercent: percentNumber('ForeignTaxPercent'), ForeignTaxValueSettlementCurrency: poalimNumber('ForeignTaxValueSettlementCurrency'), CapitalTaxPercent: percentNumber('CapitalTaxPercent'), CapitalTaxValueSettlementCurrency: poalimNumber('CapitalTaxValueSettlementCurrency'), PostDeductionTaxValueNIS: nonNegativeNumber('PostDeductionTaxValueNIS'), PreviousActionsDeductions: poalimNumber('PreviousActionsDeductions'), PostActionDeductionBalance: nonNegativeNumber('PostActionDeductionBalance'), NominalProfitLossNIS: poalimNumber('NominalProfitLossNIS'), NominalProfitLossLinkage: poalimNumber('NominalProfitLossLinkage'), RealProfitLossNIS: poalimNumber('RealProfitLossNIS'), AccumulatedInterest: nonNegativeNumber('AccumulatedInterest'), FundPlusAccumulatedInerestValue: nonNegativeNumber('FundPlusAccumulatedInerestValue'), /** Payment per unit held, not a percentage despite the name. */ PeymentPecentage: nonNegativeNumber('PeymentPecentage'), PaymentLinkingValue: nonNegativeNumber('PaymentLinkingValue'), ExDateBalance: nonNegativeNumber('ExDateBalance'), IssueCurrencyToTradeCurrencyRate: nonNegativeNumber('IssueCurrencyToTradeCurrencyRate'), TradeCurrnecyRate: nonNegativeNumber('TradeCurrnecyRate'), /** * The customer's own rate, or the sentinel -1 when the bank has none. Any * other negative value would be a real rate with the wrong sign. */ PersonalCurrencyRate: poalimNumber('PersonalCurrencyRate').refine( value => value === -1 || value > 0, { error: issue => `PersonalCurrencyRate: expected a positive rate or the sentinel -1, got ${describeInput(issue.input)}.`, }, ), // ── order / execution metadata ────────────────────────────────────────────── PaymentName: alwaysNull('PaymentName'), /** Set only for orders routed from an external trading system. */ OrderOrigin: nonEmptyString('OrderOrigin', 'an order origin').nullable(), OrderType: alwaysNull('OrderType'), OrderSubject: alwaysNull('OrderSubject'), // The order-side fields are zero throughout this feed: it reports executions, // and the originating order lives in a different endpoint. OrderedNV: nonNegativeNumber('OrderedNV'), ExecutedNV: nonNegativeNumber('ExecutedNV'), OrderedValue: nonNegativeNumber('OrderedValue'), ExecutionDate: poalimDateTime('ExecutionDate'), LastTranactionDate: poalimDateTime('LastTranactionDate').nullable(), IsCancelTransaction: poalimEnum('IsCancelTransaction', HEBREW_YES_NO, 'HEBREW_YES_NO'), }); /** * Invariants the bank's own data obeys across fields. They are what makes a * half-understood response fail loudly instead of landing in the database as a * plausible-looking row. */ const ExecutionItemSchema = ExecutionFields.superRefine((row, ctx) => { // A buy/sell names its own trade type, in both directions: the pair decides // whether a row moves a position, so a mismatch would misread the trade. // // Nothing stronger is asserted about the other categories. The bank files the // same transfer TradeType under `תשלומים ואירועי חברה` on one account and under // `העברות` on another, so which non-trading bucket a TradeType belongs to is // the bank's presentation choice, not an invariant. const isTradingTransaction = TRADING_TRADE_TYPES.has(row.TransactionType); const isTradingTrade = TRADING_TRADE_TYPES.has(row.TradeType); if (isTradingTransaction !== isTradingTrade) { ctx.addIssue({ code: 'custom', path: ['TradeType'], message: `TradeType "${row.TradeType}" and TransactionType "${row.TransactionType}" disagree on whether this row is a trade — one names a buy/sell and the other does not.`, }); } else if (isTradingTransaction && row.TradeType !== row.TransactionType) { ctx.addIssue({ code: 'custom', path: ['TradeType'], message: `TradeType "${row.TradeType}" contradicts TransactionType "${row.TransactionType}" — a buy cannot be a sale.`, }); } // The payment block travels together — all three set, or all three null. A // half-filled block would silently break the upload dedup key, which is // built from exactly these fields. Which categories carry one is left open: // corporate actions always do and trades never do, but transfers vary. const paymentFields = ['PaymentType', 'PaymentDate', 'ExDate'] as const; const setPaymentFields = paymentFields.filter(field => row[field] !== null); if (setPaymentFields.length > 0 && setPaymentFields.length < paymentFields.length) { for (const field of paymentFields) { if (row[field] === null) { ctx.addIssue({ code: 'custom', path: [field], message: `${field} is null while ${setPaymentFields.join(' and ')} ${setPaymentFields.length === 1 ? 'is' : 'are'} set — the bank fills PaymentType, PaymentDate and ExDate together.`, }); } } } if (isTradingTrade && setPaymentFields.length > 0) { ctx.addIssue({ code: 'custom', path: ['PaymentType'], message: `${setPaymentFields.join(', ')} set on a plain "${row.TradeType}" execution, where the bank leaves the payment fields null.`, }); } // A cancelled execution must say when it was cancelled (the reverse does not // hold: a cancellation date also appears on the rows that supersede one). if (row.IsCancelTransaction === 'כן' && row.CancelDate === null) { ctx.addIssue({ code: 'custom', path: ['CancelDate'], message: 'CancelDate is null although IsCancelTransaction is "כן" (cancelled).', }); } // The vars mapper stamps the requested account onto every row rather than // reading these; if they disagree, the upload would file the execution under // an account it did not happen in. if (row.FinancialAccountBranch !== null && Number(row.FinancialAccountBranch) !== row.Branch) { ctx.addIssue({ code: 'custom', path: ['FinancialAccountBranch'], message: `FinancialAccountBranch "${row.FinancialAccountBranch}" does not match Branch ${row.Branch}.`, }); } if (row.FinancialAccountNumber !== null && Number(row.FinancialAccountNumber) !== row.Account) { ctx.addIssue({ code: 'custom', path: ['FinancialAccountNumber'], message: `FinancialAccountNumber "${row.FinancialAccountNumber}" does not match Account ${row.Account}.`, }); } // One currency per execution: the bank quotes, settles and issues in the same // one, and the uploaded amounts are only comparable while that holds. if (row.TradeCurrency !== row.SettlementCurrency) { ctx.addIssue({ code: 'custom', path: ['SettlementCurrency'], message: `SettlementCurrency "${row.SettlementCurrency}" differs from TradeCurrency "${row.TradeCurrency}" — a cross-currency settlement needs an explicit conversion before upload.`, }); } if (row.IssueCurrency !== row.TradeCurrency) { ctx.addIssue({ code: 'custom', path: ['IssueCurrency'], message: `IssueCurrency "${row.IssueCurrency}" differs from TradeCurrency "${row.TradeCurrency}" — IssueCurrencyToTradeCurrencyRate would have to be applied.`, }); } // Both name fields carry the same text; if they ever diverge we are storing // one language in two columns and losing the other. if (row.EngName !== row.HebName) { ctx.addIssue({ code: 'custom', path: ['HebName'], message: `HebName "${row.HebName}" differs from EngName "${row.EngName}" — the bank has started sending genuinely localised names.`, }); } if ((row.IsUSEquity === 'כן') !== (row.IssuerCountryCode === 'US')) { ctx.addIssue({ code: 'custom', path: ['IsUSEquity'], message: `IsUSEquity "${row.IsUSEquity}" disagrees with IssuerCountryCode "${row.IssuerCountryCode}".`, }); } }); /** * `Account.PageState` is the bank's opaque paging cursor and is not consumed — * the endpoint is queried with an explicit date range instead. */ export const PoalimSecuritiesTransactionsPayloadSchema = z.strictObject({ Account: z.strictObject({ PageState: z .string({ error: issue => `Account.PageState: expected a string, got ${describeInput(issue.input)}.`, }) .nullable() .optional(), // A portfolio with no activity in the range omits this entirely. Execution: z .array(ExecutionItemSchema, { error: issue => `Account.Execution: expected a list, got ${describeInput(issue.input)}.`, }) .default([]), }), }); export type PoalimSecuritiesTransactionsPayload = z.infer< typeof PoalimSecuritiesTransactionsPayloadSchema >;