@accounter/server
Version:
Accounter GraphQL server
744 lines (717 loc) • 26.6 kB
text/typescript
import { GraphQLError } from 'graphql';
import type {
ChargeResolvers,
Maybe,
Resolver,
ResolversParentTypes,
ResolversTypes,
} from '../../../../__generated__/types.js';
import { UUID_REGEX } from '../../../../shared/constants.js';
import { ChargeTypeEnum } from '../../../../shared/enums.js';
import { errorSimplifier } from '../../../../shared/errors.js';
import { AdminContextProvider } from '../../../admin-context/providers/admin-context.provider.js';
import { ScopeProvider } from '../../../auth/providers/scope.provider.js';
import { suggestionDataSchema } from '../../../financial-entities/helpers/business-suggestion-data-schema.helper.js';
import { BusinessesProvider } from '../../../financial-entities/providers/businesses.provider.js';
import { ChargeTagsProvider } from '../../../tags/providers/charge-tags.provider.js';
import { TagsProvider } from '../../../tags/providers/tags.provider.js';
import { IGetTagsByIDsResult } from '../../../tags/types.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import { getChargeType } from '../../helpers/charge-type.js';
import { calculateTotalAmount, getChargeBusinesses } from '../../helpers/common.helper.js';
import { ChargesProvider } from '../../providers/charges.provider.js';
import type { ChargesModule } from '../../types.js';
import { missingConversionInfoSuggestions } from './conversion-suggeestions.resolver.js';
export type Suggestion = Awaited<ResolversTypes['ChargeSuggestions']>;
const missingInfoSuggestions: Resolver<
Maybe<Suggestion>,
ResolversParentTypes['Charge'],
GraphQLModules.Context
> = async (DbCharge, _, context, __) => {
try {
const { injector } = context;
const {
financialAccounts: {
poalimBusinessId,
etherScanBusinessId,
krakenBusinessId,
etanaBusinessId,
},
defaultLocalCurrency,
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const [chargeType, tags] = await Promise.all([
getChargeType(DbCharge, injector),
injector.get(ChargeTagsProvider).getTagsByChargeIDLoader.load(DbCharge.id),
]);
// if all required fields are filled, no need for suggestions
if (!!tags.length && !!DbCharge.user_description?.trim()) {
return null;
}
if (chargeType === ChargeTypeEnum.Conversion) {
return missingConversionInfoSuggestions(DbCharge, _, context, __);
}
const [formattedAmount, { allBusinessIds, mainBusinessId }] = await Promise.all([
calculateTotalAmount(DbCharge.id, injector, defaultLocalCurrency),
getChargeBusinesses(DbCharge.id, injector),
]);
const chargeAmount = formattedAmount?.raw ?? 0;
// if charge has a businesses, use it's suggestion data
if (mainBusinessId) {
const business = await injector
.get(BusinessesProvider)
.getBusinessByIdLoader.load(mainBusinessId);
if (business?.suggestion_data) {
const {
data: suggestionData,
error,
success,
} = suggestionDataSchema.safeParse(business.suggestion_data);
if (!success) {
console.error('Failed to parse suggestion data for business', {
businessId: business.id,
error,
});
throw new GraphQLError('Failed to parse suggestion data for business');
}
return {
description: suggestionData.description,
tags: await Promise.all(
(suggestionData.tags ?? []).map(tag =>
UUID_REGEX.test(tag)
? injector.get(TagsProvider).getTagByIDLoader.load(tag)
: injector.get(TagsProvider).getTagByNameLoader.load(tag),
),
).then(tags => tags.filter(Boolean) as IGetTagsByIDsResult[]),
};
}
}
if (allBusinessIds.length > 1) {
const isKrakenIncluded = krakenBusinessId && allBusinessIds.includes(krakenBusinessId);
const isEtherscanIncluded =
etherScanBusinessId && allBusinessIds.includes(etherScanBusinessId);
const isEtanaIncluded = etanaBusinessId && allBusinessIds.includes(etanaBusinessId);
const isPoalimIncluded = poalimBusinessId && allBusinessIds.includes(poalimBusinessId);
if (isKrakenIncluded && isEtherscanIncluded) {
return {
description: 'Etherscan to Kraken transfer',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
};
}
if (isKrakenIncluded && isEtanaIncluded) {
return {
description: 'Kraken to Etana transfer',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
};
}
if (isPoalimIncluded && isEtanaIncluded) {
return {
description: 'Etana to Poalim transfer',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
};
}
}
const allBusinesses = await injector.get(BusinessesProvider).getAllBusinesses();
const suggestions: Record<string, Suggestion> = {};
for (const business of allBusinesses) {
if (!business.suggestion_data) continue;
const {
data: suggestionData,
error,
success,
} = suggestionDataSchema.safeParse(business.suggestion_data);
if (!success) {
console.error('Failed to parse suggestion data for business', {
businessId: business.id,
error,
});
continue;
}
if (business.id in allBusinessIds) {
return {
description: suggestionData.description,
tags: await Promise.all(
(suggestionData.tags ?? []).map(tag =>
UUID_REGEX.test(tag)
? injector.get(TagsProvider).getTagByIDLoader.load(tag)
: injector.get(TagsProvider).getTagByNameLoader.load(tag),
),
).then(tags => tags.filter(Boolean) as IGetTagsByIDsResult[]),
};
}
if (!suggestionData.phrases) continue;
for (const phrase of suggestionData.phrases) {
suggestions[phrase] = {
description: suggestionData.description,
tags: suggestionData.tags
? await Promise.all(
suggestionData.tags.map(tag =>
UUID_REGEX.test(tag)
? injector.get(TagsProvider).getTagByIDLoader.load(tag)
: injector.get(TagsProvider).getTagByNameLoader.load(tag),
),
).then(tags => tags.filter(Boolean) as IGetTagsByIDsResult[])
: [],
};
}
}
const transactions = await injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(DbCharge.id);
const description = transactions.map(t => t.source_description).join(' ');
for (const [phrase, suggestion] of Object.entries(suggestions)) {
if (Array.isArray(phrase) && new RegExp(phrase.join('|')).test(description)) {
return suggestion;
}
if (description.includes(phrase)) {
return suggestion;
}
}
if (
description.includes('ע\' העברת מט"ח') ||
(description.includes('העברת מט"ח') && Math.abs(chargeAmount) < 400) ||
(description.includes('מטח') && Math.abs(chargeAmount) < 400) ||
description.includes('F.C.COM') ||
description.includes('ע.מפעולות-ישיר') ||
description.includes('ריבית חובה') ||
description.includes('FEE')
) {
const sourceTransaction =
transactions.length === 0
? 'Missing'
: transactions.length === 1
? transactions[0].source_reference
: `['${transactions.map(t => t.source_reference).join("','")}']`;
return {
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
description: `Fees for source transaction=${sourceTransaction}`,
};
}
if (description.includes('דותן שמחה') || description.includes('שמחה דותן')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `${previousMonth}/2022 Salary`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('גולדשטין אורי')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `${previousMonth}/2022 Salary`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('גרדוש')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `${previousMonth}/2022 Salary`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('תובל')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `${previousMonth}/2022 Salary`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('מנורה מבטחים פנס')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Pension ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('פניקס אקסלנס')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Training Fund ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('מיטב דש גמל ופנס')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Pension ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('מגדל מקפת')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Pension ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('מגדל השתלמות')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Training Fund ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('ביטוח לאומי')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Social Security Deductions for Salaries ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('LANCE GLOBAL')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: 'long' });
return {
description: `The Guild Enterprise Support - ${previousMonth} 2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (
(description.includes('העברת מט"ח') &&
(description.includes('fbv') || description.includes('fv'))) ||
description.includes('kamil kisiela')
) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `Software Development and Consulting ${previousMonth}/23`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('slava')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: '2-digit' });
return {
description: `Web Development Services ${previousMonth}/23`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('COURIER PLUS INC')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: 'long' });
return {
description: `GraphQL Hive Enterprise License - ${previousMonth} 2023`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('GOBRANDS')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: 'long' });
return {
description: `GraphQL Hive Enterprise License - ${previousMonth} 2023`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('MEDIC FIRST AI')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: 'long' });
return {
description: `GraphQL Hive Enterprise License - ${previousMonth} 2023`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('מס הכנסה')) {
const flag = description.includes('מס הכנסה ני');
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
description: flag
? `Tax for employees for ${previousMonth}/2022`
: `Advance Tax for ${previousMonth}/2022`,
};
}
if (description.includes('המכס ומעמ-גביי תשלום') || description.includes('CUSTOM + V.A.T')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', {
month: '2-digit',
});
return {
description: `VAT for ${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('חניון')) {
return {
description: 'Parking',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('transportation')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('deel')) {
return {
description: 'Laurin Salary',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('GITHUB')) {
const suggested = {
description: 'GitHub Actions',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
if (chargeAmount <= -2000) {
suggested.description = 'Monthly Sponsor for Benjie, Code-Hex, hayes';
} else if (chargeAmount <= -1000) {
suggested.description = 'Monthly Sponsor for Andarist, warrenday';
} else {
suggested.description = 'GitHub Actions';
}
return suggested;
}
if (chargeAmount === -4329) {
return {
description: 'Office rent',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('APPLE COM BILL/ITUNES.COM')) {
const flag = chargeAmount === -109.9;
return {
taxCategory: 'אתר',
beneficiaaries: [], // NOTE: used to be ' '
description: flag ? 'LinkedIn' : 'Apple Services',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load(flag ? 'business' : 'computer')
.then(res => (res ? [res] : [])),
};
}
if (
description.includes('ע\' העברת מט"ח') ||
(description.includes('העברת מט"ח') && Math.abs(chargeAmount) < 400) ||
(description.includes('מטח') && Math.abs(chargeAmount) < 400) ||
description.includes('F.C.COM') ||
description.includes('ע.מפעולות-ישיר') ||
description.includes('ריבית חובה') ||
description.includes('FEE')
) {
const description = transactions.length
? `['${transactions.map(t => t.source_reference).join("','")}']`
: 'Missing';
//NOTE: multiple suggestions business
return {
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
description,
};
}
if (description.includes('ריבית זכות')) {
//NOTE: multiple suggestions business
return {
description: 'Interest fees on Euro plus',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('פועלים- דמי כרטיס')) {
//NOTE: multiple suggestions business
return {
description: 'Bank creditcard fees',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('financial')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('אריה קריסטל')) {
//NOTE: multiple suggestions business
return {
description: 'Water bill for 04-2022',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('house')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('aleksandra')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: '2-digit' });
return {
description: `Software Consulting Fees (${previousMonth}/2023)`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (description.includes('denelop')) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: '2-digit' });
return {
description: `Software Development and Consulting ${previousMonth}/2023`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (chargeAmount === -12_000) {
const current = new Date();
current.setMonth(current.getMonth() - 1);
const previousMonth = current.toLocaleString('default', { month: '2-digit' });
return {
description: `${previousMonth}/2022`,
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
if (chargeAmount === -600) {
return {
description: 'Matic Zavadlal - April 2021',
tags: await injector
.get(TagsProvider)
.getTagByNameLoader.load('business')
.then(res => (res ? [res] : [])),
};
}
return null;
} catch (error) {
throw errorSimplifier('Unexpected error fetching missing info suggestions', error);
}
};
const commonChargeFields: ChargesModule.ChargeResolvers = {
missingInfoSuggestions: missingInfoSuggestions as ChargeResolvers['missingInfoSuggestions'],
};
export const chargeSuggestionsResolvers: ChargesModule.Resolvers = {
Query: {
similarCharges: async (
_,
{
chargeId,
withMissingTags = false,
withMissingDescription = false,
descriptionDifferentThan,
tagsDifferentThan,
},
{ injector },
) => {
if (!chargeId?.trim()) {
throw new GraphQLError('Charge ID is required');
}
try {
const [mainCharge, { allBusinessIds, mainBusinessId }] = await Promise.all([
injector
.get(ChargesProvider)
.getChargeByIdLoader.load(chargeId)
.catch(e => {
console.error('Error fetching charge', { chargeId, error: e });
throw new GraphQLError('Error fetching charge');
}),
getChargeBusinesses(chargeId, injector),
]);
if (!mainCharge) {
throw new GraphQLError(`Charge not found: ${chargeId}`);
}
const similarCharges = await injector
.get(ChargesProvider)
.getSimilarCharges({
businessId: mainBusinessId,
businessArray: allBusinessIds,
withMissingTags,
withMissingDescription,
tagsDifferentThan: tagsDifferentThan ? [...tagsDifferentThan] : undefined,
descriptionDifferentThan,
ownerId: mainCharge.owner_id,
})
.catch(e => {
console.error('Error fetching similar charges:', {
chargeId,
businessId: mainBusinessId,
businessArray: allBusinessIds,
error: e.message,
});
throw new GraphQLError('Error fetching similar charges');
});
return similarCharges;
} catch (error) {
throw errorSimplifier('Unexpected error fetching similar charges', error);
}
},
similarChargesByBusiness: async (
_,
{ businessId, ownerId: ownerIdInput, tagsDifferentThan, descriptionDifferentThan },
{ injector },
) => {
if (!businessId?.trim()) {
throw new GraphQLError('Business ID is required');
}
try {
// Validate an explicit owner against the read scope (throws if out of
// scope); otherwise default to the request's primary business.
const trimmedOwnerId = ownerIdInput?.trim();
let ownerId: string;
if (trimmedOwnerId) {
await injector.get(ScopeProvider).getReadScope([trimmedOwnerId]);
ownerId = trimmedOwnerId;
} else {
ownerId = (await injector.get(AdminContextProvider).getVerifiedAdminContext()).ownerId;
}
const similarCharges = await injector
.get(ChargesProvider)
.getSimilarCharges({
businessId,
tagsDifferentThan: tagsDifferentThan ? [...tagsDifferentThan] : undefined,
descriptionDifferentThan,
ownerId,
})
.catch(e => {
console.error('Error fetching similar charges:', {
businessId,
ownerId,
error: e.message,
});
throw new GraphQLError('Error fetching similar charges by business');
});
return similarCharges;
} catch (error) {
throw errorSimplifier('Unexpected error fetching similar charges by business', error);
}
},
},
CommonCharge: commonChargeFields,
FinancialCharge: commonChargeFields,
ConversionCharge: commonChargeFields,
SalaryCharge: commonChargeFields,
InternalTransferCharge: commonChargeFields,
DividendCharge: commonChargeFields,
BusinessTripCharge: commonChargeFields,
MonthlyVatCharge: commonChargeFields,
BankDepositCharge: commonChargeFields,
ForeignSecuritiesCharge: commonChargeFields,
CreditcardBankCharge: commonChargeFields,
};