@accounter/server
Version:
Accounter GraphQL server
1,597 lines • 54.1 kB
text/typescript
import { Injectable, Scope } from 'graphql-modules';
import { sql } from '@pgtyped/runtime';
import type {
ChangedField,
ChangedTransaction,
InsertedTransactionSummary,
PoalimForeignTransactionInput,
PoalimIlsTransactionInput,
PoalimSecurityInput,
PoalimSecurityTransactionInput,
PoalimSwiftTransactionInput,
ScraperUploadResult,
} from '../../../__generated__/types.js';
import { TIMELESS_DATE_REGEX } from '../../../shared/constants.js';
import { dateToTimelessDateString } from '../../../shared/helpers/index.js';
import type { TimelessDateString } from '../../../shared/types/index.js';
import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js';
import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js';
import { formatValue } from '../helpers/utils.helper.js';
import {
validatePoalimForeignTransactions,
validatePoalimIlsTransactions,
validatePoalimSecurities,
validatePoalimSecuritiesTransactions,
validatePoalimSwiftTransactions,
} from '../helpers/validators.helper.js';
import type {
IFetchPoalimForeignByKeysQuery,
IFetchPoalimForeignByKeysResult,
IFetchPoalimIlsByKeysQuery,
IFetchPoalimIlsByKeysResult,
IFetchPoalimSecuritiesByKeysQuery,
IFetchPoalimSecuritiesByKeysResult,
IFetchPoalimSecuritiesTransactionsByKeysQuery,
IFetchPoalimSecuritiesTransactionsByKeysResult,
IFetchPoalimSwiftByIdsQuery,
IFetchPoalimSwiftByIdsResult,
IUploadPoalimForeignTransactionsParams,
IUploadPoalimForeignTransactionsQuery,
IUploadPoalimForeignTransactionsResult,
IUploadPoalimIlsTransactionsParams,
IUploadPoalimIlsTransactionsQuery,
IUploadPoalimIlsTransactionsResult,
IUploadPoalimSecuritiesParams,
IUploadPoalimSecuritiesQuery,
IUploadPoalimSecuritiesResult,
IUploadPoalimSecuritiesTransactionsParams,
IUploadPoalimSecuritiesTransactionsQuery,
IUploadPoalimSecuritiesTransactionsResult,
IUploadPoalimSwiftTransactionsParams,
IUploadPoalimSwiftTransactionsQuery,
IUploadPoalimSwiftTransactionsResult,
} from '../types.js';
const fetchPoalimIlsByKeys = sql<IFetchPoalimIlsByKeysQuery>`
SELECT
id,
event_date,
serial_number,
account_number,
branch_number,
activity_description,
activity_type_code,
text_code,
reference_number,
reference_catenated_number,
value_date,
event_amount,
event_activity_type_code,
current_balance,
internal_link_code,
transaction_type,
data_group_code,
executing_branch_number,
event_id,
details,
pfm_details,
different_date_indication,
rejected_data_event_pertaining_indication,
contra_bank_number,
contra_branch_number,
contra_account_number,
contra_account_type_code,
comment,
beneficiary_details_data_party_name,
beneficiary_details_data_message_headline,
beneficiary_details_data_party_headline,
beneficiary_details_data_message_detail
FROM accounter_schema.poalim_ils_account_transactions
WHERE account_number = ANY($accountNumbers!)
AND branch_number = ANY($branchNumbers!)
AND event_date = ANY($eventDates!)
`;
const uploadPoalimIlsTransactions = sql<IUploadPoalimIlsTransactionsQuery>`
INSERT INTO accounter_schema.poalim_ils_account_transactions (
event_date,
formatted_event_date,
serial_number,
activity_type_code,
activity_description,
text_code,
reference_number,
reference_catenated_number,
value_date,
formatted_value_date,
event_amount,
event_activity_type_code,
current_balance,
internal_link_code,
original_event_create_date,
formatted_original_event_create_date,
transaction_type,
data_group_code,
beneficiary_details_data,
expanded_event_date,
executing_branch_number,
event_id,
details,
pfm_details,
different_date_indication,
rejected_data_event_pertaining_indication,
table_number,
record_number,
contra_bank_number,
contra_branch_number,
contra_account_number,
contra_account_type_code,
marketing_offer_context,
comment_existence_switch,
english_action_desc,
field_desc_display_switch,
url_address_niar,
offer_activity_context,
comment,
beneficiary_details_data_party_name,
beneficiary_details_data_message_headline,
beneficiary_details_data_party_headline,
beneficiary_details_data_message_detail,
beneficiary_details_data_table_number,
beneficiary_details_data_record_number,
activity_description_include_value_date,
bank_number,
branch_number,
account_number
)
VALUES $$transactions(
eventDate,
formattedEventDate,
serialNumber,
activityTypeCode,
activityDescription,
textCode,
referenceNumber,
referenceCatenatedNumber,
valueDate,
formattedValueDate,
eventAmount,
eventActivityTypeCode,
currentBalance,
internalLinkCode,
originalEventCreateDate,
formattedOriginalEventCreateDate,
transactionType,
dataGroupCode,
beneficiaryDetailsData,
expandedEventDate,
executingBranchNumber,
eventId,
details,
pfmDetails,
differentDateIndication,
rejectedDataEventPertainingIndication,
tableNumber,
recordNumber,
contraBankNumber,
contraBranchNumber,
contraAccountNumber,
contraAccountTypeCode,
marketingOfferContext,
commentExistenceSwitch,
englishActionDesc,
fieldDescDisplaySwitch,
urlAddressNiar,
offerActivityContext,
comment,
beneficiaryDetailsDataPartyName,
beneficiaryDetailsDataMessageHeadline,
beneficiaryDetailsDataPartyHeadline,
beneficiaryDetailsDataMessageDetail,
beneficiaryDetailsDataTableNumber,
beneficiaryDetailsDataRecordNumber,
activityDescriptionIncludeValueDate,
bankNumber,
branchNumber,
accountNumber
)
ON CONFLICT (event_date, serial_number, account_number, branch_number) DO NOTHING
RETURNING id, event_date, serial_number, account_number, branch_number, activity_description, event_amount;
`;
const fetchPoalimForeignByKeys = sql<IFetchPoalimForeignByKeysQuery>`
SELECT
id,
executing_date,
account_number,
branch_number,
event_number,
activity_description,
event_amount,
currency,
current_balance,
reference_catenated_number,
reference_number,
currency_rate,
event_details,
rate_fixing_code,
contra_currency_code,
event_activity_type_code,
transaction_type,
rate_fixing_short_description,
currency_long_description,
activity_type_code,
comments,
account_name,
contra_bank_number,
contra_branch_number,
contra_account_number,
contra_account_field_name_lable,
rate_fixing_description,
currency_swift_code
FROM accounter_schema.poalim_foreign_account_transactions
WHERE account_number = ANY($accountNumbers!)
AND branch_number = ANY($branchNumbers!)
AND executing_date = ANY($executingDates!)
`;
const fetchPoalimSwiftByIds = sql<IFetchPoalimSwiftByIdsQuery>`
SELECT
id,
transfer_catenated_id,
account_number,
branch_number,
bank_number,
start_date,
swift_status_code,
swift_status_desc,
amount,
currency_code_catenated_key,
currency_long_description,
charge_party_name,
reference_number,
data_origin_code,
order_customer_name,
beneficiary_english_street_name,
beneficiary_english_city_name,
beneficiary_english_country_name,
swift_remittance_information_70,
swift_details_of_charges_71a,
swift_senders_charges_71f
FROM accounter_schema.poalim_swift_account_transactions
WHERE transfer_catenated_id = ANY($transferCatenatedIds!)
`;
const fetchPoalimSecuritiesByKeys = sql<IFetchPoalimSecuritiesByKeysQuery>`
SELECT
id,
bank_number,
branch_number,
account_number,
as_of_date,
security_key,
eng_name,
heb_name,
item_type,
is_etf,
is_foreign,
currency_code,
exchange,
equity_type,
allowed_order_direction,
equity_sub_type,
eng_symbol,
heb_symbol,
symbol,
expiration_date,
stock_type,
creation_equity_num,
contract_type
FROM accounter_schema.poalim_securities
WHERE bank_number = ANY($bankNumbers!)
AND branch_number = ANY($branchNumbers!)
AND account_number = ANY($accountNumbers!)
AND security_key = ANY($securityKeys!)
`;
const uploadPoalimSecurities = sql<IUploadPoalimSecuritiesQuery>`
INSERT INTO accounter_schema.poalim_securities (
bank_number,
branch_number,
account_number,
as_of_date,
security_key,
eng_name,
heb_name,
item_type,
is_etf,
is_foreign,
currency_code,
exchange,
equity_type,
allowed_order_direction,
equity_sub_type,
eng_symbol,
heb_symbol,
symbol,
expiration_date,
stock_type,
creation_equity_num,
contract_type,
owner_id
)
VALUES $$securities(
bankNumber,
branchNumber,
accountNumber,
asOfDate,
securityKey,
engName,
hebName,
itemType,
isEtf,
isForeign,
currencyCode,
exchange,
equityType,
allowedOrderDirection,
equitySubType,
engSymbol,
hebSymbol,
symbol,
expirationDate,
stockType,
creationEquityNum,
contractType,
ownerId
)
ON CONFLICT (owner_id, bank_number, branch_number, account_number, security_key) DO NOTHING
RETURNING id, bank_number, branch_number, account_number, security_key, eng_name, as_of_date;
`;
const fetchPoalimSecuritiesTransactionsByKeys = sql<IFetchPoalimSecuritiesTransactionsByKeysQuery>`
SELECT
id,
bank_number,
branch_number,
account_number,
security,
trade_date,
value_date,
settlement_date,
trade_type,
transaction_type,
nv,
trade_price,
net_value_trade_currency,
payment_type,
payment_date,
ex_date,
cancel_date,
isin,
symbol,
eng_name,
heb_name,
security_group,
trade_currency,
settlement_currency,
trade_gross_value_trade_currency,
trade_gross_value_nis,
net_value_nis,
net_value_settlement_currency,
trade_commission_value_nis,
israe_tax_value,
foreign_tax_value_settlement_currency,
capital_tax_value_settlement_currency,
is_cancel_transaction
FROM accounter_schema.poalim_securities_transactions
WHERE bank_number = ANY($bankNumbers!)
AND branch_number = ANY($branchNumbers!)
AND account_number = ANY($accountNumbers!)
AND security = ANY($securities!)
AND trade_date = ANY($tradeDates!)
`;
const uploadPoalimSecuritiesTransactions = sql<IUploadPoalimSecuritiesTransactionsQuery>`
INSERT INTO accounter_schema.poalim_securities_transactions (
owner_id,
bank_number,
branch_number,
account_number,
security,
trade_date,
value_date,
settlement_date,
trade_type,
transaction_type,
nv,
trade_price,
net_value_trade_currency,
payment_type,
payment_date,
ex_date,
cancel_date,
isin,
symbol,
symbol_osi,
eng_name,
heb_name,
eng_name_full,
heb_name_full,
security_group,
security_sub_group,
issue_currency,
issuer_country,
issuer_country_code,
issuer_exchange,
exchange_country,
is_tradable,
is_us_equity,
is_jumbo,
implied_asset_mult,
expiry_date,
trade_currency,
settlement_currency,
commissions_currency,
payment_currency,
trade_gross_value_trade_currency,
trade_gross_value_nis,
net_value_nis,
net_value_settlement_currency,
settlement_price,
trade_commission_percent,
trade_commission_value_nis,
trade_commission_value_trade_currency,
agent_commission_value_trade_currency,
management_fees_percent,
management_fees_value_nis,
management_fees_value_trade_currency,
israe_tax_value,
israel_tax_percent,
israel_tax_value_by_payments_settlement_currency,
foreign_tax_percent,
foreign_tax_value_settlement_currency,
capital_tax_percent,
capital_tax_value_settlement_currency,
post_deduction_tax_value_nis,
previous_actions_deductions,
post_action_deduction_balance,
nominal_profit_loss_nis,
nominal_profit_loss_linkage,
real_profit_loss_nis,
accumulated_interest,
fund_plus_accumulated_inerest_value,
peyment_pecentage,
payment_linking_value,
ex_date_balance,
issue_currency_to_trade_currency_rate,
trade_currnecy_rate,
personal_currency_rate,
payment_name,
order_origin,
order_type,
order_subject,
ordered_nv,
executed_nv,
ordered_value,
execution_date,
last_tranaction_date,
is_cancel_transaction,
executing_branch,
financial_account_branch,
financial_account_number,
account_name
)
VALUES $$transactions(
ownerId,
bankNumber,
branchNumber,
accountNumber,
security,
tradeDate,
valueDate,
settlementDate,
tradeType,
transactionType,
nv,
tradePrice,
netValueTradeCurrency,
paymentType,
paymentDate,
exDate,
cancelDate,
isin,
symbol,
symbolOsi,
engName,
hebName,
engNameFull,
hebNameFull,
securityGroup,
securitySubGroup,
issueCurrency,
issuerCountry,
issuerCountryCode,
issuerExchange,
exchangeCountry,
isTradable,
isUsEquity,
isJumbo,
impliedAssetMult,
expiryDate,
tradeCurrency,
settlementCurrency,
commissionsCurrency,
paymentCurrency,
tradeGrossValueTradeCurrency,
tradeGrossValueNis,
netValueNis,
netValueSettlementCurrency,
settlementPrice,
tradeCommissionPercent,
tradeCommissionValueNis,
tradeCommissionValueTradeCurrency,
agentCommissionValueTradeCurrency,
managementFeesPercent,
managementFeesValueNis,
managementFeesValueTradeCurrency,
israeTaxValue,
israelTaxPercent,
israelTaxValueByPaymentsSettlementCurrency,
foreignTaxPercent,
foreignTaxValueSettlementCurrency,
capitalTaxPercent,
capitalTaxValueSettlementCurrency,
postDeductionTaxValueNis,
previousActionsDeductions,
postActionDeductionBalance,
nominalProfitLossNis,
nominalProfitLossLinkage,
realProfitLossNis,
accumulatedInterest,
fundPlusAccumulatedInerestValue,
peymentPecentage,
paymentLinkingValue,
exDateBalance,
issueCurrencyToTradeCurrencyRate,
tradeCurrnecyRate,
personalCurrencyRate,
paymentName,
orderOrigin,
orderType,
orderSubject,
orderedNv,
executedNv,
orderedValue,
executionDate,
lastTranactionDate,
isCancelTransaction,
executingBranch,
financialAccountBranch,
financialAccountNumber,
accountName
)
ON CONFLICT (
owner_id,
bank_number,
branch_number,
account_number,
security,
trade_date,
value_date,
settlement_date,
trade_type,
transaction_type,
nv,
trade_price,
net_value_trade_currency,
payment_type,
payment_date,
ex_date,
cancel_date
) DO NOTHING
RETURNING id, trade_date, trade_type, eng_name, net_value_trade_currency, branch_number, account_number;
`;
const uploadPoalimForeignTransactions = sql<IUploadPoalimForeignTransactionsQuery>`
INSERT INTO accounter_schema.poalim_foreign_account_transactions (
metadata_attributes_original_event_key,
metadata_attributes_contra_branch_number,
metadata_attributes_contra_account_number,
metadata_attributes_contra_bank_number,
metadata_attributes_contra_account_field_name_lable,
metadata_attributes_data_group_code,
metadata_attributes_currency_rate,
metadata_attributes_contra_currency_code,
metadata_attributes_rate_fixing_code,
executing_date,
formatted_executing_date,
value_date,
formatted_value_date,
original_system_id,
activity_description,
event_amount,
currency,
current_balance,
reference_catenated_number,
reference_number,
currency_rate,
event_details,
rate_fixing_code,
contra_currency_code,
event_activity_type_code,
transaction_type,
rate_fixing_short_description,
currency_long_description,
activity_type_code,
event_number,
validity_date,
comments,
comment_existence_switch,
account_name,
contra_bank_number,
contra_branch_number,
contra_account_number,
original_event_key,
contra_account_field_name_lable,
data_group_code,
rate_fixing_description,
url_address_niar,
currency_swift_code,
url_address,
bank_number,
branch_number,
account_number
)
VALUES $$transactions(
metadataAttributesOriginalEventKey,
metadataAttributesContraBranchNumber,
metadataAttributesContraAccountNumber,
metadataAttributesContraBankNumber,
metadataAttributesContraAccountFieldNameLable,
metadataAttributesDataGroupCode,
metadataAttributesCurrencyRate,
metadataAttributesContraCurrencyCode,
metadataAttributesRateFixingCode,
executingDate,
formattedExecutingDate,
valueDate,
formattedValueDate,
originalSystemId,
activityDescription,
eventAmount,
currency,
currentBalance,
referenceCatenatedNumber,
referenceNumber,
currencyRate,
eventDetails,
rateFixingCode,
contraCurrencyCode,
eventActivityTypeCode,
transactionType,
rateFixingShortDescription,
currencyLongDescription,
activityTypeCode,
eventNumber,
validityDate,
comments,
commentExistenceSwitch,
accountName,
contraBankNumber,
contraBranchNumber,
contraAccountNumber,
originalEventKey,
contraAccountFieldNameLable,
dataGroupCode,
rateFixingDescription,
urlAddressNiar,
currencySwiftCode,
urlAddress,
bankNumber,
branchNumber,
accountNumber
)
ON CONFLICT (executing_date, account_number, branch_number, event_number) DO NOTHING
RETURNING id, executing_date, account_number, branch_number, event_number, activity_description, event_amount;
`;
const uploadPoalimSwiftTransactions = sql<IUploadPoalimSwiftTransactionsQuery>`
INSERT INTO accounter_schema.poalim_swift_account_transactions (
account_number,
branch_number,
bank_number,
start_date,
formatted_start_date,
swift_status_code,
swift_status_desc,
amount,
currency_code_catenated_key,
currency_long_description,
charge_party_name,
reference_number,
transfer_catenated_id,
data_origin_code,
swift_isn_serial_number,
swift_bank_code,
order_customer_name,
beneficiary_english_street_name,
beneficiary_english_city_name,
beneficiary_english_country_name,
swift_senders_reference_20,
swift_bank_operation_code_23B,
swift_instruction_code_23e,
swift_value_date_currency_amount_32A,
swift_currency_instructed_amount_33B,
swift_exchange_rate_36,
swift_ordering_customer_50k,
swift_ordering_institution_52A,
swift_ordering_institution_52d,
swift_senders_correspondent_53A,
swift_receivers_correspondent_54A,
swift_account_with_institution_57,
swift_beneficiary_customer_59,
swift_remittance_information_70,
swift_details_of_charges_71A,
swift_senders_charges_71F,
swift_senders_to_receiver_information_72,
swift_regulatory_reporting_77b
)
VALUES $$transactions(
accountNumber,
branchNumber,
bankNumber,
startDate,
formattedStartDate,
swiftStatusCode,
swiftStatusDesc,
amount,
currencyCodeCatenatedKey,
currencyLongDescription,
chargePartyName,
referenceNumber,
transferCatenatedId,
dataOriginCode,
swiftIsnSerialNumber,
swiftBankCode,
orderCustomerName,
beneficiaryEnglishStreetName,
beneficiaryEnglishCityName,
beneficiaryEnglishCountryName,
swiftSendersReference20,
swiftBankOperationCode23B,
swiftInstructionCode23E,
swiftValueDateCurrencyAmount32A,
swiftCurrencyInstructedAmount33B,
swiftExchangeRate36,
swiftOrderingCustomer50K,
swiftOrderingInstitution52A,
swiftOrderingInstitution52D,
swiftSendersCorrespondent53A,
swiftReceiversCorrespondent54A,
swiftAccountWithInstitution57,
swiftBeneficiaryCustomer59,
swiftRemittanceInformation70,
swiftDetailsOfCharges71A,
swiftSendersCharges71F,
swiftSendersToReceiverInformation72,
swiftRegulatoryReporting77B
)
ON CONFLICT (transfer_catenated_id) DO NOTHING
RETURNING id, start_date, charge_party_name, amount, account_number;
`;
const POALIM_ILS_DIFF_FIELDS: Array<{
key: keyof IFetchPoalimIlsByKeysResult;
incoming: (
t: IUploadPoalimIlsTransactionsParams['transactions'][number],
) => string | number | boolean | null;
}> = [
{ key: 'activity_description', incoming: t => t.activityDescription ?? null },
{ key: 'activity_type_code', incoming: t => t.activityTypeCode ?? null },
{ key: 'text_code', incoming: t => t.textCode ?? null },
{
key: 'reference_number',
incoming: t => (t.referenceNumber == null ? null : Number(t.referenceNumber)),
},
{ key: 'reference_catenated_number', incoming: t => t.referenceCatenatedNumber ?? null },
{ key: 'event_amount', incoming: t => (t.eventAmount == null ? null : Number(t.eventAmount)) },
{ key: 'event_activity_type_code', incoming: t => t.eventActivityTypeCode ?? null },
{
key: 'current_balance',
incoming: t => (t.currentBalance == null ? null : Number(t.currentBalance)),
},
{ key: 'internal_link_code', incoming: t => t.internalLinkCode ?? null },
{ key: 'transaction_type', incoming: t => t.transactionType ?? null },
{ key: 'data_group_code', incoming: t => t.dataGroupCode ?? null },
{ key: 'executing_branch_number', incoming: t => t.executingBranchNumber ?? null },
{ key: 'event_id', incoming: t => (t.eventId == null ? null : Number(t.eventId)) },
{ key: 'details', incoming: t => t.details ?? null },
{ key: 'pfm_details', incoming: t => t.pfmDetails ?? null },
{ key: 'different_date_indication', incoming: t => t.differentDateIndication ?? null },
{
key: 'rejected_data_event_pertaining_indication',
incoming: t => t.rejectedDataEventPertainingIndication ?? null,
},
{ key: 'contra_bank_number', incoming: t => t.contraBankNumber ?? null },
{ key: 'contra_branch_number', incoming: t => t.contraBranchNumber ?? null },
{ key: 'contra_account_number', incoming: t => t.contraAccountNumber ?? null },
{ key: 'contra_account_type_code', incoming: t => t.contraAccountTypeCode ?? null },
{ key: 'comment', incoming: t => t.comment ?? null },
{
key: 'beneficiary_details_data_party_name',
incoming: t => t.beneficiaryDetailsDataPartyName ?? null,
},
{
key: 'beneficiary_details_data_message_headline',
incoming: t => t.beneficiaryDetailsDataMessageHeadline ?? null,
},
{
key: 'beneficiary_details_data_party_headline',
incoming: t => t.beneficiaryDetailsDataPartyHeadline ?? null,
},
{
key: 'beneficiary_details_data_message_detail',
incoming: t => t.beneficiaryDetailsDataMessageDetail ?? null,
},
];
const POALIM_ILS_NUMERIC_FIELDS: (keyof IFetchPoalimIlsByKeysResult)[] = [
'event_amount',
'current_balance',
] as const;
function diffPoalimIlsRow(
existing: IFetchPoalimIlsByKeysResult,
incoming: IUploadPoalimIlsTransactionsParams['transactions'][number],
): ChangedField[] {
const changed: ChangedField[] = [];
for (const { key, incoming: getIncoming } of POALIM_ILS_DIFF_FIELDS) {
const isNumberField = POALIM_ILS_NUMERIC_FIELDS.includes(key);
const oldValue = formatValue(existing[key], isNumberField);
const newValue = formatValue(getIncoming(incoming), isNumberField);
const oldStr = oldValue;
const newStr = newValue;
if (oldStr !== newStr) {
changed.push({ field: key, oldValue: oldStr, newValue: newStr });
}
}
return changed;
}
const POALIM_FOREIGN_DIFF_FIELDS: Array<{
key: keyof IFetchPoalimForeignByKeysResult;
incoming: (
t: IUploadPoalimForeignTransactionsParams['transactions'][number],
) => string | number | boolean | null;
}> = [
{ key: 'activity_description', incoming: t => t.activityDescription ?? null },
{ key: 'event_amount', incoming: t => (t.eventAmount == null ? null : Number(t.eventAmount)) },
{ key: 'currency', incoming: t => t.currency ?? null },
{
key: 'current_balance',
incoming: t => (t.currentBalance == null ? null : Number(t.currentBalance)),
},
{ key: 'reference_catenated_number', incoming: t => t.referenceCatenatedNumber ?? null },
{ key: 'reference_number', incoming: t => t.referenceNumber ?? null },
{ key: 'currency_rate', incoming: t => (t.currencyRate == null ? null : Number(t.currencyRate)) },
{ key: 'event_details', incoming: t => t.eventDetails ?? null },
{ key: 'rate_fixing_code', incoming: t => t.rateFixingCode ?? null },
{ key: 'contra_currency_code', incoming: t => t.contraCurrencyCode ?? null },
{ key: 'event_activity_type_code', incoming: t => t.eventActivityTypeCode ?? null },
{ key: 'transaction_type', incoming: t => t.transactionType ?? null },
{ key: 'rate_fixing_short_description', incoming: t => t.rateFixingShortDescription ?? null },
{ key: 'currency_long_description', incoming: t => t.currencyLongDescription ?? null },
{ key: 'activity_type_code', incoming: t => t.activityTypeCode ?? null },
{ key: 'comments', incoming: t => t.comments ?? null },
{ key: 'account_name', incoming: t => t.accountName ?? null },
{ key: 'contra_bank_number', incoming: t => t.contraBankNumber ?? null },
{ key: 'contra_branch_number', incoming: t => t.contraBranchNumber ?? null },
{ key: 'contra_account_number', incoming: t => t.contraAccountNumber ?? null },
{ key: 'contra_account_field_name_lable', incoming: t => t.contraAccountFieldNameLable ?? null },
{ key: 'rate_fixing_description', incoming: t => t.rateFixingDescription ?? null },
{ key: 'currency_swift_code', incoming: t => t.currencySwiftCode ?? null },
];
const POALIM_FOREIGN_NUMERIC_FIELDS: (keyof IFetchPoalimForeignByKeysResult)[] = [
'event_amount',
'current_balance',
'currency_rate',
] as const;
function diffPoalimForeignRow(
existing: IFetchPoalimForeignByKeysResult,
incoming: IUploadPoalimForeignTransactionsParams['transactions'][number],
): ChangedField[] {
const changed: ChangedField[] = [];
for (const { key, incoming: getIncoming } of POALIM_FOREIGN_DIFF_FIELDS) {
const isNumberField = POALIM_FOREIGN_NUMERIC_FIELDS.includes(key);
const oldValue = formatValue(existing[key], isNumberField);
const newValue = formatValue(getIncoming(incoming), isNumberField);
if (oldValue !== newValue) {
changed.push({ field: key, oldValue, newValue });
}
}
return changed;
}
const POALIM_SWIFT_DIFF_FIELDS: Array<{
key: keyof IFetchPoalimSwiftByIdsResult;
incoming: (
t: IUploadPoalimSwiftTransactionsParams['transactions'][number],
) => string | number | boolean | null;
}> = [
{ key: 'swift_status_code', incoming: t => t.swiftStatusCode ?? null },
{ key: 'swift_status_desc', incoming: t => t.swiftStatusDesc ?? null },
{ key: 'amount', incoming: t => t.amount ?? null },
{ key: 'currency_code_catenated_key', incoming: t => t.currencyCodeCatenatedKey ?? null },
{ key: 'currency_long_description', incoming: t => t.currencyLongDescription ?? null },
{ key: 'charge_party_name', incoming: t => t.chargePartyName ?? null },
{ key: 'reference_number', incoming: t => t.referenceNumber ?? null },
{ key: 'data_origin_code', incoming: t => t.dataOriginCode ?? null },
{ key: 'order_customer_name', incoming: t => t.orderCustomerName ?? null },
{ key: 'beneficiary_english_street_name', incoming: t => t.beneficiaryEnglishStreetName ?? null },
{ key: 'beneficiary_english_city_name', incoming: t => t.beneficiaryEnglishCityName ?? null },
{
key: 'beneficiary_english_country_name',
incoming: t => t.beneficiaryEnglishCountryName ?? null,
},
{ key: 'swift_remittance_information_70', incoming: t => t.swiftRemittanceInformation70 ?? null },
{ key: 'swift_details_of_charges_71a', incoming: t => t.swiftDetailsOfCharges71A ?? null },
{ key: 'swift_senders_charges_71f', incoming: t => t.swiftSendersCharges71F ?? null },
];
function diffPoalimSwiftRow(
existing: IFetchPoalimSwiftByIdsResult,
incoming: IUploadPoalimSwiftTransactionsParams['transactions'][number],
): ChangedField[] {
const changed: ChangedField[] = [];
for (const { key, incoming: getIncoming } of POALIM_SWIFT_DIFF_FIELDS) {
const oldStr = existing[key] == null ? 'null' : String(existing[key]);
const newStr = getIncoming(incoming) == null ? 'null' : String(getIncoming(incoming));
if (oldStr !== newStr) {
changed.push({ field: key, oldValue: oldStr, newValue: newStr });
}
}
return changed;
}
/**
* as_of_date is deliberately absent: it changes on every scrape and would flag
* every row as changed. Only the security's own attributes are compared.
*/
const POALIM_SECURITIES_DIFF_FIELDS: Array<{
key: keyof IFetchPoalimSecuritiesByKeysResult;
incoming: (
s: IUploadPoalimSecuritiesParams['securities'][number],
) => string | number | boolean | null;
}> = [
{ key: 'eng_name', incoming: s => s.engName ?? null },
{ key: 'heb_name', incoming: s => s.hebName ?? null },
{ key: 'item_type', incoming: s => s.itemType ?? null },
{ key: 'is_etf', incoming: s => s.isEtf ?? null },
{ key: 'is_foreign', incoming: s => s.isForeign ?? null },
{ key: 'currency_code', incoming: s => s.currencyCode ?? null },
{ key: 'exchange', incoming: s => s.exchange ?? null },
{ key: 'equity_type', incoming: s => s.equityType ?? null },
{ key: 'allowed_order_direction', incoming: s => s.allowedOrderDirection ?? null },
{ key: 'equity_sub_type', incoming: s => s.equitySubType ?? null },
{ key: 'eng_symbol', incoming: s => s.engSymbol ?? null },
{ key: 'heb_symbol', incoming: s => s.hebSymbol ?? null },
{ key: 'symbol', incoming: s => s.symbol ?? null },
{ key: 'expiration_date', incoming: s => s.expirationDate ?? null },
{ key: 'stock_type', incoming: s => s.stockType ?? null },
{ key: 'creation_equity_num', incoming: s => s.creationEquityNum ?? null },
{ key: 'contract_type', incoming: s => s.contractType ?? null },
];
const POALIM_SECURITIES_NUMERIC_FIELDS: (keyof IFetchPoalimSecuritiesByKeysResult)[] = [
'equity_type',
'equity_sub_type',
] as const;
function diffPoalimSecurityRow(
existing: IFetchPoalimSecuritiesByKeysResult,
incoming: IUploadPoalimSecuritiesParams['securities'][number],
): ChangedField[] {
const changed: ChangedField[] = [];
for (const { key, incoming: getIncoming } of POALIM_SECURITIES_DIFF_FIELDS) {
const isNumberField = POALIM_SECURITIES_NUMERIC_FIELDS.includes(key);
const oldValue = formatValue(existing[key], isNumberField);
const newValue = formatValue(getIncoming(incoming), isNumberField);
if (oldValue !== newValue) {
changed.push({ field: key, oldValue, newValue });
}
}
return changed;
}
/**
* Only the fields that are *not* part of the dedup key can differ on a matched row —
* everything in the key is, by definition, identical. These are the descriptive and
* monetary fields the bank could restate after the fact.
*/
const POALIM_SECURITIES_TRANSACTIONS_DIFF_FIELDS: Array<{
key: keyof IFetchPoalimSecuritiesTransactionsByKeysResult;
incoming: (
t: IUploadPoalimSecuritiesTransactionsParams['transactions'][number],
) => string | number | boolean | null;
}> = [
{ key: 'isin', incoming: t => t.isin ?? null },
{ key: 'symbol', incoming: t => t.symbol ?? null },
{ key: 'eng_name', incoming: t => t.engName ?? null },
{ key: 'heb_name', incoming: t => t.hebName ?? null },
{ key: 'security_group', incoming: t => t.securityGroup ?? null },
{ key: 'trade_currency', incoming: t => t.tradeCurrency ?? null },
{ key: 'settlement_currency', incoming: t => t.settlementCurrency ?? null },
{
key: 'trade_gross_value_trade_currency',
incoming: t => t.tradeGrossValueTradeCurrency ?? null,
},
{ key: 'trade_gross_value_nis', incoming: t => t.tradeGrossValueNis ?? null },
{ key: 'net_value_nis', incoming: t => t.netValueNis ?? null },
{ key: 'net_value_settlement_currency', incoming: t => t.netValueSettlementCurrency ?? null },
{ key: 'trade_commission_value_nis', incoming: t => t.tradeCommissionValueNis ?? null },
{ key: 'israe_tax_value', incoming: t => t.israeTaxValue ?? null },
{
key: 'foreign_tax_value_settlement_currency',
incoming: t => t.foreignTaxValueSettlementCurrency ?? null,
},
{
key: 'capital_tax_value_settlement_currency',
incoming: t => t.capitalTaxValueSettlementCurrency ?? null,
},
{ key: 'is_cancel_transaction', incoming: t => t.isCancelTransaction ?? null },
];
const POALIM_SECURITIES_TRANSACTIONS_NUMERIC_FIELDS: (keyof IFetchPoalimSecuritiesTransactionsByKeysResult)[] =
[
'trade_gross_value_trade_currency',
'trade_gross_value_nis',
'net_value_nis',
'net_value_settlement_currency',
'trade_commission_value_nis',
'israe_tax_value',
'foreign_tax_value_settlement_currency',
'capital_tax_value_settlement_currency',
] as const;
/**
* Takes the calendar date off one of the bank's timestamps.
*
* The strings are `2024-01-15T00:00:00.0000000+02:00` — a calendar date dressed as an
* instant, with the Israel offset of that date. Postgres reads the leading date when
* casting one into a DATE column, so this must too: converting through `Date` would
* shift the day for any server not running on Israel time.
*
* Anything whose leading ten characters are not a real calendar date is passed through
* untouched — including the bank's `0001-01-01` no-execution sentinel, which is outside
* the range `TIMELESS_DATE_REGEX` accepts — so Postgres reports it rather than this
* helper inventing a date.
*/
function toCalendarDate(value: string): TimelessDateString | string {
const candidate = value.slice(0, 10);
return TIMELESS_DATE_REGEX.test(candidate) ? (candidate as TimelessDateString) : value;
}
/**
* The executions response carries no per-row id, so identity is the natural key the
* dedup index is built on. Existing rows come back from pg as `Date`/`string`
* (NUMERIC) while incoming rows are the bank's date strings and JS numbers, so both
* sides are normalised to the same canonical form before being joined into a key.
*/
function securityTransactionKeyOf(row: {
bank_number?: number | null | void;
bankNumber?: number | null | void;
branch_number?: number | null | void;
branchNumber?: number | null | void;
account_number?: number | null | void;
accountNumber?: number | null | void;
security?: string | null | void;
trade_date?: Date | string | null | void;
tradeDate?: Date | string | null | void;
value_date?: Date | string | null | void;
valueDate?: Date | string | null | void;
settlement_date?: Date | string | null | void;
settlementDate?: Date | string | null | void;
trade_type?: string | null | void;
tradeType?: string | null | void;
transaction_type?: string | null | void;
transactionType?: string | null | void;
nv?: number | string | null | void;
trade_price?: number | string | null | void;
tradePrice?: number | string | null | void;
net_value_trade_currency?: number | string | null | void;
netValueTradeCurrency?: number | string | null | void;
payment_type?: string | null | void;
paymentType?: string | null | void;
payment_date?: Date | string | null | void;
paymentDate?: Date | string | null | void;
ex_date?: Date | string | null | void;
exDate?: Date | string | null | void;
cancel_date?: Date | string | null | void;
cancelDate?: Date | string | null | void;
}): string {
const date = (value: Date | string | null | void) => {
if (value == null) return '';
// Calendar dates, not instants: the columns are DATE, and the bank's strings
// are midnight with the Israel offset of that date. Comparing them as instants
// would put an incoming "2024-01-15T00:00:00+02:00" an hour or two before the
// stored day and never match the row it belongs to.
return value instanceof Date ? dateToTimelessDateString(value) : toCalendarDate(value);
};
const num = (value: number | string | null | void) =>
value == null ? '' : String(Number(value));
const text = (value: string | null | void) => value ?? '';
return [
row.bank_number ?? row.bankNumber,
row.branch_number ?? row.branchNumber,
row.account_number ?? row.accountNumber,
text(row.security),
date(row.trade_date ?? row.tradeDate),
date(row.value_date ?? row.valueDate),
date(row.settlement_date ?? row.settlementDate),
text(row.trade_type ?? row.tradeType),
text(row.transaction_type ?? row.transactionType),
num(row.nv),
num(row.trade_price ?? row.tradePrice),
num(row.net_value_trade_currency ?? row.netValueTradeCurrency),
text(row.payment_type ?? row.paymentType),
date(row.payment_date ?? row.paymentDate),
date(row.ex_date ?? row.exDate),
date(row.cancel_date ?? row.cancelDate),
].join('|');
}
function diffPoalimSecurityTransactionRow(
existing: IFetchPoalimSecuritiesTransactionsByKeysResult,
incoming: IUploadPoalimSecuritiesTransactionsParams['transactions'][number],
): ChangedField[] {
const changed: ChangedField[] = [];
for (const { key, incoming: getIncoming } of POALIM_SECURITIES_TRANSACTIONS_DIFF_FIELDS) {
const isNumberField = POALIM_SECURITIES_TRANSACTIONS_NUMERIC_FIELDS.includes(key);
const oldValue = formatValue(existing[key], isNumberField);
const newValue = formatValue(getIncoming(incoming), isNumberField);
if (oldValue !== newValue) {
changed.push({ field: key, oldValue, newValue });
}
}
return changed;
}
@Injectable({
scope: Scope.Operation,
global: true,
})
export class PoalimScraperIngestionProvider {
private businessIdCache: string | null = null;
constructor(
private db: TenantAwareDBClient,
private authContextProvider: AuthContextProvider,
) {}
private async getBusinessId() {
if (this.businessIdCache !== null) {
return this.businessIdCache;
}
const authContext = await this.authContextProvider.getAuthContext();
this.businessIdCache = authContext?.tenant.businessId ?? null;
return this.businessIdCache;
}
async uploadPoalimIlsTransactions(
transactions: readonly PoalimIlsTransactionInput[],
): Promise<ScraperUploadResult> {
try {
if (transactions.length === 0)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
const validated = validatePoalimIlsTransactions(transactions);
const eventDates = validated
.map(t => (t.eventDate ? new Date(t.eventDate) : null))
.filter((d): d is Date => d !== null);
const accountNumbers = validated
.map(t => t.accountNumber ?? null)
.filter((n): n is number => n !== null);
const branchNumbers = validated
.map(t => t.branchNumber ?? null)
.filter((n): n is number => n !== null);
const existing = await fetchPoalimIlsByKeys.run(
{ eventDates, accountNumbers, branchNumbers },
this.db,
);
type ConflictKey = `${string}_${string}_${string}_${string}`;
const existingByKey = new Map<ConflictKey, IFetchPoalimIlsByKeysResult>();
for (const row of existing) {
const key: ConflictKey = `${row.event_date ? dateToTimelessDateString(row.event_date) : ''}_${row.serial_number}_${row.account_number}_${row.branch_number}`;
existingByKey.set(key, row);
}
const result: IUploadPoalimIlsTransactionsResult[] = await uploadPoalimIlsTransactions.run(
{ transactions: validated },
this.db,
);
const insertedIds = result
.map(r => r.id)
.filter((id): id is string => typeof id === 'string');
const insertedIdSet = new Set(insertedIds);
const insertedTransactions: InsertedTransactionSummary[] = result.map(r => ({
id: r.id,
date: dateToTimelessDateString(r.event_date),
description: r.activity_description,
amount: r.event_amount,
account: String(r.account_number),
}));
const changedTransactions: ChangedTransaction[] = [];
for (const t of validated) {
const key: ConflictKey = `${t.eventDate ? dateToTimelessDateString(new Date(t.eventDate)) : ''}_${t.serialNumber}_${t.accountNumber}_${t.branchNumber}`;
const existingRow = existingByKey.get(key);
if (existingRow && !insertedIdSet.has(existingRow.id)) {
const changedFields = diffPoalimIlsRow(existingRow, t);
if (changedFields.length > 0) {
changedTransactions.push({ id: existingRow.id, changedFields });
}
}
}
return {
inserted: insertedIds.length,
skipped: transactions.length - insertedIds.length,
insertedIds,
insertedTransactions,
changedTransactions,
};
} catch (error) {
console.error('Error uploading Poalim ILS transactions:', error);
throw error;
}
}
async uploadPoalimForeignTransactions(
transactions: readonly PoalimForeignTransactionInput[],
): Promise<ScraperUploadResult> {
try {
if (transactions.length === 0)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
const validated = validatePoalimForeignTransactions(transactions);
const executingDates = validated
.map(t => (t.executingDate ? new Date(t.executingDate) : null))
.filter((d): d is Date => d !== null);
const accountNumbers = validated
.map(t => t.accountNumber ?? null)
.filter((n): n is number => n !== null);
const branchNumbers = validated
.map(t => t.branchNumber ?? null)
.filter((n): n is number => n !== null);
const existing = await fetchPoalimForeignByKeys.run(
{ executingDates, accountNumbers, branchNumbers },
this.db,
);
type ForeignKey = `${string}_${string}_${string}_${string}`;
const existingByKey = new Map<ForeignKey, IFetchPoalimForeignByKeysResult>();
for (const row of existing) {
const key: ForeignKey = `${row.executing_date ? dateToTimelessDateString(row.executing_date) : ''}_${row.account_number}_${row.branch_number}_${row.event_number}`;
existingByKey.set(key, row);
}
const result: IUploadPoalimForeignTransactionsResult[] =
await uploadPoalimForeignTransactions.run({ transactions: validated }, this.db);
const insertedIds = result
.map(r => r.id)
.filter((id): id is string => typeof id === 'string');
const insertedIdSet = new Set(insertedIds);
const insertedTransactions: InsertedTransactionSummary[] = result.map(r => ({
id: r.id,
date: r.executing_date ? dateToTimelessDateString(r.executing_date) : null,
description: r.activity_description ?? null,
amount: r.event_amount == null ? null : String(r.event_amount),
account: r.account_number == null ? null : String(r.account_number),
}));
const changedTransactions: ChangedTransaction[] = [];
for (const t of validated) {
const key: ForeignKey = `${t.executingDate ? dateToTimelessDateString(new Date(t.executingDate)) : ''}_${t.accountNumber}_${t.branchNumber}_${t.eventNumber}`;
const existingRow = existingByKey.get(key);
if (existingRow && !insertedIdSet.has(existingRow.id)) {
const changedFields = diffPoalimForeignRow(existingRow, t);
if (changedFields.length > 0) {
changedTransactions.push({ id: existingRow.id, changedFields });
}
}
}
return {
inserted: insertedIds.length,
skipped: transactions.length - insertedIds.length,
insertedIds,
insertedTransactions,
changedTransactions,
};
} catch (error) {
console.error('Error uploading Poalim foreign transactions:', error);
throw error;
}
}
async uploadPoalimSwiftTransactions(
swifts: readonly PoalimSwiftTransactionInput[],
): Promise<ScraperUploadResult> {
try {
if (swifts.length === 0)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
const validated = validatePoalimSwiftTransactions(swifts);
const transferCatenatedIds = validated
.map(t => t.transferCatenatedId ?? null)
.filter((id): id is string => id !== null);
const existing = await fetchPoalimSwiftByIds.run({ transferCatenatedIds }, this.db);
const existingById = new Map(existing.map(row => [row.transfer_catenated_id, row]));
const result: IUploadPoalimSwiftTransactionsResult[] =
await uploadPoalimSwiftTransactions.run({ transactions: validated }, this.db);
const insertedIds = result
.map(r => r.id)
.filter((id): id is string => typeof id === 'string');
const insertedIdSet = new Set(insertedIds);
const insertedTransactions: InsertedTransactionSummary[] = result.map(r => ({
id: r.id,
date: r.start_date ?? null,
description: r.charge_party_name ?? null,
amount: r.amount == null ? null : String(r.amount),
account: r.account_number == null ? null : String(r.account_number),
}));
const changedTransactions: ChangedTransaction[] = [];
for (const t of validated) {
if (!t.transferCatenatedId) continue;
const existingRow = existingById.get(t.transferCatenatedId);
if (existingRow && !insertedIdSet.has(existingRow.id)) {
const changedFields = diffPoalimSwiftRow(existingRow, t);
if (changedFields.length > 0) {
changedTransactions.push({ id: existingRow.id, changedFields });
}
}
}
return {
inserted: insertedIds.length,
skipped: swifts.length - insertedIds.length,
insertedIds,
insertedTransactions,
changedTransactions,
};
} catch (error) {
console.error('Error uploading Poalim SWIFT transactions:', error);
throw error;
}
}
async uploadPoalimSecurities(
securities: readonly PoalimSecurityInput[],
): Promise<ScraperUploadResult> {
try {
if (securities.length === 0)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
const businessId = await this.getBusinessId();
if (!businessId) {
// owner_id is NOT NULL and drives the table's RLS WITH CHECK clause: without a
// tenant context the insert would fail deep inside Postgres with an opaque error.
throw new Error(
'Cannot upload Poalim securities: no business context found in the auth context',
);
}
const validated = validatePoalimSecurities(securities).map(s => ({
...s,
ownerId: businessId,
}));
const bankNumbers = validated
.map(s => s.bankNumber ?? null)
.filter((n): n is number => n !== null);
const branchNumbers = validated
.map(s => s.branchNumber ?? null)
.filter((n): n is number => n !== null);
const accountNumbers = validated
.map(s => s.accountNumber ?? null)
.filter((n): n is number => n !== null);
const securityKeys = validated
.map(s => s.securityKey ?? null)
.filter((k): k is string => k !== null);
const existing = await fetchPoalimSecuritiesByKeys.run(
{ bankNumbers, branchNumbers, accountNumbers, securityKeys },
this.db,
);
type SecurityKey = `${string}_${string}_${string}_${string}`;
const securityKeyOf = (
bank: number | null | void,
branch: number | null | void,
account: number | null | void,
key: string | null | void,
): SecurityKey => `${bank}_${branch}_${account}_${key}`;
const existingByKey = new Map<SecurityKey, IFetchPoalimSecuritiesByKeysResult>();
for (const row of existing) {
existingByKey.set(
securityKeyOf(row.bank_number, row.branch_number, row.account_number, row.security_key),
row,
);
}
const result: IUploadPoalimSecuritiesResult[] = await uploadPoalimSecurities.run(
{ securities: validated },
this.db,
);
const insertedIds = result
.map(r => r.id)
.filter((id): id is string => typeof id === 'string');
const insertedIdSet = new Set(insertedIds);
const insertedTransactions: InsertedTransactionSummary[] = result.map(r => ({
id: r.id,
date: r.as_of_date ? dateToTimelessDateString(r.as_of_date) : null,
description: r.eng_name ?? null,
amount: null,
account: `${r.branch_number}-${r.account_number}`,
}));
const changedTransactions: ChangedTransaction[] = [];
for (const s of validated) {
const existingRow = existingByKey.get(
securityKeyOf(s.bankNumber, s.branchNumber, s.accountNumber, s.securityKey),
);
if (existingRow && !insertedIdSet.has(existingRow.id)) {
const changedFields = diffPoalimSecurityRow(existingRow, s);
if (changedFields.length > 0) {
changedTransactions.push({ id: existingRow.id, changedFields });
}
}
}
return {
inserted: insertedIds.length,
skipped: securities.length - insertedIds.length,
insertedIds,
insertedTransactions,
changedTransactions,
};
} catch (error) {
console.error('Error uploading Poalim securities:', error);
throw error;
}
}
async uploadPoalimSecuritiesTransactions(
transactions: readonly PoalimSecurityTransactionInput[],
): Promise<ScraperUploadResult> {
try {
if (transactions.length === 0)
return {
inserted: 0,
skipped: 0,
insertedIds: [],
insertedTransactions: [],
changedTransactions: [],
};
const businessId = await this.getBusinessId();
if (!businessId) {
// owner_id is NOT NULL and drives the table's RLS WITH CHECK clause: without a
// tenant context the insert would fail deep inside Postgres with an opaque error.
throw new Error(
'Cannot upload Poalim securities transactions: no business context found in the auth context',
);
}
const validated = validatePoalimSecuritiesTransactions(transactions).map(t => ({
...t,
ownerId: businessId,
}));
const isNumber = (value: number | null | void): value is number => typeof value === 'number';
const bankNumbers = validated.map(t => t.bankNumber).filter(isNumber);
const branchNumbers = validated.map(t => t.branchNumber).filter(isNumber);
const accountNumbers = validated.map(t => t.accountNumber).filter(isNumber);
const securities = validated
.map(t => t.security ?? null)
.filter((s): s is string => s !== null);
// Passed as calendar-date strings rather than `Date`s: a `Date` is serialised
// as a UTC instant, which lands on the previous day once Postgres casts it to
// the DATE column and would match nothing.
const tradeDates = validated
.map(t => (t.tradeDate ? toCalendarDate(String(t.tradeDate)) : null))
.filter((d): d is TimelessDateString | string => d !== null);
// Coarse fetch on the indexed prefix of the dedup key; the full key — which
// includes nullable corporate-action dates — is matched in memory below.
const existing = await fetchPoalimSecuritiesTransactionsByKeys.run(
{ bankNumbers, branchNumbers, accountNumbers, securities, tradeDates },
this.db,
);
const existingByKey = new Map<string, IFetchPoalimSecuritiesTransactionsByKeysResult>();
for (const row of existing) {
existingByKey.set(securityTransactionKeyOf(row), row);
}
const result: IUploadPoalimSecuritiesTransactionsResult[] =
await uploadPoalimSecuritiesTransactions.run({ transactions: validated }, this.db);
const insertedIds = result
.map(r => r.id)
.filter((id): id is string => typeof id === 'string');
const insertedIdSet = new Set(insertedIds);
const insertedTransactions: InsertedTransactionSummary[] = result.map(r => ({
id: r.id,
date: r.trade_date ? dateToTimelessDateString(r.trade_date) : null,
description: [r.trade_type, r.eng_name].filter(Boolean).join(' — ') || null,
amount: r.net_value_trade_currency == null ? null : String(r.net_value_trade_currency),
account: `${r.branch_number}-${r.account_number}`,
}));
const changedTransactions: ChangedTransaction[] = [];
for (const t of validated) {
const existingRow = existingByKey.get(securityTransactionKeyOf(t));
if (existingRow && !insertedIdSet.has(existingRow.id)) {
const changedFields = diffPoalimSecurityTransactionRow(existingRow, t);
if (changedFields.length > 0) {
changedTransactions.push({ id: existingRow.id, changedFields });
}
}
}
return {
inserted: insertedIds.length,
skipped: transactions.length - insertedIds.length,
insertedIds,
insertedTransactions,
changedTransactions,
};
} catch (error) {
console.error('Error uploading Poalim securities transactions:', error);
throw error;
}
}
}