@accounter/server
Version:
Accounter GraphQL server
344 lines • 16.8 kB
JavaScript
import { __decorate, __metadata } from "tslib";
import DataLoader from 'dataloader';
import { Injectable, Scope } from 'graphql-modules';
import { sql } from '@pgtyped/runtime';
import { formatCurrency } from '../../../shared/helpers/amount.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js';
import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js';
import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js';
import { TaxCategoriesProvider } from '../../financial-entities/providers/tax-categories.provider.js';
import { buildSecurityBusinessName } from '../helpers/security-business-name.helper.js';
/**
* No owner_id predicate anywhere in this file: both tables are FORCE RLS with a
* tenant_isolation policy, so going through TenantAwareDBClient scopes every read and write
* to the acting tenant — same contract as the poalim_securities tables.
*/
const getSecurityBusinessesByIds = sql `
SELECT *
FROM accounter_schema.businesses_securities
WHERE id IN $$ids;`;
const getAllSecurityBusinesses = sql `
SELECT *
FROM accounter_schema.businesses_securities;`;
const getSecurityBusinessesByIsins = sql `
SELECT *
FROM accounter_schema.businesses_securities
WHERE isin = ANY($isins!);`;
/**
* An explicit `owner_id` predicate, unlike the rest of this file.
*
* RLS narrows to the request's *scope*, which can span several businesses, while this lookup has
* to answer for one owner: `security_identifiers` is unique on
* `(owner_id, identifier_type, identifier_value)`, so the same Poalim key legitimately exists
* under two of a tenant's businesses when both trade the security. Without the predicate the
* batch would see both rows and keep whichever was written last, silently attaching one
* business's trade to the other's security.
*/
const getSecurityBusinessesByIdentifiers = sql `
SELECT si.identifier_value, si.owner_id AS identifier_owner_id, bs.*
FROM accounter_schema.security_identifiers si
INNER JOIN accounter_schema.businesses_securities bs
ON bs.id = si.business_id
WHERE si.owner_id = $ownerId!
AND si.identifier_type = $identifierType!
AND si.identifier_value = ANY($identifierValues!);`;
const getSecurityIdentifiersByBusinessIds = sql `
SELECT *
FROM accounter_schema.security_identifiers
WHERE business_id IN $$businessIds
ORDER BY identifier_type, identifier_value;`;
const insertSecurityBusiness = sql `
INSERT INTO accounter_schema.businesses_securities (
id, owner_id, isin, symbol, eng_name, heb_name, exchange,
currency_code, item_type, stock_type, is_etf, is_foreign, issuer_country_code
)
VALUES (
$id!, $ownerId!, $isin!, $symbol, $engName, $hebName, $exchange,
$currencyCode, $itemType, $stockType, $isEtf, $isForeign, $issuerCountryCode
)
RETURNING *;`;
const insertSecurityIdentifier = sql `
INSERT INTO accounter_schema.security_identifiers (
owner_id, business_id, identifier_type, identifier_value
)
VALUES ($ownerId!, $businessId!, $identifierType!, $identifierValue!)
ON CONFLICT (owner_id, identifier_type, identifier_value) DO NOTHING;`;
/**
* `businesses_securities.currency_code` is `accounter_schema.currency`, but the securities
* feeds are not: Poalim spells its currencies out in Hebrew (`דולר ארה"ב`), other sources use
* ISO codes. `formatCurrency` knows both, and its nullable form returns null for a label it
* does not recognize rather than throwing — a security whose currency cannot be resolved is
* still worth having, with the column left empty.
*
* The guard matters: `formatCurrency` reads a *missing* label as ILS, which would stamp every
* security that reports no currency as a shekel one.
*/
function toCurrency(rawCurrency) {
const label = rawCurrency?.trim();
return label ? formatCurrency(label, true) : null;
}
/** Cache/lookup key for the identifier relation, which is unique on all three parts. */
function identifierCacheKey(ownerId, type, value) {
return `${ownerId}:${type}:${value}`;
}
/** Batch grouping key — one query per (owner, identifier type). */
function identifierGroupKey(ownerId, type) {
return `${ownerId}:${type}`;
}
function splitIdentifierGroupKey(group) {
const separator = group.indexOf(':');
return [group.slice(0, separator), group.slice(separator + 1)];
}
let SecurityBusinessesProvider = class SecurityBusinessesProvider {
db;
adminContextProvider;
financialEntitiesProvider;
businessesProvider;
taxCategoriesProvider;
constructor(db, adminContextProvider, financialEntitiesProvider, businessesProvider, taxCategoriesProvider) {
this.db = db;
this.adminContextProvider = adminContextProvider;
this.financialEntitiesProvider = financialEntitiesProvider;
this.businessesProvider = businessesProvider;
this.taxCategoriesProvider = taxCategoriesProvider;
}
async batchSecurityBusinessesByIds(ids) {
const rows = await getSecurityBusinessesByIds.run({ ids: [...new Set(ids)] }, this.db);
const byId = new Map(rows.map(row => [row.id, row]));
return ids.map(id => byId.get(id) ?? null);
}
getSecurityBusinessByIdLoader = new DataLoader((ids) => this.batchSecurityBusinessesByIds(ids));
allSecurityBusinessesPromise = null;
/**
* Every security business of the acting tenant. Cached per request because charge typing and
* account resolution ask for it on every transaction they touch.
*/
getAllSecurityBusinesses() {
this.allSecurityBusinessesPromise ??= getAllSecurityBusinesses
.run(undefined, this.db)
.then(rows => {
rows.map(row => this.getSecurityBusinessByIdLoader.prime(row.id, row));
return rows;
});
return this.allSecurityBusinessesPromise;
}
async getAllSecurityBusinessIds() {
const rows = await this.getAllSecurityBusinesses();
return new Set(rows.map(row => row.id));
}
async isSecurityBusiness(businessId) {
return (await this.getAllSecurityBusinessIds()).has(businessId);
}
async batchSecurityBusinessesByIdentifiers(keys) {
// One query per (owner, identifier type); in practice a batch carries a single pair.
const valuesByOwnerAndType = new Map();
for (const key of keys) {
const group = identifierGroupKey(key.ownerId, key.type);
const values = valuesByOwnerAndType.get(group);
if (values) {
values.add(key.value);
}
else {
valuesByOwnerAndType.set(group, new Set([key.value]));
}
}
const found = new Map();
await Promise.all([...valuesByOwnerAndType].map(async ([group, values]) => {
const [ownerId, identifierType] = splitIdentifierGroupKey(group);
const rows = await getSecurityBusinessesByIdentifiers.run({ ownerId, identifierType, identifierValues: [...values] }, this.db);
for (const { identifier_value, identifier_owner_id, ...securityBusiness } of rows) {
found.set(identifierCacheKey(identifier_owner_id, identifierType, identifier_value), securityBusiness);
}
}));
return keys.map(key => found.get(identifierCacheKey(key.ownerId, key.type, key.value)) ?? null);
}
getSecurityBusinessByIdentifierLoader = new DataLoader((keys) => this.batchSecurityBusinessesByIdentifiers(keys), { cacheKeyFn: key => identifierCacheKey(key.ownerId, key.type, key.value) });
async batchIdentifiersByBusinessIds(businessIds) {
const rows = await getSecurityIdentifiersByBusinessIds.run({ businessIds: [...new Set(businessIds)] }, this.db);
const byBusinessId = new Map();
for (const row of rows) {
const group = byBusinessId.get(row.business_id);
if (group) {
group.push(row);
}
else {
byBusinessId.set(row.business_id, [row]);
}
}
return businessIds.map(businessId => byBusinessId.get(businessId) ?? []);
}
getIdentifiersByBusinessIdLoader = new DataLoader((businessIds) => this.batchIdentifiersByBusinessIds(businessIds));
/** The security businesses already created for these ISINs, keyed by ISIN. */
async getSecurityBusinessesByIsins(isins) {
if (isins.length === 0) {
return new Map();
}
const rows = await getSecurityBusinessesByIsins.run({ isins: [...new Set(isins)] }, this.db);
rows.map(row => this.getSecurityBusinessByIdLoader.prime(row.id, row));
return new Map(rows.map(row => [row.isin, row]));
}
async getSecurityBusinessByIsin(isin) {
return (await this.getSecurityBusinessesByIsins([isin])).get(isin) ?? null;
}
/**
* The business a security is represented by, creating it on first sight.
*
* Idempotent by ISIN — see `createSecurityBusiness` for how a race is settled.
*/
async ensureSecurityBusiness(descriptors) {
const existing = await this.getSecurityBusinessByIsin(descriptors.isin);
return existing ?? this.createSecurityBusiness(descriptors);
}
/**
* The same for a batch, in one lookup: an ingest introduces a whole portfolio at once, and
* asking per ISIN whether it exists costs a round trip for every security every scrape.
* Only the ISINs with no business yet are created.
*/
async ensureSecurityBusinesses(descriptorsList) {
const byIsin = new Map(descriptorsList.map(descriptors => [descriptors.isin, descriptors]));
if (byIsin.size === 0) {
return new Map();
}
const securityBusinesses = await this.getSecurityBusinessesByIsins([...byIsin.keys()]);
for (const [isin, descriptors] of byIsin) {
if (!securityBusinesses.has(isin)) {
securityBusinesses.set(isin, await this.createSecurityBusiness(descriptors));
}
}
return securityBusinesses;
}
/**
* Creates the business behind a security, assuming the caller has established there is none.
*
* Concurrent ingests race on the (owner_id, isin) unique index, and the loser rolls its whole
* transaction back — financial entity and business included — then re-reads the winner's row,
* so a race can't leave a half-built business behind. That fallback is also what makes the
* caller's "there is none" only have to be true at the time it looked.
*
* Sort code, IRS code, country and tax category are inherited from the tenant's general
* foreign-securities business, so a security behaves like it everywhere those fields drive
* reporting.
*/
async createSecurityBusiness(descriptors) {
const adminContext = await this.adminContextProvider.getVerifiedAdminContext();
const { ownerId } = adminContext;
const generalBusinessId = adminContext.foreignSecurities.foreignSecuritiesBusinessId;
const [generalBusiness, generalTaxCategory] = await Promise.all([
generalBusinessId
? this.businessesProvider.getBusinessByIdLoader.load(generalBusinessId)
: null,
generalBusinessId
? this.taxCategoriesProvider.taxCategoryByBusinessIDsLoader.load(generalBusinessId)
: null,
]);
try {
const created = await this.db.transaction(async (client) => {
const [financialEntity] = await this.financialEntitiesProvider.insertFinancialEntity({
ownerId,
name: buildSecurityBusinessName(descriptors),
sortCode: generalBusiness?.sort_code ?? null,
type: 'business',
irsCode: generalBusiness?.irs_code ?? null,
isActive: true,
}, client);
if (!financialEntity) {
throw new Error(`Failed to create financial entity for security ISIN="${descriptors.isin}"`);
}
const business = await this.businessesProvider.insertBusiness({
id: financialEntity.id,
ownerId,
// businesses.country is NOT NULL and FK'd to countries.code, so it can never be
// passed as null; ISR is the column's own default.
country: generalBusiness?.country ?? 'ISR',
hebrewName: descriptors.hebName?.trim() || null,
// A security has no contact details, VAT number or document expectations of its
// own; the columns are spelled out because the insert takes a full tuple.
address: null,
city: null,
zipCode: null,
email: null,
website: null,
phoneNumber: null,
governmentId: null,
exemptDealer: false,
optionalVat: false,
isReceiptEnough: false,
isDocumentsOptional: false,
pcn874RecordTypeOverride: null,
// No phrases: a security must never win a description-based suggestion.
suggestions: null,
}, client);
if (!business) {
throw new Error(`Failed to create business for security ISIN="${descriptors.isin}"`);
}
// Last on purpose: the unique index on (owner_id, isin) is what arbitrates the race,
// and everything above it is rolled back when this insert loses.
const [securityBusiness] = await insertSecurityBusiness.run({
id: financialEntity.id,
ownerId,
isin: descriptors.isin,
symbol: descriptors.symbol?.trim() || null,
engName: descriptors.engName?.trim() || null,
hebName: descriptors.hebName?.trim() || null,
exchange: descriptors.exchange?.trim() || null,
currencyCode: toCurrency(descriptors.currencyCode),
itemType: descriptors.itemType ?? null,
stockType: descriptors.stockType ?? null,
isEtf: descriptors.isEtf ?? null,
isForeign: descriptors.isForeign ?? null,
issuerCountryCode: descriptors.issuerCountryCode ?? null,
}, client);
if (!securityBusiness) {
throw new Error(`Failed to create security record for ISIN="${descriptors.isin}"`);
}
if (generalTaxCategory) {
await this.taxCategoriesProvider.insertBusinessTaxCategory({
businessId: financialEntity.id,
ownerId,
taxCategoryId: generalTaxCategory.id,
});
}
return securityBusiness;
});
this.clearCache();
return created;
}
catch (error) {
const winner = await this.getSecurityBusinessByIsin(descriptors.isin);
if (winner) {
return winner;
}
throw error;
}
}
async linkIdentifier(businessId, identifierType, identifierValue) {
const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext();
await insertSecurityIdentifier.run({ ownerId, businessId, identifierType, identifierValue }, this.db);
this.getSecurityBusinessByIdentifierLoader.clear({
ownerId,
type: identifierType,
value: identifierValue,
});
this.getIdentifiersByBusinessIdLoader.clear(businessId);
}
clearCache() {
this.allSecurityBusinessesPromise = null;
this.getSecurityBusinessByIdLoader.clearAll();
this.getSecurityBusinessByIdentifierLoader.clearAll();
this.getIdentifiersByBusinessIdLoader.clearAll();
}
};
SecurityBusinessesProvider = __decorate([
Injectable({
scope: Scope.Operation,
global: true,
}),
__metadata("design:paramtypes", [TenantAwareDBClient,
AdminContextProvider,
FinancialEntitiesProvider,
BusinessesProvider,
TaxCategoriesProvider])
], SecurityBusinessesProvider);
export { SecurityBusinessesProvider };
//# sourceMappingURL=security-businesses.provider.js.map