Skip to content

Commit

Permalink
Feat/condo/doma 8371/support state organisations (#4375)
Browse files Browse the repository at this point in the history
* feat(condo): DOMA-8371 add classificationCode field to BankAccount

* feat(condo): DOMA-8371 add classificationCode test utils

* refactor(condo): DOMA-8371 change validationFailureError to throw new GQLError in tests

* feat(condo): DOMA-8371 add classificationCode field to BillingRecipient

* feat(condo): DOMA-8371 add validations for routingNumber and classificationCode

* feat(condo): DOMA-8371 add mutation and schema

* feat(condo): DOMA-8371 add validation and tests for classificationCode

* feat(condo): DOMA-8371 support '01..' routingNumber in FinanceInfoClient

* feat(condo): DOMA-8371 fix test

* fix(condo): DOMA-8371 pr fixes

* fix(condo): DOMA-8371 fix test

* feat(condo): DOMA-8371 remove validations for 01 bics
  • Loading branch information
abshnko authored Feb 13, 2024
1 parent ef5c7bd commit eb4b7be
Show file tree
Hide file tree
Showing 16 changed files with 465 additions and 29 deletions.
13 changes: 13 additions & 0 deletions apps/condo/domains/banking/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ const TRANSACTIONS_NOT_ADDED = {
messageForUser: 'api.banking.BankSyncTask.TRANSACTIONS_NOT_ADDED',
}

const CLASSIFICATION_CODE_IS_INVALID = 'CLASSIFICATION_CODE_IS_INVALID'
const NUMBER_IS_INVALID = 'NUMBER_IS_INVALID'
const ROUTING_NUMBER_IS_INVALID = 'ROUTING_NUMBER_IS_INVALID'
const TIN_IS_INVALID = 'TIN_IS_INVALID'
const INTEGRATION_REASSIGNMENT_NOT_ALLOWED = 'INTEGRATION_REASSIGNMENT_NOT_ALLOWED'
const BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED = 'BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED'

module.exports = {
BANK_INTEGRATION_IDS,
_1C_CLIENT_BANK_EXCHANGE,
Expand All @@ -56,4 +63,10 @@ module.exports = {
INVALID_DATE,
WRONG_INTEGRATION,
TRANSACTIONS_NOT_ADDED,
CLASSIFICATION_CODE_IS_INVALID,
NUMBER_IS_INVALID,
ROUTING_NUMBER_IS_INVALID,
TIN_IS_INVALID,
INTEGRATION_REASSIGNMENT_NOT_ALLOWED,
BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED,
}
67 changes: 54 additions & 13 deletions apps/condo/domains/banking/schema/BankAccount.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,36 @@
* Generated by `createschema banking.BankAccount 'organization:Relationship:Organization:CASCADE; tin:Text; country:Text; routingNumber:Text; number:Text; currency:Text; approvedAt?:DateTimeUtc; approvedBy?:Text; importId?:Text; territoryCode?:Text; bankName?:Text; meta?:Json; tinMeta?:Json; routingNumberMeta?:Json'`
*/

const { Text, DateTimeUtc, Select, Relationship, Virtual, Checkbox } = require('@keystonejs/fields')
const dayjs = require('dayjs')
const { Text, DateTimeUtc, Select, Relationship } = require('@keystonejs/fields')
const { get } = require('lodash')

const { GQLError, GQLErrorCode: { BAD_USER_INPUT } } = require('@open-condo/keystone/errors')
const { Json } = require('@open-condo/keystone/fields')
const { historical, versioned, uuided, tracked, softDeleted, dvAndSender } = require('@open-condo/keystone/plugins')
const { GQLListSchema } = require('@open-condo/keystone/schema')
const { getById, find } = require('@open-condo/keystone/schema')

const access = require('@condo/domains/banking/access/BankAccount')
const { BankTransaction, BankContractorAccount } = require('@condo/domains/banking/utils/serverSchema')
const { CLASSIFICATION_CODE_IS_INVALID,
ROUTING_NUMBER_IS_INVALID,
NUMBER_IS_INVALID,
TIN_IS_INVALID,
INTEGRATION_REASSIGNMENT_NOT_ALLOWED,
BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED } = require('@condo/domains/banking/constants')
const { validateClassificationCode } = require('@condo/domains/banking/utils/validate/classificationCode.utils')
const { validateNumber } = require('@condo/domains/banking/utils/validate/number.utils')
const { validateRoutingNumber } = require('@condo/domains/banking/utils/validate/routingNumber.utils')
const { validateTin } = require('@condo/domains/banking/utils/validate/tin.utils')
const { COUNTRIES } = require('@condo/domains/common/constants/countries')
const { IMPORT_ID_FIELD, CURRENCY_CODE_FIELD } = require('@condo/domains/common/schema/fields')
const { ORGANIZATION_OWNED_FIELD } = require('@condo/domains/organization/schema/fields')

const getTotalAmount = (transactions) => transactions.reduce((amount, transaction) => amount + parseFloat(transaction.amount), 0)
const getError = (type, message, field, code = BAD_USER_INPUT) => ({
code,
type,
message,
variable: ['data', field],
})

const BankAccount = new GQLListSchema('BankAccount', {
schemaDoc: 'Bank account, that will have transactions, pulled from various integrated data sources',
Expand All @@ -33,15 +44,25 @@ const BankAccount = new GQLListSchema('BankAccount', {
ref: 'BankIntegrationAccountContext',
kmigratorOptions: { null: true, on_delete: 'models.PROTECT' },
hooks: {
validateInput: async ({ existingItem, resolvedData, addFieldValidationError, operation }) => {
validateInput: async ({ context, existingItem, resolvedData, operation }) => {
if (operation === 'update' && existingItem.integrationContext) {
return addFieldValidationError(`Integration reassignment is not allowed for BankAccount with id="${existingItem.id}"`)
throw new GQLError(getError(
INTEGRATION_REASSIGNMENT_NOT_ALLOWED,
`Integration reassignment is not allowed for BankAccount with id="${existingItem.id}"`,
'integrationContext'),
context,
)
}
const resolvedFields = { ...existingItem, ...resolvedData }
const bankIntegrationAccountContext = await getById('BankIntegrationAccountContext', get(resolvedFields, 'integrationContext'))
const alreadyConnectedBankAccounts = await find('BankAccount', { integrationContext: { id: bankIntegrationAccountContext.id } })
if (alreadyConnectedBankAccounts.length > 0) {
return addFieldValidationError(`Cannot connect to BankIntegrationAccountContext, used by another BankAccount(id="${alreadyConnectedBankAccounts[0].id}")`)
throw new GQLError(getError(
BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED,
`Cannot connect to BankIntegrationAccountContext, used by another BankAccount(id="${alreadyConnectedBankAccounts[0].id}")`,
'integrationContext'),
context,
)
}
},
},
Expand All @@ -59,7 +80,7 @@ const BankAccount = new GQLListSchema('BankAccount', {
type: Text,
isRequired: true,
hooks: {
validateInput: ({ existingItem, resolvedData, addFieldValidationError }) => {
validateInput: ({ context, existingItem, resolvedData }) => {
const newItem = { ...existingItem, ...resolvedData }

const country = get(newItem, 'country')
Expand All @@ -68,7 +89,7 @@ const BankAccount = new GQLListSchema('BankAccount', {
const { result, errors } = validateTin(tin, country)

if ( !result ) {
addFieldValidationError(errors[0])
throw new GQLError(getError(TIN_IS_INVALID, errors[0], 'tin'), context)
}
},
},
Expand All @@ -92,7 +113,7 @@ const BankAccount = new GQLListSchema('BankAccount', {
type: Text,
isRequired: true,
hooks: {
validateInput: ({ existingItem, resolvedData, addFieldValidationError }) => {
validateInput: ({ context, existingItem, resolvedData }) => {
const newItem = { ...existingItem, ...resolvedData }

const country = get(newItem, 'country')
Expand All @@ -101,7 +122,7 @@ const BankAccount = new GQLListSchema('BankAccount', {
const { result, errors } = validateRoutingNumber(routingNumber, country)

if ( !result ) {
addFieldValidationError(errors[0])
throw new GQLError(getError(ROUTING_NUMBER_IS_INVALID, errors[0], 'routingNumber'), context)
}
},
},
Expand All @@ -118,7 +139,7 @@ const BankAccount = new GQLListSchema('BankAccount', {
type: Text,
isRequired: true,
hooks: {
validateInput: ({ existingItem, resolvedData, addFieldValidationError }) => {
validateInput: ({ context, existingItem, resolvedData }) => {
const newItem = { ...existingItem, ...resolvedData }

const country = get(newItem, 'country')
Expand All @@ -128,7 +149,7 @@ const BankAccount = new GQLListSchema('BankAccount', {
const { result, errors } = validateNumber(number, routingNumber, country)

if ( !result ) {
addFieldValidationError(errors[0])
throw new GQLError(getError(NUMBER_IS_INVALID, errors[0], 'number'), context)
}
},
},
Expand Down Expand Up @@ -179,6 +200,26 @@ const BankAccount = new GQLListSchema('BankAccount', {
type: Json,
isRequired: false,
},

classificationCode: {
schemaDoc: 'Budget classification code, used for state-funded organizations',
type: Text,
isRequired: false,
hooks: {
validateInput: ({ context, existingItem, resolvedData }) => {
const newItem = { ...existingItem, ...resolvedData }

const country = get(newItem, 'country')
const code = get(newItem, 'classificationCode')

const { result, errors } = validateClassificationCode(code, country)

if ( !result ) {
throw new GQLError(getError(CLASSIFICATION_CODE_IS_INVALID, errors[0], 'classificationCode'), context)
}
},
},
},
},
plugins: [uuided(), versioned(), tracked(), softDeleted(), dvAndSender(), historical()],
access: {
Expand Down
32 changes: 23 additions & 9 deletions apps/condo/domains/banking/schema/BankAccount.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ const {
makeClient,
makeLoggedInAdminClient,
UUID_RE,
expectToThrowValidationFailureError,
expectToThrowUniqueConstraintViolationError,
expectValuesOfCommonFields,
expectValuesOfCommonFields, expectToThrowGQLError,
} = require('@open-condo/keystone/test.utils')
const {
expectToThrowAuthenticationErrorToObj, expectToThrowAuthenticationErrorToObjects,
Expand Down Expand Up @@ -570,11 +569,17 @@ describe('BankAccount', () => {
const [anotherBankAccount] = await createTestBankAccount(adminClient, organization, {
integrationContext: { connect: { id: bankIntegrationAccountContext.id } },
})
await expectToThrowValidationFailureError(async () => {

await expectToThrowGQLError(async () => {
await createTestBankAccount(adminClient, organization, {
integrationContext: { connect: { id: anotherBankAccount.integrationContext.id } },
})
}, `Cannot connect to BankIntegrationAccountContext, used by another BankAccount(id="${anotherBankAccount.id}")`)
}, {
code: 'BAD_USER_INPUT',
type: 'BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED',
message: `Cannot connect to BankIntegrationAccountContext, used by another BankAccount(id="${anotherBankAccount.id}")`,
variable: ['data', 'integrationContext'],
})
})

test('can\'t connect existing BankAccount without BankIntegrationAccountContext to BankIntegrationAccountContext used by another BankAccount', async () => {
Expand All @@ -584,14 +589,18 @@ describe('BankAccount', () => {
const [anotherBankAccount] = await createTestBankAccount(adminClient, organization, {
integrationContext: { connect: { id: anotherBankIntegrationAccountContext.id } },
})
await expectToThrowValidationFailureError(async () => {
await expectToThrowGQLError(async () => {
await updateTestBankAccount(adminClient, bankAccount.id, {
integrationContext: {
connect: { id: anotherBankIntegrationAccountContext.id },
},
})
}, `Cannot connect to BankIntegrationAccountContext, used by another BankAccount(id="${anotherBankAccount.id}")`)

}, {
code: 'BAD_USER_INPUT',
type: 'BANK_INTEGRATION_ACCOUNT_CONTEXT_ALREADY_USED',
message: `Cannot connect to BankIntegrationAccountContext, used by another BankAccount(id="${anotherBankAccount.id}")`,
variable: ['data', 'integrationContext'],
})
})

test('cannot connect BankIntegrationAccountContext if BankAccount is already connected to some integrationContext', async () => {
Expand All @@ -604,11 +613,16 @@ describe('BankAccount', () => {
integrationContext: { connect: { id: BankIntegrationAccountContext.id } },
})

await expectToThrowValidationFailureError(async () => {
await expectToThrowGQLError(async () => {
await updateTestBankAccount(adminClient, bankAccount.id, {
integrationContext: { connect: { id: anotherIntegrationContext.id } },
})
}, `Integration reassignment is not allowed for BankAccount with id="${bankAccount.id}"`)
}, {
code: 'BAD_USER_INPUT',
type: 'INTEGRATION_REASSIGNMENT_NOT_ALLOWED',
message: `Integration reassignment is not allowed for BankAccount with id="${bankAccount.id}"`,
variable: ['data', 'integrationContext'],
})
})
})
})
7 changes: 7 additions & 0 deletions apps/condo/domains/banking/utils/testSchema/bankAccount.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function bulidValidRequisitesForRuBankAccount (extra = {}) {
const tin = createValidRuTin10()
const routingNumber = createValidRuRoutingNumber()
const number = createValidRuNumber(routingNumber)
const classificationCode = createValidRuClassificationCode()

const validRUBankAccount = {
tin,
Expand All @@ -20,6 +21,7 @@ function bulidValidRequisitesForRuBankAccount (extra = {}) {
bankName: faker.company.name(),
currencyCode: 'RUB',
territoryCode: faker.datatype.number().toString(),
classificationCode,
}
return {
...validRUBankAccount,
Expand Down Expand Up @@ -47,6 +49,10 @@ function createValidRuRoutingNumber () {
return '04' + faker.datatype.number(getRange(7)).toString()
}

function createValidRuClassificationCode () {
return faker.datatype.number(getRange(20)).toString()
}

function createValidRuTin10 () {
const tin = faker.datatype.number(getRange(10)).toString()

Expand All @@ -71,4 +77,5 @@ module.exports = {
createValidRuRoutingNumber,
createValidRuTin10,
createValidRuTin12,
createValidRuClassificationCode,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const { getCountrySpecificValidator } = require('./countrySpecificValidators')

/*
* Classification code validator
*
* The following check is performed:
* 1) Сhecking for emptiness
*
* Example:
* RU - 90205039900060030131
*/

const EMPTY = 'Classification code is empty'
const NOT_NUMERIC = 'Classification code can contain only digits'

const validateClassificationCode = (routingNumber, country) => {
const errors = []

const classificationCodeWithoutSpaces = routingNumber.toString().trim()
if (!classificationCodeWithoutSpaces.length) {
errors.push(EMPTY)
}
if (!/^[0-9]*$/.test(classificationCodeWithoutSpaces)) {
errors.push(NOT_NUMERIC)
}

getCountrySpecificValidator('classificationCode', country)(classificationCodeWithoutSpaces, errors)

return {
result: !errors.length,
errors: errors,
}
}

module.exports = {
validateClassificationCode,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const { createValidRuClassificationCode } = require('@condo/domains/banking/utils/testSchema/bankAccount')
const { validateClassificationCode } = require('@condo/domains/banking/utils/validate/classificationCode.utils')
const { SPACE_SYMBOLS, SPACE_SYMBOL_LABLES } = require('@condo/domains/common/utils/string.utils')

const SPACES = SPACE_SYMBOLS.split('')


describe('validateClassificationCode', () => {
describe('Ru', () => {

const COUNTRY_CODE_RU = 'ru'
const VALID_RU_CLASSIFICATION_CODE = ['90205039900060030131', '18205049900160030200', '15305036900250030200']
const WRONG_LENGTH_RU_CLASSIFICATION_CODE = '902050399000600301311'
const WRONG_FORMAT_RU_CLASSIFICATION_CODE = 'A0205039900060030131'

VALID_RU_CLASSIFICATION_CODE.forEach(code => {
test(`should pass if valid: (${code})`, () => {
const { result } = validateClassificationCode(code, COUNTRY_CODE_RU)
expect(result).toBe(true)
})

SPACES.forEach(spaceSymbol => {
test(`should pass if valid: (${code}) with spaces symbol (${SPACE_SYMBOL_LABLES[spaceSymbol] || spaceSymbol})`, () => {
const classificationCodeValue = `${spaceSymbol}${code}${spaceSymbol}`

const { result } = validateClassificationCode(classificationCodeValue, COUNTRY_CODE_RU)
expect(result).toBe(true)
})
})
})

test('classification code from generator should pass', () => {
const code = createValidRuClassificationCode()
const { result } = validateClassificationCode(code, COUNTRY_CODE_RU)
expect(result).toBe(true)
})

test('should fail if has wrong length', () => {
const { result, errors } = validateClassificationCode(WRONG_LENGTH_RU_CLASSIFICATION_CODE, COUNTRY_CODE_RU)
expect(result).toBe(false)
expect(errors[0]).toBe('Classification code length was expected to be 20, but received 21')
})
test('should fail if contain invalid chars', () => {
const { result, errors } = validateClassificationCode(WRONG_FORMAT_RU_CLASSIFICATION_CODE, COUNTRY_CODE_RU)
expect(result).toBe(false)
expect(errors[0]).toBe('Classification code can contain only digits')
})
test('should fail if empty', () => {
const { result, errors } = validateClassificationCode('', COUNTRY_CODE_RU)
expect(result).toBe(false)
expect(errors[0]).toBe('Classification code is empty')
})
})
})
Loading

0 comments on commit eb4b7be

Please sign in to comment.