diff --git a/components/crm/Dockerfile b/components/crm/Dockerfile index 0dd5d676f..103e8d3fd 100644 --- a/components/crm/Dockerfile +++ b/components/crm/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.4 # ci: force rebuild crm image - 2026-04-01 -FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder WORKDIR /crm-app diff --git a/components/crm/api/docs.go b/components/crm/api/docs.go index 8bc01f788..a25e7daec 100644 --- a/components/crm/api/docs.go +++ b/components/crm/api/docs.go @@ -117,6 +117,18 @@ const docTemplate = `{ "name": "banking_details_iban", "in": "query" }, + { + "type": "string", + "description": "Filter alias by banking details bank ID", + "name": "banking_details_bank_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter alias by banking details type", + "name": "banking_details_type", + "in": "query" + }, { "type": "string", "description": "Filter alias by regulatory fields participant document", @@ -179,6 +191,186 @@ const docTemplate = `{ } } }, + "/v1/aliases/backfill-bank-account-index": { + "post": { + "description": "Scans tenant alias collections and rebuilds alias_bank_account_index rows. Report contains counts and alias IDs only; no document, account, or bank identity values.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Aliases" + ], + "summary": "Backfill Alias Bank Account Resolver Index", + "parameters": [ + { + "type": "string", + "description": "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled.", + "name": "Authorization", + "in": "header" + }, + { + "description": "Backfill Input", + "name": "backfill", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/BackfillBankAccountIndexRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mmodel.BankAccountIndexBackfillReport" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + } + } + } + }, + "/v1/aliases/resolve-account": { + "post": { + "description": "Resolves an active alias across the current tenant by account ID. Does not require X-Organization-Id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Aliases" + ], + "summary": "Resolve Alias by Account ID", + "parameters": [ + { + "type": "string", + "description": "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled.", + "name": "Authorization", + "in": "header" + }, + { + "description": "Account Resolver Input", + "name": "resolver", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ResolveAccountRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ResolveAliasResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + } + } + } + }, + "/v1/aliases/resolve-bank-account": { + "post": { + "description": "Resolves an active alias across the current tenant by holder document and exact bank-account identity. Does not require X-Organization-Id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Aliases" + ], + "summary": "Resolve Alias by Bank Account", + "parameters": [ + { + "type": "string", + "description": "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled.", + "name": "Authorization", + "in": "header" + }, + { + "description": "Bank Account Resolver Input", + "name": "resolver", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ResolveBankAccountRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ResolveAliasResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + } + } + } + }, "/v1/holders": { "get": { "description": "List all Holders. CRM listing endpoints support pagination using the page, limit, and sort parameters. The sort parameter orders results by the entity ID using the UUID v7 standard, which is time-sortable, ensuring chronological ordering of the results.", @@ -1012,6 +1204,10 @@ const docTemplate = `{ "type": "object", "additionalProperties": {} }, + "organizationId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, "regulatoryFields": { "$ref": "#/definitions/RegulatoryFields" }, @@ -1031,6 +1227,16 @@ const docTemplate = `{ } } }, + "BackfillBankAccountIndexRequest": { + "description": "BackfillBankAccountIndexRequest payload", + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "example": true + } + } + }, "BankingDetails": { "description": "BankingDetails object", "type": "object", @@ -1460,6 +1666,119 @@ const docTemplate = `{ } } }, + "ResolveAccountRequest": { + "description": "ResolveAccountRequest payload", + "type": "object", + "required": [ + "accountId" + ], + "properties": { + "accountId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + } + } + }, + "ResolveAliasBankingDetailsResponse": { + "description": "ResolveAliasBankingDetailsResponse object", + "type": "object", + "properties": { + "account": { + "type": "string", + "example": "1234567" + }, + "bankId": { + "type": "string", + "example": "12345678" + }, + "branch": { + "type": "string", + "example": "0001" + }, + "type": { + "type": "string", + "example": "CACC" + } + } + }, + "ResolveAliasResponse": { + "description": "ResolveAliasResponse payload", + "type": "object", + "properties": { + "accountId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "bankingDetails": { + "$ref": "#/definitions/ResolveAliasBankingDetailsResponse" + }, + "holderDocument": { + "type": "string", + "example": "12345678901" + }, + "holderId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "id": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "ledgerId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "organizationId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + } + } + }, + "ResolveBankAccountBankingDetailsRequest": { + "description": "ResolveBankAccountBankingDetails object", + "type": "object", + "required": [ + "account", + "bankId", + "branch", + "type" + ], + "properties": { + "account": { + "type": "string", + "example": "1234567" + }, + "bankId": { + "type": "string", + "example": "12345678" + }, + "branch": { + "type": "string", + "example": "0001" + }, + "type": { + "type": "string", + "example": "CACC" + } + } + }, + "ResolveBankAccountRequest": { + "description": "ResolveBankAccountRequest payload", + "type": "object", + "required": [ + "bankingDetails", + "document" + ], + "properties": { + "bankingDetails": { + "$ref": "#/definitions/ResolveBankAccountBankingDetailsRequest" + }, + "document": { + "type": "string", + "example": "12345678901" + } + } + }, "UpdateAliasRequest": { "description": "UpdateAliasRequest payload", "type": "object", @@ -1565,6 +1884,47 @@ const docTemplate = `{ } } }, + "mmodel.BankAccountIndexBackfillReport": { + "type": "object", + "properties": { + "aliasesScanned": { + "type": "integer" + }, + "collectionsScanned": { + "type": "integer" + }, + "dryRun": { + "type": "boolean" + }, + "duplicateAliasIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "duplicateAliasIdsTruncated": { + "type": "boolean" + }, + "duplicates": { + "type": "integer" + }, + "incomplete": { + "type": "integer" + }, + "incompleteAliasIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "incompleteAliasIdsTruncated": { + "type": "boolean" + }, + "upserted": { + "type": "integer" + } + } + }, "pkg.HTTPError": { "type": "object", "properties": { diff --git a/components/crm/api/openapi.yaml b/components/crm/api/openapi.yaml index 155f902a8..e914e42f5 100644 --- a/components/crm/api/openapi.yaml +++ b/components/crm/api/openapi.yaml @@ -93,6 +93,16 @@ paths: name: banking_details_iban schema: type: string + - description: Filter alias by banking details bank ID + in: query + name: banking_details_bank_id + schema: + type: string + - description: Filter alias by banking details type + in: query + name: banking_details_type + schema: + type: string - description: Filter alias by regulatory fields participant document in: query name: regulatory_fields_participant_document @@ -136,6 +146,154 @@ paths: summary: List Aliases tags: - Aliases + /v1/aliases/backfill-bank-account-index: + post: + description: Scans tenant alias collections and rebuilds alias_bank_account_index + rows. Report contains counts and alias IDs only; no document, account, or + bank identity values. + parameters: + - description: The authorization token in the 'Bearer access_token' format. + Only required when auth plugin is enabled. + in: header + name: Authorization + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BackfillBankAccountIndexRequest' + description: Backfill Input + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/mmodel.BankAccountIndexBackfillReport' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Internal Server Error + summary: Backfill Alias Bank Account Resolver Index + tags: + - Aliases + x-codegen-request-body-name: backfill + /v1/aliases/resolve-account: + post: + description: Resolves an active alias across the current tenant by account ID. + Does not require X-Organization-Id. + parameters: + - description: The authorization token in the 'Bearer access_token' format. + Only required when auth plugin is enabled. + in: header + name: Authorization + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveAccountRequest' + description: Account Resolver Input + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveAliasResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Internal Server Error + summary: Resolve Alias by Account ID + tags: + - Aliases + x-codegen-request-body-name: resolver + /v1/aliases/resolve-bank-account: + post: + description: Resolves an active alias across the current tenant by holder document + and exact bank-account identity. Does not require X-Organization-Id. + parameters: + - description: The authorization token in the 'Bearer access_token' format. + Only required when auth plugin is enabled. + in: header + name: Authorization + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveBankAccountRequest' + description: Bank Account Resolver Input + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveAliasResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg.HTTPError' + description: Internal Server Error + summary: Resolve Alias by Bank Account + tags: + - Aliases + x-codegen-request-body-name: resolver /v1/holders: get: description: List all Holders. CRM listing endpoints support pagination using @@ -878,6 +1036,7 @@ components: type: LEGAL_PERSON holderId: 00000000-0000-0000-0000-000000000000 ledgerId: 00000000-0000-0000-0000-000000000000 + organizationId: 00000000-0000-0000-0000-000000000000 accountId: 00000000-0000-0000-0000-000000000000 createdAt: 2025-01-01T00:00:00Z deletedAt: 2025-01-01T00:00:00Z @@ -933,6 +1092,9 @@ components: additionalProperties: type: object type: object + organizationId: + example: 00000000-0000-0000-0000-000000000000 + type: string regulatoryFields: $ref: '#/components/schemas/RegulatoryFields' relatedParties: @@ -946,6 +1108,15 @@ components: example: 2025-01-01T00:00:00Z type: string type: object + BackfillBankAccountIndexRequest: + description: BackfillBankAccountIndexRequest payload + example: + dryRun: true + properties: + dryRun: + example: true + type: boolean + type: object BankingDetails: description: BankingDetails object example: @@ -1416,6 +1587,119 @@ components: example: CFO type: string type: object + ResolveAccountRequest: + description: ResolveAccountRequest payload + example: + accountId: 00000000-0000-0000-0000-000000000000 + properties: + accountId: + example: 00000000-0000-0000-0000-000000000000 + type: string + required: + - accountId + type: object + ResolveAliasBankingDetailsResponse: + description: ResolveAliasBankingDetailsResponse object + example: + bankId: "12345678" + type: CACC + branch: "0001" + account: "1234567" + properties: + account: + example: "1234567" + type: string + bankId: + example: "12345678" + type: string + branch: + example: "0001" + type: string + type: + example: CACC + type: string + type: object + ResolveAliasResponse: + description: ResolveAliasResponse payload + example: + ledgerId: 00000000-0000-0000-0000-000000000000 + organizationId: 00000000-0000-0000-0000-000000000000 + accountId: 00000000-0000-0000-0000-000000000000 + bankingDetails: + bankId: "12345678" + type: CACC + branch: "0001" + account: "1234567" + id: 00000000-0000-0000-0000-000000000000 + holderDocument: "12345678901" + holderId: 00000000-0000-0000-0000-000000000000 + properties: + accountId: + example: 00000000-0000-0000-0000-000000000000 + type: string + bankingDetails: + $ref: '#/components/schemas/ResolveAliasBankingDetailsResponse' + holderDocument: + example: "12345678901" + type: string + holderId: + example: 00000000-0000-0000-0000-000000000000 + type: string + id: + example: 00000000-0000-0000-0000-000000000000 + type: string + ledgerId: + example: 00000000-0000-0000-0000-000000000000 + type: string + organizationId: + example: 00000000-0000-0000-0000-000000000000 + type: string + type: object + ResolveBankAccountBankingDetailsRequest: + description: ResolveBankAccountBankingDetails object + example: + bankId: "12345678" + type: CACC + branch: "0001" + account: "1234567" + properties: + account: + example: "1234567" + type: string + bankId: + example: "12345678" + type: string + branch: + example: "0001" + type: string + type: + example: CACC + type: string + required: + - account + - bankId + - branch + - type + type: object + ResolveBankAccountRequest: + description: ResolveBankAccountRequest payload + example: + bankingDetails: + bankId: "12345678" + type: CACC + branch: "0001" + account: "1234567" + document: "12345678901" + properties: + bankingDetails: + $ref: '#/components/schemas/ResolveBankAccountBankingDetailsRequest' + document: + example: "12345678901" + type: string + required: + - bankingDetails + - document + type: object UpdateAliasRequest: description: UpdateAliasRequest payload example: @@ -1526,6 +1810,48 @@ components: prev_cursor: type: string type: object + mmodel.BankAccountIndexBackfillReport: + example: + collectionsScanned: 6 + duplicates: 1 + incomplete: 5 + dryRun: true + duplicateAliasIdsTruncated: true + duplicateAliasIds: + - duplicateAliasIds + - duplicateAliasIds + aliasesScanned: 0 + upserted: 5 + incompleteAliasIds: + - incompleteAliasIds + - incompleteAliasIds + incompleteAliasIdsTruncated: true + properties: + aliasesScanned: + type: integer + collectionsScanned: + type: integer + dryRun: + type: boolean + duplicateAliasIds: + items: + type: string + type: array + duplicateAliasIdsTruncated: + type: boolean + duplicates: + type: integer + incomplete: + type: integer + incompleteAliasIds: + items: + type: string + type: array + incompleteAliasIdsTruncated: + type: boolean + upserted: + type: integer + type: object pkg.HTTPError: properties: code: @@ -1556,6 +1882,7 @@ components: type: LEGAL_PERSON holderId: 00000000-0000-0000-0000-000000000000 ledgerId: 00000000-0000-0000-0000-000000000000 + organizationId: 00000000-0000-0000-0000-000000000000 accountId: 00000000-0000-0000-0000-000000000000 createdAt: 2025-01-01T00:00:00Z deletedAt: 2025-01-01T00:00:00Z @@ -1591,6 +1918,7 @@ components: type: LEGAL_PERSON holderId: 00000000-0000-0000-0000-000000000000 ledgerId: 00000000-0000-0000-0000-000000000000 + organizationId: 00000000-0000-0000-0000-000000000000 accountId: 00000000-0000-0000-0000-000000000000 createdAt: 2025-01-01T00:00:00Z deletedAt: 2025-01-01T00:00:00Z diff --git a/components/crm/api/swagger.json b/components/crm/api/swagger.json index b3181626f..653a17ff6 100644 --- a/components/crm/api/swagger.json +++ b/components/crm/api/swagger.json @@ -114,6 +114,18 @@ "name": "banking_details_iban", "in": "query" }, + { + "type": "string", + "description": "Filter alias by banking details bank ID", + "name": "banking_details_bank_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter alias by banking details type", + "name": "banking_details_type", + "in": "query" + }, { "type": "string", "description": "Filter alias by regulatory fields participant document", @@ -176,6 +188,186 @@ } } }, + "/v1/aliases/backfill-bank-account-index": { + "post": { + "description": "Scans tenant alias collections and rebuilds alias_bank_account_index rows. Report contains counts and alias IDs only; no document, account, or bank identity values.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Aliases" + ], + "summary": "Backfill Alias Bank Account Resolver Index", + "parameters": [ + { + "type": "string", + "description": "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled.", + "name": "Authorization", + "in": "header" + }, + { + "description": "Backfill Input", + "name": "backfill", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/BackfillBankAccountIndexRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mmodel.BankAccountIndexBackfillReport" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + } + } + } + }, + "/v1/aliases/resolve-account": { + "post": { + "description": "Resolves an active alias across the current tenant by account ID. Does not require X-Organization-Id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Aliases" + ], + "summary": "Resolve Alias by Account ID", + "parameters": [ + { + "type": "string", + "description": "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled.", + "name": "Authorization", + "in": "header" + }, + { + "description": "Account Resolver Input", + "name": "resolver", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ResolveAccountRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ResolveAliasResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + } + } + } + }, + "/v1/aliases/resolve-bank-account": { + "post": { + "description": "Resolves an active alias across the current tenant by holder document and exact bank-account identity. Does not require X-Organization-Id.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Aliases" + ], + "summary": "Resolve Alias by Bank Account", + "parameters": [ + { + "type": "string", + "description": "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled.", + "name": "Authorization", + "in": "header" + }, + { + "description": "Bank Account Resolver Input", + "name": "resolver", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ResolveBankAccountRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ResolveAliasResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg.HTTPError" + } + } + } + } + }, "/v1/holders": { "get": { "description": "List all Holders. CRM listing endpoints support pagination using the page, limit, and sort parameters. The sort parameter orders results by the entity ID using the UUID v7 standard, which is time-sortable, ensuring chronological ordering of the results.", @@ -1009,6 +1201,10 @@ "type": "object", "additionalProperties": {} }, + "organizationId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, "regulatoryFields": { "$ref": "#/definitions/RegulatoryFields" }, @@ -1028,6 +1224,16 @@ } } }, + "BackfillBankAccountIndexRequest": { + "description": "BackfillBankAccountIndexRequest payload", + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "example": true + } + } + }, "BankingDetails": { "description": "BankingDetails object", "type": "object", @@ -1457,6 +1663,119 @@ } } }, + "ResolveAccountRequest": { + "description": "ResolveAccountRequest payload", + "type": "object", + "required": [ + "accountId" + ], + "properties": { + "accountId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + } + } + }, + "ResolveAliasBankingDetailsResponse": { + "description": "ResolveAliasBankingDetailsResponse object", + "type": "object", + "properties": { + "account": { + "type": "string", + "example": "1234567" + }, + "bankId": { + "type": "string", + "example": "12345678" + }, + "branch": { + "type": "string", + "example": "0001" + }, + "type": { + "type": "string", + "example": "CACC" + } + } + }, + "ResolveAliasResponse": { + "description": "ResolveAliasResponse payload", + "type": "object", + "properties": { + "accountId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "bankingDetails": { + "$ref": "#/definitions/ResolveAliasBankingDetailsResponse" + }, + "holderDocument": { + "type": "string", + "example": "12345678901" + }, + "holderId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "id": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "ledgerId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + }, + "organizationId": { + "type": "string", + "example": "00000000-0000-0000-0000-000000000000" + } + } + }, + "ResolveBankAccountBankingDetailsRequest": { + "description": "ResolveBankAccountBankingDetails object", + "type": "object", + "required": [ + "account", + "bankId", + "branch", + "type" + ], + "properties": { + "account": { + "type": "string", + "example": "1234567" + }, + "bankId": { + "type": "string", + "example": "12345678" + }, + "branch": { + "type": "string", + "example": "0001" + }, + "type": { + "type": "string", + "example": "CACC" + } + } + }, + "ResolveBankAccountRequest": { + "description": "ResolveBankAccountRequest payload", + "type": "object", + "required": [ + "bankingDetails", + "document" + ], + "properties": { + "bankingDetails": { + "$ref": "#/definitions/ResolveBankAccountBankingDetailsRequest" + }, + "document": { + "type": "string", + "example": "12345678901" + } + } + }, "UpdateAliasRequest": { "description": "UpdateAliasRequest payload", "type": "object", @@ -1562,6 +1881,47 @@ } } }, + "mmodel.BankAccountIndexBackfillReport": { + "type": "object", + "properties": { + "aliasesScanned": { + "type": "integer" + }, + "collectionsScanned": { + "type": "integer" + }, + "dryRun": { + "type": "boolean" + }, + "duplicateAliasIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "duplicateAliasIdsTruncated": { + "type": "boolean" + }, + "duplicates": { + "type": "integer" + }, + "incomplete": { + "type": "integer" + }, + "incompleteAliasIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "incompleteAliasIdsTruncated": { + "type": "boolean" + }, + "upserted": { + "type": "integer" + } + } + }, "pkg.HTTPError": { "type": "object", "properties": { diff --git a/components/crm/api/swagger.yaml b/components/crm/api/swagger.yaml index 470b3ce84..2641d949d 100644 --- a/components/crm/api/swagger.yaml +++ b/components/crm/api/swagger.yaml @@ -103,6 +103,9 @@ definitions: metadata: additionalProperties: {} type: object + organizationId: + example: 00000000-0000-0000-0000-000000000000 + type: string regulatoryFields: $ref: '#/definitions/RegulatoryFields' relatedParties: @@ -116,6 +119,13 @@ definitions: example: "2025-01-01T00:00:00Z" type: string type: object + BackfillBankAccountIndexRequest: + description: BackfillBankAccountIndexRequest payload + properties: + dryRun: + example: true + type: boolean + type: object BankingDetails: description: BankingDetails object properties: @@ -445,6 +455,88 @@ definitions: example: CFO type: string type: object + ResolveAccountRequest: + description: ResolveAccountRequest payload + properties: + accountId: + example: 00000000-0000-0000-0000-000000000000 + type: string + required: + - accountId + type: object + ResolveAliasBankingDetailsResponse: + description: ResolveAliasBankingDetailsResponse object + properties: + account: + example: "1234567" + type: string + bankId: + example: "12345678" + type: string + branch: + example: "0001" + type: string + type: + example: CACC + type: string + type: object + ResolveAliasResponse: + description: ResolveAliasResponse payload + properties: + accountId: + example: 00000000-0000-0000-0000-000000000000 + type: string + bankingDetails: + $ref: '#/definitions/ResolveAliasBankingDetailsResponse' + holderDocument: + example: "12345678901" + type: string + holderId: + example: 00000000-0000-0000-0000-000000000000 + type: string + id: + example: 00000000-0000-0000-0000-000000000000 + type: string + ledgerId: + example: 00000000-0000-0000-0000-000000000000 + type: string + organizationId: + example: 00000000-0000-0000-0000-000000000000 + type: string + type: object + ResolveBankAccountBankingDetailsRequest: + description: ResolveBankAccountBankingDetails object + properties: + account: + example: "1234567" + type: string + bankId: + example: "12345678" + type: string + branch: + example: "0001" + type: string + type: + example: CACC + type: string + required: + - account + - bankId + - branch + - type + type: object + ResolveBankAccountRequest: + description: ResolveBankAccountRequest payload + properties: + bankingDetails: + $ref: '#/definitions/ResolveBankAccountBankingDetailsRequest' + document: + example: "12345678901" + type: string + required: + - bankingDetails + - document + type: object UpdateAliasRequest: description: UpdateAliasRequest payload properties: @@ -513,6 +605,33 @@ definitions: prev_cursor: type: string type: object + mmodel.BankAccountIndexBackfillReport: + properties: + aliasesScanned: + type: integer + collectionsScanned: + type: integer + dryRun: + type: boolean + duplicateAliasIds: + items: + type: string + type: array + duplicateAliasIdsTruncated: + type: boolean + duplicates: + type: integer + incomplete: + type: integer + incompleteAliasIds: + items: + type: string + type: array + incompleteAliasIdsTruncated: + type: boolean + upserted: + type: integer + type: object pkg.HTTPError: properties: code: @@ -604,6 +723,14 @@ paths: in: query name: banking_details_iban type: string + - description: Filter alias by banking details bank ID + in: query + name: banking_details_bank_id + type: string + - description: Filter alias by banking details type + in: query + name: banking_details_type + type: string - description: Filter alias by regulatory fields participant document in: query name: regulatory_fields_participant_document @@ -645,6 +772,131 @@ paths: summary: List Aliases tags: - Aliases + /v1/aliases/backfill-bank-account-index: + post: + consumes: + - application/json + description: Scans tenant alias collections and rebuilds alias_bank_account_index + rows. Report contains counts and alias IDs only; no document, account, or + bank identity values. + parameters: + - description: The authorization token in the 'Bearer access_token' format. + Only required when auth plugin is enabled. + in: header + name: Authorization + type: string + - description: Backfill Input + in: body + name: backfill + required: true + schema: + $ref: '#/definitions/BackfillBankAccountIndexRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mmodel.BankAccountIndexBackfillReport' + "400": + description: Bad Request + schema: + $ref: '#/definitions/pkg.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/pkg.HTTPError' + summary: Backfill Alias Bank Account Resolver Index + tags: + - Aliases + /v1/aliases/resolve-account: + post: + consumes: + - application/json + description: Resolves an active alias across the current tenant by account ID. + Does not require X-Organization-Id. + parameters: + - description: The authorization token in the 'Bearer access_token' format. + Only required when auth plugin is enabled. + in: header + name: Authorization + type: string + - description: Account Resolver Input + in: body + name: resolver + required: true + schema: + $ref: '#/definitions/ResolveAccountRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ResolveAliasResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/pkg.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/pkg.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/pkg.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/pkg.HTTPError' + summary: Resolve Alias by Account ID + tags: + - Aliases + /v1/aliases/resolve-bank-account: + post: + consumes: + - application/json + description: Resolves an active alias across the current tenant by holder document + and exact bank-account identity. Does not require X-Organization-Id. + parameters: + - description: The authorization token in the 'Bearer access_token' format. + Only required when auth plugin is enabled. + in: header + name: Authorization + type: string + - description: Bank Account Resolver Input + in: body + name: resolver + required: true + schema: + $ref: '#/definitions/ResolveBankAccountRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ResolveAliasResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/pkg.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/pkg.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/pkg.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/pkg.HTTPError' + summary: Resolve Alias by Bank Account + tags: + - Aliases /v1/holders: get: description: List all Holders. CRM listing endpoints support pagination using diff --git a/components/crm/internal/adapters/http/in/alias.go b/components/crm/internal/adapters/http/in/alias.go index f063f71d2..f9cb397f6 100644 --- a/components/crm/internal/adapters/http/in/alias.go +++ b/components/crm/internal/adapters/http/in/alias.go @@ -5,9 +5,12 @@ package in import ( + "context" + "errors" "fmt" "github.com/LerianStudio/midaz/v3/components/crm/internal/services" + "github.com/LerianStudio/midaz/v3/pkg" cn "github.com/LerianStudio/midaz/v3/pkg/constant" "github.com/LerianStudio/midaz/v3/pkg/mmodel" "github.com/LerianStudio/midaz/v3/pkg/net/http" @@ -18,6 +21,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type AliasHandler struct { @@ -292,6 +296,8 @@ func (handler *AliasHandler) DeleteAliasByID(c *fiber.Ctx) error { // @Param banking_details_branch query string false "Filter alias by banking details branch" // @Param banking_details_account query string false "Filter alias by banking details account" // @Param banking_details_iban query string false "Filter alias by banking details iban" +// @Param banking_details_bank_id query string false "Filter alias by banking details bank ID" +// @Param banking_details_type query string false "Filter alias by banking details type" // @Param regulatory_fields_participant_document query string false "Filter alias by regulatory fields participant document" // @Param related_party_document query string false "Filter alias by related party document" // @Param related_party_role query string false "Filter alias by related party role" @@ -427,3 +433,41 @@ func (handler *AliasHandler) DeleteRelatedParty(c *fiber.Ctx) error { return http.NoContent(c) } + +func handleAliasResolverHandlerError(ctx context.Context, span trace.Span, logger libLog.Logger, message string, err error) { + if isBusinessError(err) { + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, message, err) + logger.Log(ctx, libLog.LevelWarn, message, libLog.Err(err)) + + return + } + + libOpenTelemetry.HandleSpanError(span, message, err) + logger.Log(ctx, libLog.LevelError, message, libLog.Err(err)) +} + +func isBusinessError(err error) bool { + var validationErr pkg.ValidationError + if errors.As(err, &validationErr) { + return true + } + + var knownValidationErr pkg.ValidationKnownFieldsError + if errors.As(err, &knownValidationErr) { + return true + } + + var unknownValidationErr pkg.ValidationUnknownFieldsError + if errors.As(err, &unknownValidationErr) { + return true + } + + var notFoundErr pkg.EntityNotFoundError + if errors.As(err, ¬FoundErr) { + return true + } + + var conflictErr pkg.EntityConflictError + + return errors.As(err, &conflictErr) +} diff --git a/components/crm/internal/adapters/http/in/alias_test.go b/components/crm/internal/adapters/http/in/alias_test.go index c76a23c12..767616e1c 100644 --- a/components/crm/internal/adapters/http/in/alias_test.go +++ b/components/crm/internal/adapters/http/in/alias_test.go @@ -342,6 +342,205 @@ func TestAliasHandler_CreateAlias(t *testing.T) { } } +func TestAliasHandler_ResolveBankAccountDoesNotRequireOrganizationHeader(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + repo := alias.NewMockRepository(ctrl) + holderRepo := holder.NewMockRepository(ctrl) + uc := &services.UseCase{AliasRepo: repo, HolderRepo: holderRepo} + handler := &AliasHandler{Service: uc} + + aliasID := uuid.New() + holderID := uuid.New() + organizationID := uuid.New() + ledgerID := uuid.New().String() + accountID := uuid.New().String() + document := "12345678901" + bankID := "12345678" + branch := "0001" + account := "1234567" + accountType := "CACC" + + repo.EXPECT().ResolveBankAccount(gomock.Any(), gomock.Any()).Return([]*mmodel.Alias{{ + ID: &aliasID, + OrganizationID: &organizationID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + BankingDetails: &mmodel.BankingDetails{BankID: &bankID, Branch: &branch, Account: &account, Type: &accountType}, + }}, nil).Times(1) + + app := fiber.New() + app.Post("/v1/aliases/resolve-bank-account", http.WithBody(new(mmodel.ResolveBankAccountInput), handler.ResolveBankAccount)) + + body := `{"document":"12345678901","bankingDetails":{"bankId":"12345678","branch":"0001","account":"1234567","type":"CACC"}}` + req := httptest.NewRequest("POST", "/v1/aliases/resolve-bank-account", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + assert.Equal(t, aliasID.String(), result["id"]) + assert.Equal(t, organizationID.String(), result["organizationId"]) + assert.Equal(t, ledgerID, result["ledgerId"]) + assert.Equal(t, accountID, result["accountId"]) + assert.Equal(t, holderID.String(), result["holderId"]) + assert.Equal(t, document, result["holderDocument"]) + assert.NotContains(t, result, "holderName") + bankingDetails, ok := result["bankingDetails"].(map[string]any) + require.True(t, ok) + assert.Equal(t, bankID, bankingDetails["bankId"]) + assert.Equal(t, branch, bankingDetails["branch"]) + assert.Equal(t, account, bankingDetails["account"]) + assert.Equal(t, accountType, bankingDetails["type"]) +} + +func TestAliasHandler_ResolveBankAccountRejectsUnknownFields(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + repo := alias.NewMockRepository(ctrl) + uc := &services.UseCase{AliasRepo: repo} + handler := &AliasHandler{Service: uc} + app := fiber.New() + app.Post("/v1/aliases/resolve-bank-account", http.WithBody(new(mmodel.ResolveBankAccountInput), handler.ResolveBankAccount)) + + body := `{"document":"12345678901","organizationId":"` + uuid.New().String() + `","bankingDetails":{"bankId":"12345678","branch":"0001","account":"1234567","type":"CACC"}}` + req := httptest.NewRequest("POST", "/v1/aliases/resolve-bank-account", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) +} + +func TestAliasHandler_ResolveAccountDoesNotRequireOrganizationHeader(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + repo := alias.NewMockRepository(ctrl) + uc := &services.UseCase{AliasRepo: repo} + handler := &AliasHandler{Service: uc} + + aliasID := uuid.New() + holderID := uuid.New() + accountUUID := uuid.New() + organizationID := uuid.New() + ledgerID := uuid.New().String() + document := "12345678901" + bankID := "12345678" + branch := "0001" + account := "1234567" + accountType := "CACC" + accountID := accountUUID.String() + + repo.EXPECT().ResolveAccount(gomock.Any(), accountUUID).Return([]*mmodel.Alias{{ + ID: &aliasID, + OrganizationID: &organizationID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + BankingDetails: &mmodel.BankingDetails{BankID: &bankID, Branch: &branch, Account: &account, Type: &accountType}, + }}, nil).Times(1) + + app := fiber.New() + app.Post("/v1/aliases/resolve-account", http.WithBody(new(mmodel.ResolveAccountInput), handler.ResolveAccount)) + + body := `{"accountId":"` + accountUUID.String() + `"}` + req := httptest.NewRequest("POST", "/v1/aliases/resolve-account", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + assert.Equal(t, aliasID.String(), result["id"]) + assert.Equal(t, organizationID.String(), result["organizationId"]) + assert.Equal(t, accountID, result["accountId"]) + assert.NotContains(t, result, "holderName") +} + +func TestAliasHandler_BackfillBankAccountIndexReturnsNoPIIReport(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + repo := alias.NewMockRepository(ctrl) + uc := &services.UseCase{AliasRepo: repo} + handler := &AliasHandler{Service: uc} + aliasID := uuid.New() + + repo.EXPECT().BackfillBankAccountIndex(gomock.Any(), true).Return(&mmodel.BankAccountIndexBackfillReport{ + DryRun: true, + CollectionsScanned: 1, + AliasesScanned: 2, + Incomplete: 1, + IncompleteAliasIDs: []uuid.UUID{aliasID}, + }, nil).Times(1) + + app := fiber.New() + app.Post("/v1/aliases/backfill-bank-account-index", http.WithBody(new(mmodel.BackfillBankAccountIndexInput), handler.BackfillBankAccountIndex)) + + req := httptest.NewRequest("POST", "/v1/aliases/backfill-bank-account-index", bytes.NewBufferString(`{"dryRun":true}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), aliasID.String()) + assert.NotContains(t, string(body), "12345678901") + assert.NotContains(t, string(body), "1234567") +} + +func TestIsBusinessErrorClassifiesResolverErrors(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "validation error", + err: pkg.ValidateBusinessError(cn.ErrMissingFieldsInRequest, cn.EntityAlias, "accountId"), + expected: true, + }, + { + name: "not found error", + err: pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias), + expected: true, + }, + { + name: "conflict error", + err: pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias), + expected: true, + }, + { + name: "non business error", + err: cn.ErrInternalServer, + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, isBusinessError(tc.err)) + }) + } +} + func TestAliasHandler_GetAliasByID(t *testing.T) { tests := []struct { name string diff --git a/components/crm/internal/adapters/http/in/backfill_bank_account_index.go b/components/crm/internal/adapters/http/in/backfill_bank_account_index.go new file mode 100644 index 000000000..e574ce445 --- /dev/null +++ b/components/crm/internal/adapters/http/in/backfill_bank_account_index.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package in + +import ( + libCommons "github.com/LerianStudio/lib-commons/v5/commons" + libLog "github.com/LerianStudio/lib-commons/v5/commons/log" + libOpenTelemetry "github.com/LerianStudio/lib-commons/v5/commons/opentelemetry" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/LerianStudio/midaz/v3/pkg/net/http" + "github.com/gofiber/fiber/v2" + "go.opentelemetry.io/otel/attribute" +) + +// BackfillBankAccountIndex repairs the tenant-wide alias bank-account resolver index. +// +// @Summary Backfill Alias Bank Account Resolver Index +// @Description Scans tenant alias collections and rebuilds alias_bank_account_index rows. Report contains counts and alias IDs only; no document, account, or bank identity values. +// @Tags Aliases +// @Accept json +// @Produce json +// @Param Authorization header string false "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled." +// @Param backfill body mmodel.BackfillBankAccountIndexInput true "Backfill Input" +// @Success 200 {object} mmodel.BankAccountIndexBackfillReport +// @Failure 400 {object} pkg.HTTPError +// @Failure 500 {object} pkg.HTTPError +// @Router /v1/aliases/backfill-bank-account-index [post] +func (handler *AliasHandler) BackfillBankAccountIndex(p any, c *fiber.Ctx) error { + ctx := c.UserContext() + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "handler.backfill_bank_account_index") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID)) + + payload, ok := p.(*mmodel.BackfillBankAccountIndexInput) + if !ok || payload == nil { + return http.WithError(c, cn.ErrInternalServer) + } + + result, err := handler.Service.BackfillBankAccountIndex(ctx, payload.DryRun) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to backfill bank account index", err) + logger.Log(ctx, libLog.LevelError, "Failed to backfill bank account index", libLog.Err(err)) + + return http.WithError(c, err) + } + + return http.OK(c, result) +} diff --git a/components/crm/internal/adapters/http/in/resolve_account.go b/components/crm/internal/adapters/http/in/resolve_account.go new file mode 100644 index 000000000..90ebc2475 --- /dev/null +++ b/components/crm/internal/adapters/http/in/resolve_account.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package in + +import ( + libCommons "github.com/LerianStudio/lib-commons/v5/commons" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/LerianStudio/midaz/v3/pkg/net/http" + "github.com/gofiber/fiber/v2" + "go.opentelemetry.io/otel/attribute" +) + +// ResolveAccount resolves an active alias by tenant-wide ledger account ID. +// +// @Summary Resolve Alias by Account ID +// @Description Resolves an active alias across the current tenant by account ID. Does not require X-Organization-Id. +// @Tags Aliases +// @Accept json +// @Produce json +// @Param Authorization header string false "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled." +// @Param resolver body mmodel.ResolveAccountInput true "Account Resolver Input" +// @Success 200 {object} mmodel.ResolveAliasResponse +// @Failure 400 {object} pkg.HTTPError +// @Failure 404 {object} pkg.HTTPError +// @Failure 409 {object} pkg.HTTPError +// @Failure 500 {object} pkg.HTTPError +// @Router /v1/aliases/resolve-account [post] +func (handler *AliasHandler) ResolveAccount(p any, c *fiber.Ctx) error { + ctx := c.UserContext() + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "handler.resolve_account") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID)) + + payload, ok := p.(*mmodel.ResolveAccountInput) + if !ok || payload == nil { + return http.WithError(c, cn.ErrInternalServer) + } + + result, err := handler.Service.ResolveAccount(ctx, payload) + if err != nil { + handleAliasResolverHandlerError(ctx, span, logger, "Failed to resolve account", err) + + return http.WithError(c, err) + } + + return http.OK(c, result) +} diff --git a/components/crm/internal/adapters/http/in/resolve_bank_account.go b/components/crm/internal/adapters/http/in/resolve_bank_account.go new file mode 100644 index 000000000..7656d90f8 --- /dev/null +++ b/components/crm/internal/adapters/http/in/resolve_bank_account.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package in + +import ( + libCommons "github.com/LerianStudio/lib-commons/v5/commons" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/LerianStudio/midaz/v3/pkg/net/http" + "github.com/gofiber/fiber/v2" + "go.opentelemetry.io/otel/attribute" +) + +// ResolveBankAccount resolves an active alias by tenant-wide bank-account identity. +// +// @Summary Resolve Alias by Bank Account +// @Description Resolves an active alias across the current tenant by holder document and exact bank-account identity. Does not require X-Organization-Id. +// @Tags Aliases +// @Accept json +// @Produce json +// @Param Authorization header string false "The authorization token in the 'Bearer access_token' format. Only required when auth plugin is enabled." +// @Param resolver body mmodel.ResolveBankAccountInput true "Bank Account Resolver Input" +// @Success 200 {object} mmodel.ResolveAliasResponse +// @Failure 400 {object} pkg.HTTPError +// @Failure 404 {object} pkg.HTTPError +// @Failure 409 {object} pkg.HTTPError +// @Failure 500 {object} pkg.HTTPError +// @Router /v1/aliases/resolve-bank-account [post] +func (handler *AliasHandler) ResolveBankAccount(p any, c *fiber.Ctx) error { + ctx := c.UserContext() + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "handler.resolve_bank_account") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID)) + + payload, ok := p.(*mmodel.ResolveBankAccountInput) + if !ok || payload == nil { + return http.WithError(c, cn.ErrInternalServer) + } + + result, err := handler.Service.ResolveBankAccount(ctx, payload) + if err != nil { + handleAliasResolverHandlerError(ctx, span, logger, "Failed to resolve bank account", err) + + return http.WithError(c, err) + } + + return http.OK(c, result) +} diff --git a/components/crm/internal/adapters/http/in/routes.go b/components/crm/internal/adapters/http/in/routes.go index 095a6cae3..731f0e435 100644 --- a/components/crm/internal/adapters/http/in/routes.go +++ b/components/crm/internal/adapters/http/in/routes.go @@ -72,6 +72,9 @@ func NewRouter(lg libLog.Logger, tl *libOpenTelemetry.Telemetry, auth *middlewar // Aliases f.Get("/v1/aliases", auth.Authorize(ApplicationName, "aliases", "get"), ah.GetAllAliases) + f.Post("/v1/aliases/resolve-bank-account", auth.Authorize(ApplicationName, "aliases", "resolve"), http.WithBody(new(mmodel.ResolveBankAccountInput), ah.ResolveBankAccount)) + f.Post("/v1/aliases/resolve-account", auth.Authorize(ApplicationName, "aliases", "resolve"), http.WithBody(new(mmodel.ResolveAccountInput), ah.ResolveAccount)) + f.Post("/v1/aliases/backfill-bank-account-index", auth.Authorize(ApplicationName, "aliases", "backfill"), http.WithBody(new(mmodel.BackfillBankAccountIndexInput), ah.BackfillBankAccountIndex)) f.Post("/v1/holders/:holder_id/aliases", auth.Authorize(ApplicationName, "aliases", "post"), http.ParseUUIDPathParameters("aliases"), http.WithBody(new(mmodel.CreateAliasInput), ah.CreateAlias)) f.Get("/v1/holders/:holder_id/aliases/:alias_id", auth.Authorize(ApplicationName, "aliases", "get"), http.ParseUUIDPathParameters("aliases"), ah.GetAliasByID) f.Patch("/v1/holders/:holder_id/aliases/:alias_id", auth.Authorize(ApplicationName, "aliases", "patch"), http.ParseUUIDPathParameters("aliases"), http.WithBody(new(mmodel.UpdateAliasInput), ah.UpdateAlias)) diff --git a/components/crm/internal/adapters/mongodb/alias/alias.mongodb.go b/components/crm/internal/adapters/mongodb/alias/alias.mongodb.go index c9f30df60..34339db4b 100644 --- a/components/crm/internal/adapters/mongodb/alias/alias.mongodb.go +++ b/components/crm/internal/adapters/mongodb/alias/alias.mongodb.go @@ -8,7 +8,6 @@ import ( "context" "errors" "fmt" - "reflect" "strings" "time" @@ -42,6 +41,9 @@ type Repository interface { Delete(ctx context.Context, organizationID string, holderID, id uuid.UUID, hardDelete bool) error DeleteRelatedParty(ctx context.Context, organizationID string, holderID, aliasID, relatedPartyID uuid.UUID) error Count(ctx context.Context, organizationID string, holderID uuid.UUID) (int64, error) + ResolveBankAccount(ctx context.Context, input *mmodel.ResolveBankAccountInput) ([]*mmodel.Alias, error) + ResolveAccount(ctx context.Context, accountID uuid.UUID) ([]*mmodel.Alias, error) + BackfillBankAccountIndex(ctx context.Context, dryRun bool) (*mmodel.BankAccountIndexBackfillReport, error) } // MongoDBRepository is a MongoDB-specific implementation of Repository @@ -84,6 +86,24 @@ func (am *MongoDBRepository) getDatabase(ctx context.Context) (*mongo.Database, return am.connection.Database(ctx) } +func (am *MongoDBRepository) withTransaction(ctx context.Context, db *mongo.Database, fn func(mongo.SessionContext) error) error { + if err := ctx.Err(); err != nil { + return err + } + + session, err := db.Client().StartSession() + if err != nil { + return err + } + defer session.EndSession(ctx) + + _, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (any, error) { + return nil, fn(sc) + }) + + return err +} + // Create inserts an alias into mongo func (am *MongoDBRepository) Create(ctx context.Context, organizationID string, alias *mmodel.Alias) (*mmodel.Alias, error) { _, tracer, reqId, _ := libCommons.NewTrackingFromContext(ctx) @@ -140,23 +160,49 @@ func (am *MongoDBRepository) Create(ctx context.Context, organizationID string, attribute.Int("app.request.repository_input.related_parties_count", len(record.RelatedParties)), ) - _, err = coll.InsertOne(ctx, record) + result, err := record.ToEntity(am.DataSecurity) if err != nil { - libOpenTelemetry.HandleSpanError(spanInsert, "Failed to insert alias", err) + libOpenTelemetry.HandleSpanError(span, "Failed to convert alias to model", err) - if mongo.IsDuplicateKeyError(err) { - if strings.Contains(err.Error(), "account_id") { - return nil, pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, reflect.TypeOf(mmodel.Alias{}).Name()) - } - } + return nil, err + } + + if err := ensureBankAccountIndexIndexes(ctx, db); err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to create bank account index indexes", err) return nil, err } - result, err := record.ToEntity(am.DataSecurity) - if err != nil { - libOpenTelemetry.HandleSpanError(span, "Failed to convert alias to model", err) + err = am.withTransaction(ctx, db, func(sc mongo.SessionContext) error { + if _, insertErr := coll.InsertOne(sc, record); insertErr != nil { + if mongo.IsDuplicateKeyError(insertErr) && strings.Contains(insertErr.Error(), "account_id") { + businessErr := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanInsert, "Alias account already associated", businessErr) + + return businessErr + } + libOpenTelemetry.HandleSpanError(spanInsert, "Failed to insert alias", insertErr) + + return insertErr + } + + if indexErr := am.replaceBankAccountIndex(sc, db, organizationID, result); indexErr != nil { + if mongo.IsDuplicateKeyError(indexErr) { + businessErr := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanInsert, "Bank account identity already associated", businessErr) + + return businessErr + } + + libOpenTelemetry.HandleSpanError(spanInsert, "Failed to replace alias bank account index", indexErr) + + return indexErr + } + + return nil + }) + if err != nil { return nil, err } @@ -204,7 +250,7 @@ func (am *MongoDBRepository) Find(ctx context.Context, organizationID string, ho libOpenTelemetry.HandleSpanError(span, "Failed to find account", err) if errors.Is(err, mongo.ErrNoDocuments) { - return nil, pkg.ValidateBusinessError(cn.ErrAliasNotFound, reflect.TypeOf(mmodel.Alias{}).Name()) + return nil, pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias) } return nil, err @@ -220,6 +266,7 @@ func (am *MongoDBRepository) Find(ctx context.Context, organizationID string, ho return result, nil } +//nolint:gocognit,gocyclo // Transaction-backed update must keep source mutation and resolver-index replacement in one callback. func (am *MongoDBRepository) Update(ctx context.Context, organizationID string, holderID, id uuid.UUID, alias *mmodel.Alias, fieldsToRemove []string) (*mmodel.Alias, error) { _, tracer, reqId, _ := libCommons.NewTrackingFromContext(ctx) @@ -244,15 +291,28 @@ func (am *MongoDBRepository) Update(ctx context.Context, organizationID string, } coll := db.Collection(strings.ToLower("aliases_" + organizationID)) + if err := createIndexes(ctx, coll); err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to create alias indexes", err) + + return nil, err + } ctx, spanUpdate := tracer.Start(ctx, "mongodb.update_alias.update_by_id") defer spanUpdate.End() spanUpdate.SetAttributes(attributes...) - err = libOpenTelemetry.SetSpanAttributesFromValue(spanUpdate, "app.request.repository_input", alias, nil) - if err != nil { - libOpenTelemetry.HandleSpanError(spanUpdate, "Failed to set span attributes", err) + spanUpdate.SetAttributes( + attribute.Bool("app.request.repository_input.has_metadata", alias != nil && alias.Metadata != nil), + attribute.Bool("app.request.repository_input.has_banking_details", alias != nil && alias.BankingDetails != nil), + attribute.Bool("app.request.repository_input.has_regulatory_fields", alias != nil && alias.RegulatoryFields != nil), + attribute.Int("app.request.repository_input.related_parties_count", aliasRelatedPartiesCount(alias)), + ) + + filter := bson.D{ + {Key: "_id", Value: id}, + {Key: "holder_id", Value: holderID}, + {Key: "deleted_at", Value: nil}, } aliasToUpdate := &MongoDBModel{} @@ -279,41 +339,104 @@ func (am *MongoDBRepository) Update(ctx context.Context, organizationID string, update := mongoUtils.BuildDocumentToPatch(updateDocument, fieldsToRemove) - filter := bson.D{ - {Key: "_id", Value: id}, - {Key: "holder_id", Value: holderID}, - {Key: "deleted_at", Value: nil}, - } - - updateResult, err := coll.UpdateOne(ctx, filter, update) - if err != nil { - libOpenTelemetry.HandleSpanError(spanUpdate, "Failed to update alias", err) + if err := ensureBankAccountIndexIndexes(ctx, db); err != nil { + libOpenTelemetry.HandleSpanError(spanUpdate, "Failed to create bank account index indexes", err) return nil, err } - if updateResult.MatchedCount == 0 { - return nil, pkg.ValidateBusinessError(cn.ErrAliasNotFound, reflect.TypeOf(mmodel.Alias{}).Name()) - } - - var record MongoDBModel - ctx, spanFind := tracer.Start(ctx, "mongodb.update_alias.find_by_id") defer spanFind.End() spanFind.SetAttributes(attributes...) - err = coll.FindOne(ctx, filter).Decode(&record) - if err != nil { - libOpenTelemetry.HandleSpanError(spanFind, "Failed to find alias after update", err) + var result *mmodel.Alias - return nil, err - } + err = am.withTransaction(ctx, db, func(sc mongo.SessionContext) error { + var beforeRecord MongoDBModel + if findErr := coll.FindOne(sc, filter).Decode(&beforeRecord); findErr != nil { + if errors.Is(findErr, mongo.ErrNoDocuments) { + businessErr := pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanUpdate, "Alias not found", businessErr) - result, err := record.ToEntity(am.DataSecurity) - if err != nil { - libOpenTelemetry.HandleSpanError(spanFind, "Failed to convert alias to model", err) + return businessErr + } + + libOpenTelemetry.HandleSpanError(spanUpdate, "Failed to find alias before update", findErr) + + return findErr + } + + beforeAlias, toBeforeErr := beforeRecord.ToEntity(am.DataSecurity) + if toBeforeErr != nil { + libOpenTelemetry.HandleSpanError(spanUpdate, "Failed to convert alias before update", toBeforeErr) + + return toBeforeErr + } + + candidateAlias := mergeAliasForBankAccountIndex(beforeAlias, alias, fieldsToRemove) + if conflictErr := am.preflightBankAccountIdentityConflict(sc, organizationID, candidateAlias); conflictErr != nil { + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanUpdate, "Bank account identity conflict", conflictErr) + + return conflictErr + } + + updateResult, updateErr := coll.UpdateOne(sc, filter, update) + if updateErr != nil { + if mongo.IsDuplicateKeyError(updateErr) { + businessErr := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanUpdate, "Alias account already associated", businessErr) + + return businessErr + } + + libOpenTelemetry.HandleSpanError(spanUpdate, "Failed to update alias", updateErr) + + return updateErr + } + + if updateResult.MatchedCount == 0 { + businessErr := pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanUpdate, "Alias not found", businessErr) + + return businessErr + } + + var record MongoDBModel + if findErr := coll.FindOne(sc, filter).Decode(&record); findErr != nil { + libOpenTelemetry.HandleSpanError(spanFind, "Failed to find alias after update", findErr) + + return findErr + } + + resolved, toEntityErr := record.ToEntity(am.DataSecurity) + if toEntityErr != nil { + libOpenTelemetry.HandleSpanError(spanFind, "Failed to convert alias to model", toEntityErr) + + return toEntityErr + } + + indexAlias := candidateAlias + indexAlias.UpdatedAt = resolved.UpdatedAt + if indexErr := am.replaceBankAccountIndex(sc, db, organizationID, indexAlias); indexErr != nil { + if mongo.IsDuplicateKeyError(indexErr) { + businessErr := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanFind, "Bank account identity already associated", businessErr) + + return businessErr + } + + libOpenTelemetry.HandleSpanError(spanFind, "Failed to replace alias bank account index", indexErr) + + return indexErr + } + + result = resolved + + return nil + }) + if err != nil { return nil, err } @@ -347,8 +470,14 @@ func (am *MongoDBRepository) Delete(ctx context.Context, organizationID string, opts := options.Delete() coll := db.Collection(strings.ToLower("aliases_" + organizationID)) + if err := createIndexes(ctx, coll); err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to create alias indexes", err) + + return err + } ctx, spanDelete := tracer.Start(ctx, "mongodb.delete_alias.delete_one") + defer spanDelete.End() spanDelete.SetAttributes(attributes...) @@ -358,39 +487,203 @@ func (am *MongoDBRepository) Delete(ctx context.Context, organizationID string, {Key: "deleted_at", Value: nil}, } - if hardDelete { - deleted, err := coll.DeleteOne(ctx, filter, opts) - if err != nil { - libOpenTelemetry.HandleSpanError(spanDelete, "Failed to delete alias", err) + if err := ensureBankAccountIndexIndexes(ctx, db); err != nil { + libOpenTelemetry.HandleSpanError(spanDelete, "Failed to create bank account index indexes", err) - return err - } + return err + } - spanDelete.End() + err = am.withTransaction(ctx, db, func(sc mongo.SessionContext) error { + if hardDelete { + deleted, deleteErr := coll.DeleteOne(sc, filter, opts) + if deleteErr != nil { + libOpenTelemetry.HandleSpanError(spanDelete, "Failed to delete alias", deleteErr) - if deleted.DeletedCount == 0 { - return pkg.ValidateBusinessError(cn.ErrAliasNotFound, reflect.TypeOf(mmodel.Alias{}).Name()) - } - } else { - update := bson.D{ - {Key: "$set", Value: bson.D{ - {Key: "deleted_at", Value: time.Now()}, - }}, - } + return deleteErr + } - updateResult, err := coll.UpdateOne(ctx, filter, update) - if err != nil { - libOpenTelemetry.HandleSpanError(spanDelete, "Failed to delete alias", err) + if deleted.DeletedCount == 0 { + businessErr := pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanDelete, "Alias not found", businessErr) - return err + return businessErr + } + } else { + update := bson.D{ + {Key: "$set", Value: bson.D{ + {Key: "deleted_at", Value: time.Now()}, + }}, + } + + updateResult, updateErr := coll.UpdateOne(sc, filter, update) + if updateErr != nil { + libOpenTelemetry.HandleSpanError(spanDelete, "Failed to delete alias", updateErr) + + return updateErr + } + + if updateResult.MatchedCount == 0 { + businessErr := pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(spanDelete, "Alias not found", businessErr) + + return businessErr + } } - if updateResult.MatchedCount == 0 { - return pkg.ValidateBusinessError(cn.ErrAliasNotFound, reflect.TypeOf(mmodel.Alias{}).Name()) + if indexErr := am.deleteBankAccountIndexWithDB(sc, db, id, hardDelete); indexErr != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to delete alias bank account index", indexErr) + + return indexErr } + + return nil + }) + if err != nil { + return err } logger.Log(ctx, libLog.LevelInfo, fmt.Sprintln("Deleted a document with id: ", id.String(), " (hard delete: ", hardDelete, ")")) return nil } + +func mergeAliasForBankAccountIndex(existingAlias, patch *mmodel.Alias, fieldsToRemove []string) *mmodel.Alias { + if existingAlias == nil { + return patch + } + + merged := *existingAlias + if patch == nil { + return &merged + } + + if patch.Document != nil { + merged.Document = patch.Document + } + + if patch.Type != nil { + merged.Type = patch.Type + } + + if patch.LedgerID != nil { + merged.LedgerID = patch.LedgerID + } + + if patch.AccountID != nil { + merged.AccountID = patch.AccountID + } + + if patch.HolderID != nil { + merged.HolderID = patch.HolderID + } + + if patch.BankingDetails != nil { + merged.BankingDetails = mergeBankingDetailsForBankAccountIndex(merged.BankingDetails, patch.BankingDetails) + } + + for _, field := range fieldsToRemove { + clearRemovedBankAccountIndexField(&merged, field) + } + + return &merged +} + +func clearRemovedBankAccountIndexField(alias *mmodel.Alias, field string) { + if alias == nil { + return + } + + switch strings.TrimSpace(field) { + case "document": + alias.Document = nil + case "type": + alias.Type = nil + case "ledgerID", "ledgerId", "ledger_id": + alias.LedgerID = nil + case "accountID", "accountId", "account_id": + alias.AccountID = nil + case "holderID", "holderId", "holder_id": + alias.HolderID = nil + case "bankingDetails", "banking_details": + alias.BankingDetails = nil + case "bankingDetails.bankId", "banking_details.bank_id": + clearBankingDetailsFieldForBankAccountIndex(alias, func(bankingDetails *mmodel.BankingDetails) { + bankingDetails.BankID = nil + }) + case "bankingDetails.branch", "banking_details.branch": + clearBankingDetailsFieldForBankAccountIndex(alias, func(bankingDetails *mmodel.BankingDetails) { + bankingDetails.Branch = nil + }) + case "bankingDetails.account", "banking_details.account": + clearBankingDetailsFieldForBankAccountIndex(alias, func(bankingDetails *mmodel.BankingDetails) { + bankingDetails.Account = nil + }) + case "bankingDetails.type", "banking_details.type": + clearBankingDetailsFieldForBankAccountIndex(alias, func(bankingDetails *mmodel.BankingDetails) { + bankingDetails.Type = nil + }) + } +} + +func clearBankingDetailsFieldForBankAccountIndex(alias *mmodel.Alias, clearField func(*mmodel.BankingDetails)) { + if alias.BankingDetails == nil { + return + } + + bankingDetails := *alias.BankingDetails + clearField(&bankingDetails) + alias.BankingDetails = &bankingDetails +} + +func mergeBankingDetailsForBankAccountIndex(existing, patch *mmodel.BankingDetails) *mmodel.BankingDetails { + if existing == nil { + return patch + } + + if patch == nil { + return existing + } + + merged := *existing + if patch.BankID != nil { + merged.BankID = patch.BankID + } + + if patch.Branch != nil { + merged.Branch = patch.Branch + } + + if patch.Account != nil { + merged.Account = patch.Account + } + + if patch.Type != nil { + merged.Type = patch.Type + } + + if patch.OpeningDate != nil { + merged.OpeningDate = patch.OpeningDate + } + + if patch.ClosingDate != nil { + merged.ClosingDate = patch.ClosingDate + } + + if patch.IBAN != nil { + merged.IBAN = patch.IBAN + } + + if patch.CountryCode != nil { + merged.CountryCode = patch.CountryCode + } + + return &merged +} + +func aliasRelatedPartiesCount(alias *mmodel.Alias) int { + if alias == nil { + return 0 + } + + return len(alias.RelatedParties) +} diff --git a/components/crm/internal/adapters/mongodb/alias/alias.mongodb_integration_test.go b/components/crm/internal/adapters/mongodb/alias/alias.mongodb_integration_test.go index fcc37c937..008144cef 100644 --- a/components/crm/internal/adapters/mongodb/alias/alias.mongodb_integration_test.go +++ b/components/crm/internal/adapters/mongodb/alias/alias.mongodb_integration_test.go @@ -12,6 +12,7 @@ import ( "slices" "strings" "testing" + "time" "github.com/LerianStudio/midaz/v3/pkg" "github.com/LerianStudio/midaz/v3/pkg/mmodel" @@ -105,6 +106,408 @@ func TestIntegration_AliasRepo_Create_EncryptsData(t *testing.T) { assert.NotEmpty(t, search["document"], "document hash should be generated") } +func TestIntegration_AliasRepo_Create_UpsertsBankAccountIndexWithCanonicalBranch(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-index-" + uuid.New().String()[:8] + holderID := uuid.New() + originalDocument := "12345678901" + originalAccount := "001234567" + + banking := mongotestutil.DefaultBankingDetailsParams() + banking.Branch = "0001" + banking.Account = originalAccount + banking.BankID = "12345678" + banking.Type = "CACC" + alias := mongotestutil.CreateTestAliasSimple(t, holderID, "account-index-1", originalDocument) + alias.BankingDetails = mongotestutil.CreateBankingDetails(banking) + + _, err := repo.Create(ctx, organizationID, alias) + require.NoError(t, err) + + var rawDoc bson.M + err = container.Database.Collection(bankAccountIndexCollection).FindOne(ctx, bson.M{"alias_id": alias.ID}).Decode(&rawDoc) + require.NoError(t, err) + + bankingDoc, ok := rawDoc["banking_details"].(bson.M) + require.True(t, ok) + assert.Equal(t, "1", bankingDoc["branch_canonical"]) + assert.Equal(t, "0001", bankingDoc["branch"]) + assert.NotEqual(t, originalAccount, bankingDoc["account"]) + assert.NotEqual(t, originalDocument, rawDoc["document"]) +} + +func TestIntegration_AliasRepo_Create_DuplicateActiveBankIdentityConflicts(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-dup-bank-" + uuid.New().String()[:8] + document := "12345678901" + banking := mongotestutil.DefaultBankingDetailsParams() + banking.BankID = "12345678" + banking.Branch = "0001" + banking.Account = "001234567" + banking.Type = "CACC" + + alias1 := mongotestutil.CreateTestAliasSimple(t, uuid.New(), "account-bank-1", document) + alias1.BankingDetails = mongotestutil.CreateBankingDetails(banking) + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias1)) + + alias2 := mongotestutil.CreateTestAliasSimple(t, uuid.New(), "account-bank-2", document) + alias2.BankingDetails = mongotestutil.CreateBankingDetails(banking) + + _, err := repo.Create(ctx, organizationID, alias2) + require.Error(t, err) + var conflictErr pkg.EntityConflictError + require.ErrorAs(t, err, &conflictErr) + assert.Equal(t, int64(0), mongotestutil.CountDocuments(t, container.Database, strings.ToLower("aliases_"+organizationID), bson.M{"_id": alias2.ID})) +} + +func TestIntegration_AliasRepo_Create_CrossOrganizationDuplicateBankIdentityConflicts(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + document := "12345678901" + banking := mongotestutil.DefaultBankingDetailsParams() + banking.BankID = "12345678" + banking.Branch = "0001" + banking.Account = "001234567" + banking.Type = "CACC" + + alias1 := mongotestutil.CreateTestAliasSimple(t, uuid.New(), "account-bank-cross-1", document) + alias1.BankingDetails = mongotestutil.CreateBankingDetails(banking) + require.NoError(t, mustCreateAlias(repo, ctx, "org-cross-a", alias1)) + + alias2 := mongotestutil.CreateTestAliasSimple(t, uuid.New(), "account-bank-cross-2", document) + alias2.BankingDetails = mongotestutil.CreateBankingDetails(banking) + + _, err := repo.Create(ctx, "org-cross-b", alias2) + require.Error(t, err) + var conflictErr pkg.EntityConflictError + require.ErrorAs(t, err, &conflictErr) +} + +func TestIntegration_AliasRepo_Create_WithoutBankingDetailsIsResolvableByAccount(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + accountID := uuid.New() + alias := mongotestutil.CreateTestAliasSimple(t, holderID, accountID.String(), "12345678901") + + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + results, err := repo.ResolveAccount(ctx, accountID) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, accountID.String(), *results[0].AccountID) + assert.Nil(t, results[0].BankingDetails) +} + +func TestIntegration_AliasRepo_ResolveBankAccount_IgnoresIncompleteBankingRows(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + accountID := uuid.New() + document := "12345678901" + bankID := "12345678" + branch := "0001" + alias := mongotestutil.CreateTestAliasSimple(t, holderID, accountID.String(), document) + alias.BankingDetails = &mmodel.BankingDetails{BankID: &bankID, Branch: &branch} + + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + results, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: document, + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: bankID, Branch: branch, Account: "123456", Type: "CACC", + }, + }) + + require.NoError(t, err) + assert.Empty(t, results) +} + +func TestIntegration_AliasRepo_ResolveBankAccount_ExcludesDeletedIndexRows(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-resolve-del-" + uuid.New().String()[:8] + holderID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, "account-resolve-del", "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + require.NoError(t, repo.Delete(ctx, organizationID, holderID, *alias.ID, false)) + + results, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "001", Branch: "1", Account: "123456", Type: "CACC", + }, + }) + + require.NoError(t, err) + assert.Empty(t, results) +} + +func TestIntegration_AliasRepo_ResolveBankAccount_ExcludesHardDeletedIndexRows(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-resolve-hard-del-" + uuid.New().String()[:8] + holderID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, "account-resolve-hard-del", "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + require.NoError(t, repo.Delete(ctx, organizationID, holderID, *alias.ID, true)) + + results, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "001", Branch: "1", Account: "123456", Type: "CACC", + }, + }) + + require.NoError(t, err) + assert.Empty(t, results) + assert.Equal(t, int64(0), mongotestutil.CountDocuments(t, container.Database, bankAccountIndexCollection, bson.M{"alias_id": alias.ID})) +} + +func TestIntegration_AliasRepo_ResolveBankAccount_ReturnsProofFields(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, "account-resolve-proof", "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + results, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "001", Branch: "0001", Account: "123456", Type: "CACC", + }, + }) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, uuid.MustParse(organizationID), *results[0].OrganizationID) + assert.Equal(t, "12345678901", *results[0].Document) + assert.Equal(t, "0001", *results[0].BankingDetails.Branch) + assert.Equal(t, "123456", *results[0].BankingDetails.Account) +} + +func TestIntegration_AliasRepo_ResolveBankAccount_CanonicalBranchMatchesAndReturnsStoredBranch(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, "account-resolve-canonical", "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + results, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "001", Branch: "1", Account: "123456", Type: "CACC", + }, + }) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "0001", *results[0].BankingDetails.Branch) +} + +func TestIntegration_AliasRepo_Update_RemoveBankingDetailsClearsIndexProofFields(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + accountID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, accountID.String(), "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + _, err := repo.Update(ctx, organizationID, holderID, *alias.ID, &mmodel.Alias{}, []string{"banking_details"}) + require.NoError(t, err) + + accountResults, err := repo.ResolveAccount(ctx, accountID) + require.NoError(t, err) + require.Len(t, accountResults, 1) + assert.Nil(t, accountResults[0].BankingDetails) + + bankResults, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "001", Branch: "1", Account: "123456", Type: "CACC", + }, + }) + require.NoError(t, err) + assert.Empty(t, bankResults) +} + +func TestIntegration_AliasRepo_Update_NonIdentityBankingPatchPreservesResolverIdentity(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, uuid.New().String(), "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + fixedNow := time.Date(2026, time.January, 2, 15, 0, 0, 0, time.UTC) + closingDate := mmodel.Date{Time: fixedNow.Add(24 * time.Hour)} + _, err := repo.Update(ctx, organizationID, holderID, *alias.ID, &mmodel.Alias{ + BankingDetails: &mmodel.BankingDetails{ClosingDate: &closingDate}, + }, nil) + require.NoError(t, err) + + results, err := repo.ResolveBankAccount(ctx, &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "001", Branch: "1", Account: "123456", Type: "CACC", + }, + }) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "0001", *results[0].BankingDetails.Branch) +} + +func TestIntegration_AliasRepo_ResolveAccount_ReturnsOrganizationID(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := uuid.New().String() + holderID := uuid.New() + accountID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, accountID.String(), "12345678901") + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias)) + + results, err := repo.ResolveAccount(ctx, accountID) + + require.NoError(t, err) + require.Len(t, results, 1) + require.NotNil(t, results[0].OrganizationID) + assert.Equal(t, uuid.MustParse(organizationID), *results[0].OrganizationID) +} + +func TestIntegration_AliasRepo_BackfillBankAccountIndex_DryRunReportsCountsWithoutPII(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-backfill-" + uuid.New().String()[:8] + holderID := uuid.New() + complete := mongotestutil.CreateTestAliasWithBanking(t, holderID, "account-backfill-1", "12345678901") + incomplete := mongotestutil.CreateTestAliasSimple(t, holderID, "account-backfill-2", "98765432100") + incomplete.BankingDetails = &mmodel.BankingDetails{BankID: testutils.Ptr("12345678"), Branch: testutils.Ptr("0001")} + + completeModel := &MongoDBModel{} + require.NoError(t, completeModel.FromEntity(complete, repo.DataSecurity)) + incompleteModel := &MongoDBModel{} + require.NoError(t, incompleteModel.FromEntity(incomplete, repo.DataSecurity)) + + _, err := container.Database.Collection(strings.ToLower("aliases_"+organizationID)).InsertMany(ctx, []any{completeModel, incompleteModel}) + require.NoError(t, err) + + report, err := repo.BackfillBankAccountIndex(ctx, true) + + require.NoError(t, err) + require.NotNil(t, report) + assert.True(t, report.DryRun) + assert.Equal(t, 1, report.CollectionsScanned) + assert.Equal(t, 2, report.AliasesScanned) + assert.Equal(t, 1, report.Incomplete) + assert.Equal(t, 0, report.Upserted) + assert.NotContains(t, fmt.Sprint(report.IncompleteAliasIDs), "98765432100") + assert.Equal(t, int64(0), mongotestutil.CountDocuments(t, container.Database, bankAccountIndexCollection, bson.M{})) +} + +func TestIntegration_AliasRepo_BackfillBankAccountIndex_DryRunReportsDuplicateAliasIDsWithoutPII(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationA := "org-backfill-dup-a" + organizationB := "org-backfill-dup-b" + document := "12345678901" + banking := mongotestutil.DefaultBankingDetailsParams() + banking.BankID = "12345678" + banking.Branch = "0001" + banking.Account = "001234567" + banking.Type = "CACC" + + aliasA := mongotestutil.CreateTestAliasSimple(t, uuid.New(), "account-backfill-dup-a", document) + aliasA.BankingDetails = mongotestutil.CreateBankingDetails(banking) + modelA := &MongoDBModel{} + require.NoError(t, modelA.FromEntity(aliasA, repo.DataSecurity)) + + aliasB := mongotestutil.CreateTestAliasSimple(t, uuid.New(), "account-backfill-dup-b", document) + aliasB.BankingDetails = mongotestutil.CreateBankingDetails(banking) + modelB := &MongoDBModel{} + require.NoError(t, modelB.FromEntity(aliasB, repo.DataSecurity)) + + _, err := container.Database.Collection(strings.ToLower("aliases_"+organizationA)).InsertOne(ctx, modelA) + require.NoError(t, err) + _, err = container.Database.Collection(strings.ToLower("aliases_"+organizationB)).InsertOne(ctx, modelB) + require.NoError(t, err) + + report, err := repo.BackfillBankAccountIndex(ctx, true) + + require.NoError(t, err) + require.NotNil(t, report) + assert.Equal(t, 2, report.Duplicates) + assert.ElementsMatch(t, []uuid.UUID{*aliasA.ID, *aliasB.ID}, report.DuplicateAliasIDs) + joined := fmt.Sprint(report.DuplicateAliasIDs) + assert.NotContains(t, joined, document) + assert.NotContains(t, joined, banking.Account) +} + +func TestIntegration_AliasRepo_BackfillBankAccountIndex_NonDryRunWritesRowsWithoutPIIReport(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-backfill-write-" + uuid.New().String()[:8] + holderID := uuid.New() + alias := mongotestutil.CreateTestAliasWithBanking(t, holderID, uuid.New().String(), "12345678901") + model := &MongoDBModel{} + require.NoError(t, model.FromEntity(alias, repo.DataSecurity)) + + _, err := container.Database.Collection(strings.ToLower("aliases_"+organizationID)).InsertOne(ctx, model) + require.NoError(t, err) + + report, err := repo.BackfillBankAccountIndex(ctx, false) + + require.NoError(t, err) + require.NotNil(t, report) + assert.False(t, report.DryRun) + assert.Equal(t, 1, report.Upserted) + assert.Empty(t, report.DuplicateAliasIDs) + assert.Empty(t, report.IncompleteAliasIDs) + assert.Equal(t, int64(1), mongotestutil.CountDocuments(t, container.Database, bankAccountIndexCollection, bson.M{"alias_id": alias.ID})) +} + +func mustCreateAlias(repo *MongoDBRepository, ctx context.Context, organizationID string, alias *mmodel.Alias) error { + _, err := repo.Create(ctx, organizationID, alias) + return err +} + func TestIntegration_AliasRepo_Create_DuplicateAccount(t *testing.T) { // Arrange container := mongotestutil.SetupContainer(t) @@ -373,6 +776,40 @@ func TestIntegration_AliasRepo_FindAll_FilterByAccountID(t *testing.T) { assert.Equal(t, targetAccountID, *results[0].AccountID) } +func TestIntegration_AliasRepo_FindAll_FilterByBankIDAndType(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-filterbank-" + uuid.New().String()[:8] + holderID := uuid.New() + banking := mongotestutil.DefaultBankingDetailsParams() + banking.BankID = "12345678" + banking.Type = "CACC" + alias1 := mongotestutil.CreateTestAliasSimple(t, holderID, uuid.New().String(), "11122233344") + alias1.BankingDetails = mongotestutil.CreateBankingDetails(banking) + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias1)) + + otherBanking := mongotestutil.DefaultBankingDetailsParams() + otherBanking.BankID = "87654321" + otherBanking.Type = "SVGS" + alias2 := mongotestutil.CreateTestAliasSimple(t, holderID, uuid.New().String(), "55566677788") + alias2.BankingDetails = mongotestutil.CreateBankingDetails(otherBanking) + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, alias2)) + + results, err := repo.FindAll(ctx, organizationID, holderID, http.QueryHeader{ + Limit: 10, + Page: 1, + BankingDetailsBankID: testutils.Ptr("12345678"), + BankingDetailsType: testutils.Ptr("CACC"), + }, false) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "12345678", *results[0].BankingDetails.BankID) + assert.Equal(t, "CACC", *results[0].BankingDetails.Type) +} + func TestIntegration_AliasRepo_FindAll_ReturnsEmpty(t *testing.T) { // Arrange container := mongotestutil.SetupContainer(t) @@ -466,6 +903,51 @@ func TestIntegration_AliasRepo_Update_FieldsToRemove(t *testing.T) { assert.False(t, hasKey1, "key1 should be removed") } +func TestIntegration_AliasRepo_Update_DuplicateBankIdentityReturnsConflictAndDoesNotMutateSource(t *testing.T) { + container := mongotestutil.SetupContainer(t) + repo := createRepository(t, container) + ctx := context.Background() + + organizationID := "org-update-dup-" + uuid.New().String()[:8] + holderA := uuid.New() + holderB := uuid.New() + document := "12345678901" + + bankingA := mongotestutil.DefaultBankingDetailsParams() + bankingA.BankID = "12345678" + bankingA.Branch = "0001" + bankingA.Account = "001234567" + bankingA.Type = "CACC" + + aliasA := mongotestutil.CreateTestAliasSimple(t, holderA, "account-update-dup-a", document) + aliasA.BankingDetails = mongotestutil.CreateBankingDetails(bankingA) + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, aliasA)) + + bankingB := mongotestutil.DefaultBankingDetailsParams() + bankingB.BankID = "87654321" + bankingB.Branch = "0002" + bankingB.Account = "765432100" + bankingB.Type = "CACC" + aliasB := mongotestutil.CreateTestAliasSimple(t, holderB, "account-update-dup-b", document) + aliasB.BankingDetails = mongotestutil.CreateBankingDetails(bankingB) + require.NoError(t, mustCreateAlias(repo, ctx, organizationID, aliasB)) + + _, err := repo.Update(ctx, organizationID, holderB, *aliasB.ID, &mmodel.Alias{ + Document: &document, + BankingDetails: mongotestutil.CreateBankingDetails(bankingA), + }, nil) + + require.Error(t, err) + var conflictErr pkg.EntityConflictError + require.ErrorAs(t, err, &conflictErr) + + unchanged, findErr := repo.Find(ctx, organizationID, holderB, *aliasB.ID, false) + require.NoError(t, findErr) + assert.Equal(t, bankingB.BankID, *unchanged.BankingDetails.BankID) + assert.Equal(t, bankingB.Branch, *unchanged.BankingDetails.Branch) + assert.Equal(t, bankingB.Account, *unchanged.BankingDetails.Account) +} + // ============================================================================ // Delete Tests // ============================================================================ diff --git a/components/crm/internal/adapters/mongodb/alias/alias.mongodb_mock.go b/components/crm/internal/adapters/mongodb/alias/alias.mongodb_mock.go index a1431c115..992a4d93c 100644 --- a/components/crm/internal/adapters/mongodb/alias/alias.mongodb_mock.go +++ b/components/crm/internal/adapters/mongodb/alias/alias.mongodb_mock.go @@ -23,6 +23,7 @@ import ( type MockRepository struct { ctrl *gomock.Controller recorder *MockRepositoryMockRecorder + isgomock struct{} } // MockRepositoryMockRecorder is the mock recorder for MockRepository. @@ -42,105 +43,150 @@ func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { return m.recorder } +// BackfillBankAccountIndex mocks base method. +func (m *MockRepository) BackfillBankAccountIndex(ctx context.Context, dryRun bool) (*mmodel.BankAccountIndexBackfillReport, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BackfillBankAccountIndex", ctx, dryRun) + ret0, _ := ret[0].(*mmodel.BankAccountIndexBackfillReport) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BackfillBankAccountIndex indicates an expected call of BackfillBankAccountIndex. +func (mr *MockRepositoryMockRecorder) BackfillBankAccountIndex(ctx, dryRun any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackfillBankAccountIndex", reflect.TypeOf((*MockRepository)(nil).BackfillBankAccountIndex), ctx, dryRun) +} + // Count mocks base method. -func (m *MockRepository) Count(arg0 context.Context, arg1 string, arg2 uuid.UUID) (int64, error) { +func (m *MockRepository) Count(ctx context.Context, organizationID string, holderID uuid.UUID) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Count", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Count", ctx, organizationID, holderID) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // Count indicates an expected call of Count. -func (mr *MockRepositoryMockRecorder) Count(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) Count(ctx, organizationID, holderID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockRepository)(nil).Count), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockRepository)(nil).Count), ctx, organizationID, holderID) } // Create mocks base method. -func (m *MockRepository) Create(arg0 context.Context, arg1 string, arg2 *mmodel.Alias) (*mmodel.Alias, error) { +func (m *MockRepository) Create(ctx context.Context, organizationID string, input *mmodel.Alias) (*mmodel.Alias, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Create", ctx, organizationID, input) ret0, _ := ret[0].(*mmodel.Alias) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockRepositoryMockRecorder) Create(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) Create(ctx, organizationID, input any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, organizationID, input) } // Delete mocks base method. -func (m *MockRepository) Delete(arg0 context.Context, arg1 string, arg2, arg3 uuid.UUID, arg4 bool) error { +func (m *MockRepository) Delete(ctx context.Context, organizationID string, holderID, id uuid.UUID, hardDelete bool) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "Delete", ctx, organizationID, holderID, id, hardDelete) ret0, _ := ret[0].(error) return ret0 } // Delete indicates an expected call of Delete. -func (mr *MockRepositoryMockRecorder) Delete(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) Delete(ctx, organizationID, holderID, id, hardDelete any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), ctx, organizationID, holderID, id, hardDelete) } // DeleteRelatedParty mocks base method. -func (m *MockRepository) DeleteRelatedParty(arg0 context.Context, arg1 string, arg2, arg3, arg4 uuid.UUID) error { +func (m *MockRepository) DeleteRelatedParty(ctx context.Context, organizationID string, holderID, aliasID, relatedPartyID uuid.UUID) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteRelatedParty", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "DeleteRelatedParty", ctx, organizationID, holderID, aliasID, relatedPartyID) ret0, _ := ret[0].(error) return ret0 } // DeleteRelatedParty indicates an expected call of DeleteRelatedParty. -func (mr *MockRepositoryMockRecorder) DeleteRelatedParty(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) DeleteRelatedParty(ctx, organizationID, holderID, aliasID, relatedPartyID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRelatedParty", reflect.TypeOf((*MockRepository)(nil).DeleteRelatedParty), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRelatedParty", reflect.TypeOf((*MockRepository)(nil).DeleteRelatedParty), ctx, organizationID, holderID, aliasID, relatedPartyID) } // Find mocks base method. -func (m *MockRepository) Find(arg0 context.Context, arg1 string, arg2, arg3 uuid.UUID, arg4 bool) (*mmodel.Alias, error) { +func (m *MockRepository) Find(ctx context.Context, organizationID string, holderID, id uuid.UUID, includeDeleted bool) (*mmodel.Alias, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Find", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "Find", ctx, organizationID, holderID, id, includeDeleted) ret0, _ := ret[0].(*mmodel.Alias) ret1, _ := ret[1].(error) return ret0, ret1 } // Find indicates an expected call of Find. -func (mr *MockRepositoryMockRecorder) Find(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) Find(ctx, organizationID, holderID, id, includeDeleted any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockRepository)(nil).Find), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockRepository)(nil).Find), ctx, organizationID, holderID, id, includeDeleted) } // FindAll mocks base method. -func (m *MockRepository) FindAll(arg0 context.Context, arg1 string, arg2 uuid.UUID, arg3 http.QueryHeader, arg4 bool) ([]*mmodel.Alias, error) { +func (m *MockRepository) FindAll(ctx context.Context, organizationID string, holderID uuid.UUID, filter http.QueryHeader, includeDeleted bool) ([]*mmodel.Alias, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FindAll", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "FindAll", ctx, organizationID, holderID, filter, includeDeleted) ret0, _ := ret[0].([]*mmodel.Alias) ret1, _ := ret[1].(error) return ret0, ret1 } // FindAll indicates an expected call of FindAll. -func (mr *MockRepositoryMockRecorder) FindAll(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) FindAll(ctx, organizationID, holderID, filter, includeDeleted any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAll", reflect.TypeOf((*MockRepository)(nil).FindAll), ctx, organizationID, holderID, filter, includeDeleted) +} + +// ResolveAccount mocks base method. +func (m *MockRepository) ResolveAccount(ctx context.Context, accountID uuid.UUID) ([]*mmodel.Alias, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveAccount", ctx, accountID) + ret0, _ := ret[0].([]*mmodel.Alias) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveAccount indicates an expected call of ResolveAccount. +func (mr *MockRepositoryMockRecorder) ResolveAccount(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveAccount", reflect.TypeOf((*MockRepository)(nil).ResolveAccount), ctx, accountID) +} + +// ResolveBankAccount mocks base method. +func (m *MockRepository) ResolveBankAccount(ctx context.Context, input *mmodel.ResolveBankAccountInput) ([]*mmodel.Alias, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveBankAccount", ctx, input) + ret0, _ := ret[0].([]*mmodel.Alias) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveBankAccount indicates an expected call of ResolveBankAccount. +func (mr *MockRepositoryMockRecorder) ResolveBankAccount(ctx, input any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAll", reflect.TypeOf((*MockRepository)(nil).FindAll), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveBankAccount", reflect.TypeOf((*MockRepository)(nil).ResolveBankAccount), ctx, input) } // Update mocks base method. -func (m *MockRepository) Update(arg0 context.Context, arg1 string, arg2, arg3 uuid.UUID, arg4 *mmodel.Alias, arg5 []string) (*mmodel.Alias, error) { +func (m *MockRepository) Update(ctx context.Context, organizationID string, holderID, id uuid.UUID, input *mmodel.Alias, fieldsToRemove []string) (*mmodel.Alias, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "Update", ctx, organizationID, holderID, id, input, fieldsToRemove) ret0, _ := ret[0].(*mmodel.Alias) ret1, _ := ret[1].(error) return ret0, ret1 } // Update indicates an expected call of Update. -func (mr *MockRepositoryMockRecorder) Update(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) Update(ctx, organizationID, holderID, id, input, fieldsToRemove any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), ctx, organizationID, holderID, id, input, fieldsToRemove) } diff --git a/components/crm/internal/adapters/mongodb/alias/alias.mongodb_test.go b/components/crm/internal/adapters/mongodb/alias/alias.mongodb_test.go index 993e38327..ae5aba54a 100644 --- a/components/crm/internal/adapters/mongodb/alias/alias.mongodb_test.go +++ b/components/crm/internal/adapters/mongodb/alias/alias.mongodb_test.go @@ -7,6 +7,7 @@ package alias import ( "testing" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" "github.com/LerianStudio/midaz/v3/pkg/net/http" testutils "github.com/LerianStudio/midaz/v3/tests/utils" "github.com/google/uuid" @@ -118,6 +119,17 @@ func TestMongoDBRepository_buildAliasFilter(t *testing.T) { wantKeys: []string{"holder_id", "deleted_at", "banking_details.branch"}, wantErr: false, }, + { + name: "filter by banking details bank ID and type", + query: http.QueryHeader{ + BankingDetailsBankID: testutils.Ptr("12345678"), + BankingDetailsType: testutils.Ptr("CACC"), + }, + holderID: holderID, + includeDeleted: false, + wantKeys: []string{"holder_id", "deleted_at", "banking_details.bank_id", "banking_details.type"}, + wantErr: false, + }, { name: "filter with metadata", query: http.QueryHeader{ @@ -258,3 +270,52 @@ func TestMongoDBRepository_buildAliasFilter_BankingDetailsHashes(t *testing.T) { assert.Equal(t, expectedAccountHash, foundAccountHash, "account hash should match") assert.Equal(t, expectedIbanHash, foundIbanHash, "IBAN hash should match") } + +func TestMergeAliasForBankAccountIndexClearsRemovedIdentityFields(t *testing.T) { + t.Parallel() + + document := "12345678901" + aliasType := "LEGAL_PERSON" + ledgerID := uuid.New().String() + accountID := uuid.New().String() + holderID := uuid.New() + bankID := "001" + branch := "0001" + bankAccount := "123456" + bankType := "CACC" + + existing := &mmodel.Alias{ + Document: &document, + Type: &aliasType, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + BankingDetails: &mmodel.BankingDetails{ + BankID: &bankID, + Branch: &branch, + Account: &bankAccount, + Type: &bankType, + }, + } + + merged := mergeAliasForBankAccountIndex(existing, &mmodel.Alias{}, []string{ + "document", + "accountId", + "banking_details.bank_id", + "bankingDetails.account", + }) + + require.NotNil(t, merged) + assert.Nil(t, merged.Document) + assert.Nil(t, merged.AccountID) + assert.NotNil(t, merged.LedgerID) + require.NotNil(t, merged.BankingDetails) + assert.Nil(t, merged.BankingDetails.BankID) + assert.Nil(t, merged.BankingDetails.Account) + assert.NotNil(t, merged.BankingDetails.Branch) + assert.NotNil(t, merged.BankingDetails.Type) + + require.NotNil(t, existing.BankingDetails) + assert.NotNil(t, existing.BankingDetails.BankID) + assert.NotNil(t, existing.BankingDetails.Account) +} diff --git a/components/crm/internal/adapters/mongodb/alias/alias_maintenance.mongodb.go b/components/crm/internal/adapters/mongodb/alias/alias_maintenance.mongodb.go index 6be6b9558..9af71d21f 100644 --- a/components/crm/internal/adapters/mongodb/alias/alias_maintenance.mongodb.go +++ b/components/crm/internal/adapters/mongodb/alias/alias_maintenance.mongodb.go @@ -9,6 +9,7 @@ import ( "fmt" "reflect" "strings" + "sync" "time" libCommons "github.com/LerianStudio/lib-commons/v5/commons" @@ -24,6 +25,8 @@ import ( "go.opentelemetry.io/otel/attribute" ) +var aliasCollectionIndexEnsureCache sync.Map + // DeleteRelatedParty removes a related party from an alias by ID (hard delete) func (am *MongoDBRepository) DeleteRelatedParty(ctx context.Context, organizationID string, holderID, aliasID, relatedPartyID uuid.UUID) error { logger, tracer, reqId, _ := libCommons.NewTrackingFromContext(ctx) @@ -88,6 +91,11 @@ func (am *MongoDBRepository) DeleteRelatedParty(ctx context.Context, organizatio // createIndexes creates indexes for specific fields, if it not exists. func createIndexes(ctx context.Context, collection *mongo.Collection) error { + cacheKey := fmt.Sprintf("%p.%s.%s", collection.Database().Client(), collection.Database().Name(), collection.Name()) + if _, ok := aliasCollectionIndexEnsureCache.Load(cacheKey); ok { + return nil + } + indexModels := []mongo.IndexModel{ { Keys: bson.D{ @@ -131,6 +139,16 @@ func createIndexes(ctx context.Context, collection *mongo.Collection) error { Options: options.Index(). SetPartialFilterExpression(bson.D{{Key: "deleted_at", Value: nil}}), }, + { + Keys: bson.D{{Key: "banking_details.bank_id", Value: 1}}, + Options: options.Index(). + SetPartialFilterExpression(bson.D{{Key: "deleted_at", Value: nil}}), + }, + { + Keys: bson.D{{Key: "banking_details.type", Value: 1}}, + Options: options.Index(). + SetPartialFilterExpression(bson.D{{Key: "deleted_at", Value: nil}}), + }, { Keys: bson.D{ {Key: "ledger_id", Value: 1}, @@ -161,6 +179,11 @@ func createIndexes(ctx context.Context, collection *mongo.Collection) error { defer cancel() _, err := collection.Indexes().CreateMany(ctx, indexModels) + if err != nil { + return err + } - return err + aliasCollectionIndexEnsureCache.Store(cacheKey, struct{}{}) + + return nil } diff --git a/components/crm/internal/adapters/mongodb/alias/alias_query.mongodb.go b/components/crm/internal/adapters/mongodb/alias/alias_query.mongodb.go index 06717265f..1aaf0e686 100644 --- a/components/crm/internal/adapters/mongodb/alias/alias_query.mongodb.go +++ b/components/crm/internal/adapters/mongodb/alias/alias_query.mongodb.go @@ -61,7 +61,7 @@ func (am *MongoDBRepository) FindAll(ctx context.Context, organizationID string, attribute.Bool("app.request.query.has_ledger_id", query.LedgerID != nil), attribute.Bool("app.request.query.has_document", query.Document != nil), attribute.Bool("app.request.query.has_related_party_filters", query.RelatedPartyDocument != nil || query.RelatedPartyRole != nil), - attribute.Bool("app.request.query.has_banking_details_filters", query.BankingDetailsBranch != nil || query.BankingDetailsAccount != nil || query.BankingDetailsIban != nil), + attribute.Bool("app.request.query.has_banking_details_filters", query.BankingDetailsBranch != nil || query.BankingDetailsAccount != nil || query.BankingDetailsIban != nil || query.BankingDetailsBankID != nil || query.BankingDetailsType != nil), ) filter, err := am.buildAliasFilter(query, holderID, includeDeleted) @@ -153,6 +153,14 @@ func (am *MongoDBRepository) buildAliasFilter(query http.QueryHeader, holderID u filter = append(filter, bson.E{Key: "banking_details.branch", Value: *query.BankingDetailsBranch}) } + if !libCommons.IsNilOrEmpty(query.BankingDetailsBankID) { + filter = append(filter, bson.E{Key: "banking_details.bank_id", Value: *query.BankingDetailsBankID}) + } + + if !libCommons.IsNilOrEmpty(query.BankingDetailsType) { + filter = append(filter, bson.E{Key: "banking_details.type", Value: *query.BankingDetailsType}) + } + if !libCommons.IsNilOrEmpty(query.RegulatoryFieldsParticipantDocument) { participantDocHash := am.DataSecurity.GenerateHash(query.RegulatoryFieldsParticipantDocument) filter = append(filter, bson.E{Key: "search.regulatory_fields_participant_document", Value: participantDocHash}) diff --git a/components/crm/internal/adapters/mongodb/alias/bank_account_index.go b/components/crm/internal/adapters/mongodb/alias/bank_account_index.go new file mode 100644 index 000000000..8ba101725 --- /dev/null +++ b/components/crm/internal/adapters/mongodb/alias/bank_account_index.go @@ -0,0 +1,219 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package alias + +import ( + "errors" + "strings" + "time" + + libCrypto "github.com/LerianStudio/lib-commons/v5/commons/crypto" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/google/uuid" +) + +const bankAccountIndexCollection = "alias_bank_account_index" + +type BankAccountIndexModel struct { + ID *uuid.UUID `bson:"_id,omitempty"` + AliasID *uuid.UUID `bson:"alias_id,omitempty"` + OrganizationID *string `bson:"organization_id,omitempty"` + LedgerID *string `bson:"ledger_id,omitempty"` + AccountID *string `bson:"account_id,omitempty"` + HolderID *uuid.UUID `bson:"holder_id,omitempty"` + Document *string `bson:"document,omitempty"` + Type *string `bson:"type,omitempty"` + Search *BankAccountIndexSearchMongoDB `bson:"search,omitempty"` + BankingDetails *BankAccountIndexBankingMongoDB `bson:"banking_details,omitempty"` + CreatedAt *time.Time `bson:"created_at,omitempty"` + UpdatedAt *time.Time `bson:"updated_at,omitempty"` + DeletedAt *time.Time `bson:"deleted_at"` +} + +type BankAccountIndexSearchMongoDB struct { + Document *string `bson:"document,omitempty"` + BankingDetailsAccount *string `bson:"banking_details_account,omitempty"` +} + +type BankAccountIndexBankingMongoDB struct { + BankID *string `bson:"bank_id,omitempty"` + Branch *string `bson:"branch,omitempty"` + BranchCanonical *string `bson:"branch_canonical,omitempty"` + Account *string `bson:"account,omitempty"` + Type *string `bson:"type,omitempty"` +} + +func canonicalizeBankAccountBranch(branch string) string { + canonical := strings.TrimLeft(strings.TrimSpace(branch), "0") + if canonical == "" { + return "0" + } + + return canonical +} + +func bankAccountIndexModelFromAlias(organizationID string, alias *mmodel.Alias, ds *libCrypto.Crypto) (*BankAccountIndexModel, error) { + if alias == nil || alias.ID == nil { + return nil, nil + } + + document, err := encryptOptional(ds, alias.Document) + if err != nil { + return nil, err + } + + organization := organizationID + + createdAt := alias.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + + updatedAt := alias.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now() + } + + model := &BankAccountIndexModel{ + ID: alias.ID, + AliasID: alias.ID, + OrganizationID: &organization, + LedgerID: alias.LedgerID, + AccountID: alias.AccountID, + HolderID: alias.HolderID, + Document: document, + Type: alias.Type, + Search: &BankAccountIndexSearchMongoDB{}, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + DeletedAt: alias.DeletedAt, + } + + if alias.Document != nil && strings.TrimSpace(*alias.Document) != "" { + documentHash := ds.GenerateHash(alias.Document) + model.Search.Document = &documentHash + } + + if hasCompleteBankAccountIdentity(alias.BankingDetails) { + account, err := encryptOptional(ds, alias.BankingDetails.Account) + if err != nil { + return nil, err + } + + accountHash := ds.GenerateHash(alias.BankingDetails.Account) + branchCanonical := canonicalizeBankAccountBranch(*alias.BankingDetails.Branch) + model.Search.BankingDetailsAccount = &accountHash + model.BankingDetails = &BankAccountIndexBankingMongoDB{ + BankID: alias.BankingDetails.BankID, + Branch: alias.BankingDetails.Branch, + BranchCanonical: &branchCanonical, + Account: account, + Type: alias.BankingDetails.Type, + } + } + + return model, nil +} + +//nolint:gocyclo // Decoder validates the full resolver routing boundary before returning domain data. +func (model *BankAccountIndexModel) ToAlias(ds *libCrypto.Crypto) (*mmodel.Alias, error) { + if model == nil { + return nil, nil + } + + if model.AliasID == nil || model.OrganizationID == nil || model.LedgerID == nil || model.AccountID == nil || model.HolderID == nil || model.CreatedAt == nil || model.UpdatedAt == nil { + return nil, errors.New("malformed bank account index row: missing required routing fields") + } + + if model.AliasID != nil && *model.AliasID == uuid.Nil || model.HolderID != nil && *model.HolderID == uuid.Nil || isZeroUUIDString(model.OrganizationID) || isZeroUUIDString(model.LedgerID) || isZeroUUIDString(model.AccountID) { + return nil, errors.New("malformed bank account index row: zero UUID routing fields") + } + + organizationID, err := parseRequiredUUIDString(model.OrganizationID) + if err != nil { + return nil, errors.New("malformed bank account index row: invalid organization_id") + } + + if _, err := parseRequiredUUIDString(model.LedgerID); err != nil { + return nil, errors.New("malformed bank account index row: invalid ledger_id") + } + + if _, err := parseRequiredUUIDString(model.AccountID); err != nil { + return nil, errors.New("malformed bank account index row: invalid account_id") + } + + document, err := decryptOptional(ds, model.Document) + if err != nil { + return nil, err + } + + alias := &mmodel.Alias{ + ID: model.AliasID, + OrganizationID: &organizationID, + Document: document, + Type: model.Type, + LedgerID: model.LedgerID, + AccountID: model.AccountID, + HolderID: model.HolderID, + CreatedAt: *model.CreatedAt, + UpdatedAt: *model.UpdatedAt, + DeletedAt: model.DeletedAt, + } + + if model.BankingDetails != nil { + account, err := decryptOptional(ds, model.BankingDetails.Account) + if err != nil { + return nil, err + } + + alias.BankingDetails = &mmodel.BankingDetails{ + BankID: model.BankingDetails.BankID, + Branch: model.BankingDetails.Branch, + Account: account, + Type: model.BankingDetails.Type, + } + } + + return alias, nil +} + +func hasCompleteBankAccountIdentity(bankingDetails *mmodel.BankingDetails) bool { + return bankingDetails != nil && + bankingDetails.BankID != nil && strings.TrimSpace(*bankingDetails.BankID) != "" && + bankingDetails.Branch != nil && strings.TrimSpace(*bankingDetails.Branch) != "" && + bankingDetails.Account != nil && strings.TrimSpace(*bankingDetails.Account) != "" && + bankingDetails.Type != nil && strings.TrimSpace(*bankingDetails.Type) != "" +} + +func hasAnyBankAccountIdentity(bankingDetails *mmodel.BankingDetails) bool { + return bankingDetails != nil && + ((bankingDetails.BankID != nil && strings.TrimSpace(*bankingDetails.BankID) != "") || + (bankingDetails.Branch != nil && strings.TrimSpace(*bankingDetails.Branch) != "") || + (bankingDetails.Account != nil && strings.TrimSpace(*bankingDetails.Account) != "") || + (bankingDetails.Type != nil && strings.TrimSpace(*bankingDetails.Type) != "")) +} + +func isZeroUUIDString(value *string) bool { + if value == nil { + return false + } + + id, err := uuid.Parse(*value) + + return err == nil && id == uuid.Nil +} + +func parseRequiredUUIDString(value *string) (uuid.UUID, error) { + if value == nil { + return uuid.Nil, errors.New("missing UUID") + } + + id, err := uuid.Parse(*value) + if err != nil || id == uuid.Nil { + return uuid.Nil, errors.New("invalid UUID") + } + + return id, nil +} diff --git a/components/crm/internal/adapters/mongodb/alias/bank_account_index.mongodb.go b/components/crm/internal/adapters/mongodb/alias/bank_account_index.mongodb.go new file mode 100644 index 000000000..d29aee946 --- /dev/null +++ b/components/crm/internal/adapters/mongodb/alias/bank_account_index.mongodb.go @@ -0,0 +1,506 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package alias + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + libCommons "github.com/LerianStudio/lib-commons/v5/commons" + libLog "github.com/LerianStudio/lib-commons/v5/commons/log" + libOpenTelemetry "github.com/LerianStudio/lib-commons/v5/commons/opentelemetry" + "github.com/LerianStudio/midaz/v3/pkg" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.opentelemetry.io/otel/attribute" +) + +var bankAccountIndexEnsureCache sync.Map + +const ( + bankAccountIndexReportAliasIDLimit = 500 + bankAccountIndexDryRunIdentityLimit = 10000 +) + +func createBankAccountIndexIndexes(ctx context.Context, collection *mongo.Collection) error { + indexModels := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "search.document", Value: 1}, + {Key: "banking_details.bank_id", Value: 1}, + {Key: "banking_details.branch_canonical", Value: 1}, + {Key: "search.banking_details_account", Value: 1}, + {Key: "banking_details.type", Value: 1}, + }, + Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.D{ + {Key: "deleted_at", Value: nil}, + {Key: "search.document", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$type", Value: "string"}}}, + {Key: "banking_details.bank_id", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$type", Value: "string"}}}, + {Key: "banking_details.branch_canonical", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$type", Value: "string"}}}, + {Key: "search.banking_details_account", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$type", Value: "string"}}}, + {Key: "banking_details.type", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$type", Value: "string"}}}, + }), + }, + { + Keys: bson.D{{Key: "account_id", Value: 1}, {Key: "deleted_at", Value: 1}}, + Options: options.Index(), + }, + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + _, err := collection.Indexes().CreateMany(ctx, indexModels) + + return err +} + +func ensureBankAccountIndexIndexes(ctx context.Context, db *mongo.Database) error { + cacheKey := fmt.Sprintf("%p.%s.%s", db.Client(), db.Name(), bankAccountIndexCollection) + if _, ok := bankAccountIndexEnsureCache.Load(cacheKey); ok { + return nil + } + + if err := createBankAccountIndexIndexes(ctx, db.Collection(bankAccountIndexCollection)); err != nil { + return err + } + + bankAccountIndexEnsureCache.Store(cacheKey, struct{}{}) + + return nil +} + +func (am *MongoDBRepository) upsertBankAccountIndex(ctx context.Context, organizationID string, alias *mmodel.Alias) error { + _, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "mongodb.upsert_bank_account_index") + defer span.End() + + span.SetAttributes( + attribute.String("app.request.request_id", reqID), + attribute.String("app.request.organization_id", organizationID), + attribute.Bool("app.request.has_banking_details", alias != nil && alias.BankingDetails != nil), + ) + + if alias == nil || alias.ID == nil { + return nil + } + + db, err := am.getDatabase(ctx) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to get database", err) + return err + } + + if err := ensureBankAccountIndexIndexes(ctx, db); err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to create bank account index indexes", err) + return err + } + + err = am.replaceBankAccountIndex(ctx, db, organizationID, alias) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + businessErr := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Bank account identity already associated", businessErr) + + return businessErr + } + + libOpenTelemetry.HandleSpanError(span, "Failed to upsert bank account index", err) + + return err + } + + return nil +} + +func (am *MongoDBRepository) replaceBankAccountIndex(ctx context.Context, db *mongo.Database, organizationID string, alias *mmodel.Alias) error { + if alias == nil || alias.ID == nil { + return nil + } + + model, err := bankAccountIndexModelFromAlias(organizationID, alias, am.DataSecurity) + if err != nil { + return err + } + + _, err = db.Collection(bankAccountIndexCollection).ReplaceOne(ctx, bson.M{"_id": alias.ID}, model, options.Replace().SetUpsert(true)) + + return err +} + +func (am *MongoDBRepository) preflightBankAccountIdentityConflict(ctx context.Context, organizationID string, alias *mmodel.Alias) error { + _, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "mongodb.preflight_bank_account_identity_conflict") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID), attribute.String("app.request.organization_id", organizationID)) + + if alias == nil || alias.ID == nil || !hasCompleteBankAccountIdentity(alias.BankingDetails) || alias.Document == nil || strings.TrimSpace(*alias.Document) == "" { + return nil + } + + db, err := am.getDatabase(ctx) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to get database", err) + return err + } + + if err := ensureBankAccountIndexIndexes(ctx, db); err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to create bank account index indexes", err) + return err + } + + documentHash := am.DataSecurity.GenerateHash(alias.Document) + accountHash := am.DataSecurity.GenerateHash(alias.BankingDetails.Account) + branchCanonical := canonicalizeBankAccountBranch(*alias.BankingDetails.Branch) + filter := bson.M{ + "search.document": documentHash, + "banking_details.bank_id": *alias.BankingDetails.BankID, + "banking_details.branch_canonical": branchCanonical, + "search.banking_details_account": accountHash, + "banking_details.type": *alias.BankingDetails.Type, + "deleted_at": nil, + "_id": bson.M{"$ne": alias.ID}, + } + + count, err := db.Collection(bankAccountIndexCollection).CountDocuments(ctx, filter, options.Count().SetLimit(1)) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to preflight bank account identity conflict", err) + return err + } + + if count > 0 { + businessErr := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Bank account identity already associated", businessErr) + + return businessErr + } + + return nil +} + +func (am *MongoDBRepository) deleteBankAccountIndexWithDB(ctx context.Context, db *mongo.Database, aliasID uuid.UUID, hardDelete bool) error { + if hardDelete { + _, err := db.Collection(bankAccountIndexCollection).DeleteOne(ctx, bson.M{"_id": aliasID}) + return err + } + + _, err := db.Collection(bankAccountIndexCollection).UpdateOne(ctx, bson.M{"_id": aliasID, "deleted_at": nil}, bson.M{"$set": bson.M{"deleted_at": time.Now(), "updated_at": time.Now()}}) + + return err +} + +func (am *MongoDBRepository) ResolveBankAccount(ctx context.Context, input *mmodel.ResolveBankAccountInput) ([]*mmodel.Alias, error) { + _, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "mongodb.resolve_bank_account") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID)) + + if input == nil || input.Document == "" || input.BankingDetails.BankID == "" || input.BankingDetails.Branch == "" || input.BankingDetails.Account == "" || input.BankingDetails.Type == "" { + err := pkg.ValidateBusinessError(cn.ErrMissingFieldsInRequest, cn.EntityAlias, "document, bankingDetails.bankId, bankingDetails.branch, bankingDetails.account, bankingDetails.type") + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid bank account resolver input", err) + + return nil, err + } + + db, err := am.getDatabase(ctx) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to get database", err) + return nil, err + } + + documentHash := am.DataSecurity.GenerateHash(&input.Document) + accountHash := am.DataSecurity.GenerateHash(&input.BankingDetails.Account) + branchCanonical := canonicalizeBankAccountBranch(input.BankingDetails.Branch) + + filter := bson.D{ + {Key: "search.document", Value: documentHash}, + {Key: "banking_details.bank_id", Value: input.BankingDetails.BankID}, + {Key: "banking_details.branch_canonical", Value: branchCanonical}, + {Key: "search.banking_details_account", Value: accountHash}, + {Key: "banking_details.type", Value: input.BankingDetails.Type}, + {Key: "deleted_at", Value: nil}, + } + + return am.findBankAccountIndexAliases(ctx, db.Collection(bankAccountIndexCollection), filter) +} + +func (am *MongoDBRepository) ResolveAccount(ctx context.Context, accountID uuid.UUID) ([]*mmodel.Alias, error) { + _, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "mongodb.resolve_account") + defer span.End() + + span.SetAttributes( + attribute.String("app.request.request_id", reqID), + attribute.String("app.request.account_id", accountID.String()), + ) + + if accountID == uuid.Nil { + err := pkg.ValidateBusinessError(cn.ErrInvalidQueryParameter, cn.EntityAlias, "accountId") + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid account id", err) + + return nil, err + } + + db, err := am.getDatabase(ctx) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to get database", err) + return nil, err + } + + filter := bson.D{{Key: "account_id", Value: accountID.String()}, {Key: "deleted_at", Value: nil}} + + return am.findBankAccountIndexAliases(ctx, db.Collection(bankAccountIndexCollection), filter) +} + +func (am *MongoDBRepository) findBankAccountIndexAliases(ctx context.Context, coll *mongo.Collection, filter bson.D) ([]*mmodel.Alias, error) { + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + _ = tracer + _ = reqID + + cursor, err := coll.Find(ctx, filter, options.Find().SetLimit(2)) + if err != nil { + return nil, err + } + + defer func() { + if closeErr := cursor.Close(ctx); closeErr != nil { + logger.Log(ctx, libLog.LevelWarn, "Failed to close bank account index cursor", libLog.Err(closeErr)) + } + }() + + var records []*BankAccountIndexModel + + for cursor.Next(ctx) { + var record BankAccountIndexModel + if err := cursor.Decode(&record); err != nil { + return nil, err + } + + records = append(records, &record) + } + + if err := cursor.Err(); err != nil { + return nil, err + } + + aliases := make([]*mmodel.Alias, 0, len(records)) + for _, record := range records { + resolved, err := record.ToAlias(am.DataSecurity) + if err != nil { + return nil, err + } + + aliases = append(aliases, resolved) + } + + return aliases, nil +} + +func (am *MongoDBRepository) BackfillBankAccountIndex(ctx context.Context, dryRun bool) (*mmodel.BankAccountIndexBackfillReport, error) { + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "mongodb.backfill_bank_account_index") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID), attribute.Bool("app.request.dry_run", dryRun)) + + if err := ctx.Err(); err != nil { + return nil, err + } + + db, err := am.getDatabase(ctx) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to get database", err) + return nil, err + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + collections, err := db.ListCollectionNames(ctx, bson.M{"name": bson.M{"$regex": "^aliases_"}}) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to list alias collections", err) + return nil, err + } + + report := &mmodel.BankAccountIndexBackfillReport{DryRun: dryRun, CollectionsScanned: len(collections)} + + var seen map[string][]uuid.UUID + if dryRun { + seen = make(map[string][]uuid.UUID) + } + + for _, collectionName := range collections { + if err := ctx.Err(); err != nil { + return nil, err + } + + if err := am.backfillAliasCollection(ctx, db.Collection(collectionName), strings.TrimPrefix(collectionName, "aliases_"), dryRun, report, seen, logger); err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to backfill alias collection", err) + return nil, err + } + } + + for _, aliasIDs := range seen { + if err := ctx.Err(); err != nil { + return nil, err + } + + if len(aliasIDs) > 1 { + report.Duplicates += len(aliasIDs) + appendDuplicateAliasIDs(report, aliasIDs...) + } + } + + return report, nil +} + +//nolint:gocognit,gocyclo // Backfill keeps scan/report/write flow together to preserve resumable accounting semantics. +func (am *MongoDBRepository) backfillAliasCollection(ctx context.Context, coll *mongo.Collection, organizationID string, dryRun bool, report *mmodel.BankAccountIndexBackfillReport, seen map[string][]uuid.UUID, logger libLog.Logger) error { + if err := ctx.Err(); err != nil { + return err + } + + cursor, err := coll.Find(ctx, bson.M{"deleted_at": nil}, options.Find().SetBatchSize(100)) + if err != nil { + return err + } + + defer func() { + if closeErr := cursor.Close(ctx); closeErr != nil { + logger.Log(ctx, libLog.LevelWarn, "Failed to close alias backfill cursor", libLog.Err(closeErr)) + } + }() + + for cursor.Next(ctx) { + if err := ctx.Err(); err != nil { + return err + } + + var record MongoDBModel + if err := cursor.Decode(&record); err != nil { + return err + } + + report.AliasesScanned++ + + entity, err := record.ToEntity(am.DataSecurity) + if err != nil { + report.Incomplete++ + if record.ID != nil { + appendIncompleteAliasID(report, *record.ID) + } + + logger.Log(ctx, libLog.LevelWarn, "Skipping malformed alias during bank account index backfill", libLog.Err(err)) + + continue + } + + model, err := bankAccountIndexModelFromAlias(organizationID, entity, am.DataSecurity) + if err != nil { + report.Incomplete++ + if entity != nil && entity.ID != nil { + appendIncompleteAliasID(report, *entity.ID) + } + + logger.Log(ctx, libLog.LevelWarn, "Skipping malformed alias during bank account index backfill", libLog.Err(err)) + + continue + } + + if entity != nil && hasAnyBankAccountIdentity(entity.BankingDetails) && !hasCompleteBankAccountIdentity(entity.BankingDetails) { + report.Incomplete++ + if entity.ID != nil { + appendIncompleteAliasID(report, *entity.ID) + } + } + + if model == nil { + report.Incomplete++ + if entity != nil && entity.ID != nil { + appendIncompleteAliasID(report, *entity.ID) + } + + continue + } + + if seen != nil && entity.ID != nil && model.BankingDetails != nil && model.Search != nil && model.Search.Document != nil && model.Search.BankingDetailsAccount != nil { + identity := fmt.Sprintf("%s:%s:%s:%s:%s", bankIndexStringValue(model.Search.Document), bankIndexStringValue(model.BankingDetails.BankID), bankIndexStringValue(model.BankingDetails.BranchCanonical), bankIndexStringValue(model.Search.BankingDetailsAccount), bankIndexStringValue(model.BankingDetails.Type)) + if _, ok := seen[identity]; ok || len(seen) < bankAccountIndexDryRunIdentityLimit { + seen[identity] = append(seen[identity], *entity.ID) + } else { + report.DuplicateAliasIDsTruncated = true + } + } + + if dryRun { + continue + } + + if err := ctx.Err(); err != nil { + return err + } + + if err := am.upsertBankAccountIndex(ctx, organizationID, entity); err != nil { + if mongo.IsDuplicateKeyError(err) || errors.As(err, new(pkg.EntityConflictError)) { + report.Duplicates++ + if entity.ID != nil { + appendDuplicateAliasIDs(report, *entity.ID) + } + + continue + } + + return err + } + + report.Upserted++ + } + + return cursor.Err() +} + +func bankIndexStringValue(value *string) string { + if value == nil { + return "" + } + + return *value +} + +func appendIncompleteAliasID(report *mmodel.BankAccountIndexBackfillReport, aliasID uuid.UUID) { + if len(report.IncompleteAliasIDs) >= bankAccountIndexReportAliasIDLimit { + report.IncompleteAliasIDsTruncated = true + return + } + + report.IncompleteAliasIDs = append(report.IncompleteAliasIDs, aliasID) +} + +func appendDuplicateAliasIDs(report *mmodel.BankAccountIndexBackfillReport, aliasIDs ...uuid.UUID) { + for _, aliasID := range aliasIDs { + if len(report.DuplicateAliasIDs) >= bankAccountIndexReportAliasIDLimit { + report.DuplicateAliasIDsTruncated = true + return + } + + report.DuplicateAliasIDs = append(report.DuplicateAliasIDs, aliasID) + } +} diff --git a/components/crm/internal/adapters/mongodb/alias/bank_account_index_test.go b/components/crm/internal/adapters/mongodb/alias/bank_account_index_test.go new file mode 100644 index 000000000..89c70e3dc --- /dev/null +++ b/components/crm/internal/adapters/mongodb/alias/bank_account_index_test.go @@ -0,0 +1,281 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package alias + +import ( + "testing" + "time" + + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + testutils "github.com/LerianStudio/midaz/v3/tests/utils" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBankAccountIndexModelFromAlias_StoresCanonicalBranchAndProtectsSensitiveFields(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + aliasID := uuid.New() + holderID := uuid.New() + document := "12345678901" + account := "001234567" + branch := "0000" + bankID := "12345678" + accountType := "CACC" + organizationID := "00000000-0000-0000-0000-000000000001" + ledgerID := "00000000-0000-0000-0000-000000000002" + accountID := "00000000-0000-0000-0000-000000000003" + + model, err := bankAccountIndexModelFromAlias(organizationID, &mmodel.Alias{ + ID: &aliasID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + BankingDetails: &mmodel.BankingDetails{ + BankID: &bankID, + Branch: &branch, + Account: &account, + Type: &accountType, + }, + }, crypto) + + require.NoError(t, err) + require.NotNil(t, model) + assert.Equal(t, "0", *model.BankingDetails.BranchCanonical) + assert.Equal(t, branch, *model.BankingDetails.Branch) + assert.Equal(t, crypto.GenerateHash(&document), *model.Search.Document) + assert.Equal(t, crypto.GenerateHash(&account), *model.Search.BankingDetailsAccount) + assert.NotEqual(t, document, *model.Document) + assert.NotEqual(t, account, *model.BankingDetails.Account) +} + +func TestCanonicalizeBankAccountBranch(t *testing.T) { + t.Parallel() + + assert.Equal(t, "1", canonicalizeBankAccountBranch("0001")) + assert.Equal(t, "123", canonicalizeBankAccountBranch("00123")) + assert.Equal(t, "0", canonicalizeBankAccountBranch("0000")) + assert.Equal(t, "0", canonicalizeBankAccountBranch("")) +} + +func TestBankAccountIndexModelToAliasRejectsMalformedRoutingFields(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + model := &BankAccountIndexModel{} + + result, err := model.ToAlias(crypto) + + require.Error(t, err) + assert.Nil(t, result) +} + +func TestBankAccountIndexModelToAliasRejectsZeroUUIDRoutingFields(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + zeroID := uuid.Nil + organizationID := uuid.Nil.String() + ledgerID := uuid.New().String() + accountID := uuid.New().String() + now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + model := &BankAccountIndexModel{ + AliasID: &zeroID, + OrganizationID: &organizationID, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &zeroID, + CreatedAt: &now, + UpdatedAt: &now, + } + + result, err := model.ToAlias(crypto) + + require.Error(t, err) + assert.Nil(t, result) +} + +func TestBankAccountIndexModelToAliasReturnsTypedRoutingFields(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + aliasID := uuid.New() + holderID := uuid.New() + organizationID := uuid.New() + ledgerID := uuid.New().String() + accountID := uuid.New().String() + document := "12345678901" + createdAt := time.Date(2026, time.January, 2, 15, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(time.Hour) + + model, err := bankAccountIndexModelFromAlias(organizationID.String(), &mmodel.Alias{ + ID: &aliasID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, crypto) + require.NoError(t, err) + + result, err := model.ToAlias(crypto) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, aliasID, *result.ID) + assert.Equal(t, organizationID, *result.OrganizationID) + assert.Equal(t, ledgerID, *result.LedgerID) + assert.Equal(t, accountID, *result.AccountID) + assert.Equal(t, holderID, *result.HolderID) + assert.Equal(t, document, *result.Document) + assert.Equal(t, createdAt, result.CreatedAt) + assert.Equal(t, updatedAt, result.UpdatedAt) +} + +func TestBankAccountIndexModelToAliasRejectsInvalidOrganizationID(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + aliasID := uuid.New() + holderID := uuid.New() + organizationID := "not-a-uuid" + ledgerID := uuid.New().String() + accountID := uuid.New().String() + now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + model := &BankAccountIndexModel{ + AliasID: &aliasID, + OrganizationID: &organizationID, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + CreatedAt: &now, + UpdatedAt: &now, + } + + result, err := model.ToAlias(crypto) + + require.Error(t, err) + assert.Nil(t, result) +} + +func TestBankAccountIndexModelToAliasRejectsInvalidLedgerAndAccountIDs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*BankAccountIndexModel) + wantErr string + }{ + { + name: "invalid ledger id", + mutate: func(model *BankAccountIndexModel) { + invalid := "not-a-uuid" + model.LedgerID = &invalid + }, + wantErr: "invalid ledger_id", + }, + { + name: "invalid account id", + mutate: func(model *BankAccountIndexModel) { + invalid := "not-a-uuid" + model.AccountID = &invalid + }, + wantErr: "invalid account_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + aliasID := uuid.New() + holderID := uuid.New() + organizationID := uuid.New().String() + ledgerID := uuid.New().String() + accountID := uuid.New().String() + now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + model := &BankAccountIndexModel{ + AliasID: &aliasID, + OrganizationID: &organizationID, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + CreatedAt: &now, + UpdatedAt: &now, + } + + tt.mutate(model) + + result, err := model.ToAlias(crypto) + + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, result) + }) + } +} + +func TestBankAccountIndexRoutingUUIDHelpers(t *testing.T) { + t.Parallel() + + id := uuid.New() + idText := id.String() + zeroText := uuid.Nil.String() + invalidText := "not-a-uuid" + + parsed, err := parseRequiredUUIDString(&idText) + require.NoError(t, err) + assert.Equal(t, id, parsed) + assert.True(t, isZeroUUIDString(&zeroText)) + assert.False(t, isZeroUUIDString(&invalidText)) + assert.False(t, isZeroUUIDString(nil)) + + _, err = parseRequiredUUIDString(nil) + require.Error(t, err) + _, err = parseRequiredUUIDString(&zeroText) + require.Error(t, err) +} + +func TestHasAnyBankAccountIdentity(t *testing.T) { + t.Parallel() + + bankID := "12345678" + blank := " " + + assert.False(t, hasAnyBankAccountIdentity(nil)) + assert.False(t, hasAnyBankAccountIdentity(&mmodel.BankingDetails{BankID: &blank})) + assert.True(t, hasAnyBankAccountIdentity(&mmodel.BankingDetails{BankID: &bankID})) +} + +func TestBankAccountIndexModelFromAlias_StoresBaseRowWithoutBankingDetails(t *testing.T) { + t.Parallel() + + crypto := testutils.SetupCrypto(t) + aliasID := uuid.New() + holderID := uuid.New() + document := "12345678901" + organizationID := "00000000-0000-0000-0000-000000000001" + ledgerID := "00000000-0000-0000-0000-000000000002" + accountID := "00000000-0000-0000-0000-000000000003" + + model, err := bankAccountIndexModelFromAlias(organizationID, &mmodel.Alias{ + ID: &aliasID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + }, crypto) + + require.NoError(t, err) + require.NotNil(t, model) + assert.Nil(t, model.BankingDetails) + require.NotNil(t, model.Search.Document) + assert.Equal(t, crypto.GenerateHash(&document), *model.Search.Document) +} diff --git a/components/crm/internal/services/create-alias.go b/components/crm/internal/services/create-alias.go index 03a59f923..de63d0337 100644 --- a/components/crm/internal/services/create-alias.go +++ b/components/crm/internal/services/create-alias.go @@ -29,6 +29,11 @@ func (uc *UseCase) CreateAlias(ctx context.Context, organizationID string, holde attribute.String("app.request.holder_id", holderID.String()), ) + if err := validateCompleteBankingIdentity(ctx, cai.BankingDetails); err != nil { + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid banking identity", err) + return nil, err + } + aliasID, err := libCommons.GenerateUUIDv7() if err != nil { libOpenTelemetry.HandleSpanError(span, "Failed to generate alias id", err) diff --git a/components/crm/internal/services/create-alias_test.go b/components/crm/internal/services/create-alias_test.go index adedc6f04..9a79ccafe 100644 --- a/components/crm/internal/services/create-alias_test.go +++ b/components/crm/internal/services/create-alias_test.go @@ -33,6 +33,9 @@ func TestCreateAlias(t *testing.T) { ledgerID := uuid.Must(libCommons.GenerateUUIDv7()).String() holderDocument := "90217469051" participantDoc := "12345678912345" + bankID := "12345678" + branch := "0001" + account := "1234567" uc := &UseCase{ HolderRepo: mockHolderRepo, @@ -120,6 +123,22 @@ func TestCreateAlias(t *testing.T) { }, }, }, + { + name: "Error when partial banking identity is provided", + holderID: holderID, + input: &mmodel.CreateAliasInput{ + LedgerID: ledgerID, + AccountID: accountID, + BankingDetails: &mmodel.BankingDetails{ + BankID: &bankID, + Branch: &branch, + Account: &account, + }, + }, + mockSetup: func() {}, + expectedErr: cn.ErrMissingFieldsInRequest, + expectedResult: nil, + }, { name: "Error when holder not found for alias creation", holderID: uuid.New(), diff --git a/components/crm/internal/services/resolve-alias.go b/components/crm/internal/services/resolve-alias.go new file mode 100644 index 000000000..b7c0db480 --- /dev/null +++ b/components/crm/internal/services/resolve-alias.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package services + +import ( + "context" + + libCommons "github.com/LerianStudio/lib-commons/v5/commons" + libLog "github.com/LerianStudio/lib-commons/v5/commons/log" + libOpenTelemetry "github.com/LerianStudio/lib-commons/v5/commons/opentelemetry" + "github.com/LerianStudio/midaz/v3/pkg" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +func (uc *UseCase) ResolveBankAccount(ctx context.Context, input *mmodel.ResolveBankAccountInput) (*mmodel.ResolveAliasResponse, error) { + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "service.resolve_bank_account") + defer span.End() + + span.SetAttributes( + attribute.String("app.request.request_id", reqID), + attribute.Bool("app.request.has_document", input != nil && input.Document != ""), + attribute.Bool("app.request.has_banking_details", input != nil), + ) + + if err := ctx.Err(); err != nil { + return nil, err + } + + if input == nil || input.Document == "" || input.BankingDetails.BankID == "" || input.BankingDetails.Branch == "" || input.BankingDetails.Account == "" || input.BankingDetails.Type == "" { + err := pkg.ValidateBusinessError(cn.ErrMissingFieldsInRequest, cn.EntityAlias, "document, bankingDetails.bankId, bankingDetails.branch, bankingDetails.account, bankingDetails.type") + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid bank account resolver input", err) + + return nil, err + } + + aliases, err := uc.AliasRepo.ResolveBankAccount(ctx, input) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to resolve bank account", err) + logger.Log(ctx, libLog.LevelError, "Failed to resolve bank account", libLog.Err(err)) + + return nil, err + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + return resolveOneAlias(span, aliases, true) +} + +func (uc *UseCase) ResolveAccount(ctx context.Context, input *mmodel.ResolveAccountInput) (*mmodel.ResolveAliasResponse, error) { + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "service.resolve_account") + defer span.End() + + span.SetAttributes(attribute.String("app.request.request_id", reqID)) + + if err := ctx.Err(); err != nil { + return nil, err + } + + if input == nil { + err := pkg.ValidateBusinessError(cn.ErrMissingFieldsInRequest, cn.EntityAlias, "accountId") + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid account resolver input", err) + + return nil, err + } + + if input.AccountID == uuid.Nil { + err := pkg.ValidateBusinessError(cn.ErrInvalidQueryParameter, cn.EntityAlias, "accountId") + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid account id", err) + + return nil, err + } + + aliases, err := uc.AliasRepo.ResolveAccount(ctx, input.AccountID) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to resolve account", err) + logger.Log(ctx, libLog.LevelError, "Failed to resolve account", libLog.Err(err)) + + return nil, err + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + return resolveOneAlias(span, aliases, false) +} + +func resolveOneAlias(span trace.Span, aliases []*mmodel.Alias, requireBankingProof bool) (*mmodel.ResolveAliasResponse, error) { + switch len(aliases) { + case 0: + err := pkg.ValidateBusinessError(cn.ErrAliasNotFound, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Alias resolver match not found", err) + + return nil, err + case 1: + response := aliasToResolveResponse(aliases[0]) + if err := validateResolveAliasResponse(response, requireBankingProof); err != nil { + libOpenTelemetry.HandleSpanError(span, "Invalid resolver index row", err) + + return nil, err + } + + return response, nil + default: + err := pkg.ValidateBusinessError(cn.ErrAccountAlreadyAssociated, cn.EntityAlias) + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Duplicate active alias resolver matches", err) + + return nil, err + } +} + +func validateResolveAliasResponse(response *mmodel.ResolveAliasResponse, requireBankingProof bool) error { + if response == nil || response.ID == uuid.Nil || response.OrganizationID == uuid.Nil || response.LedgerID == uuid.Nil || response.AccountID == uuid.Nil || response.HolderID == uuid.Nil || response.HolderDocument == "" { + return pkg.ValidateBusinessError(cn.ErrInternalServer, cn.EntityAlias) + } + + if requireBankingProof && (response.BankingDetails.BankID == "" || response.BankingDetails.Branch == "" || response.BankingDetails.Account == "" || response.BankingDetails.Type == "") { + return pkg.ValidateBusinessError(cn.ErrInternalServer, cn.EntityAlias) + } + + return nil +} + +func aliasToResolveResponse(alias *mmodel.Alias) *mmodel.ResolveAliasResponse { + if alias == nil { + return nil + } + + response := &mmodel.ResolveAliasResponse{ + ID: uuidValueFromUUID(alias.ID), + OrganizationID: uuidValueFromUUID(alias.OrganizationID), + LedgerID: uuidValueFromString(alias.LedgerID), + AccountID: uuidValueFromString(alias.AccountID), + HolderID: uuidValueFromUUID(alias.HolderID), + HolderDocument: stringValue(alias.Document), + } + + if alias.BankingDetails != nil { + response.BankingDetails = mmodel.ResolveAliasBankingDetailsResponse{ + BankID: stringValue(alias.BankingDetails.BankID), + Branch: stringValue(alias.BankingDetails.Branch), + Account: stringValue(alias.BankingDetails.Account), + Type: stringValue(alias.BankingDetails.Type), + } + } + + return response +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + + return *value +} + +func uuidValueFromUUID(value *uuid.UUID) uuid.UUID { + if value == nil { + return uuid.Nil + } + + return *value +} + +func uuidValueFromString(value *string) uuid.UUID { + if value == nil { + return uuid.Nil + } + + id, err := uuid.Parse(*value) + if err != nil { + return uuid.Nil + } + + return id +} + +func (uc *UseCase) BackfillBankAccountIndex(ctx context.Context, dryRun bool) (*mmodel.BankAccountIndexBackfillReport, error) { + logger, tracer, reqID, _ := libCommons.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "service.backfill_bank_account_index") + defer span.End() + + span.SetAttributes( + attribute.String("app.request.request_id", reqID), + attribute.Bool("app.request.dry_run", dryRun), + ) + + if err := ctx.Err(); err != nil { + return nil, err + } + + report, err := uc.AliasRepo.BackfillBankAccountIndex(ctx, dryRun) + if err != nil { + libOpenTelemetry.HandleSpanError(span, "Failed to backfill bank account index", err) + logger.Log(ctx, libLog.LevelError, "Failed to backfill bank account index", libLog.Err(err)) + + return nil, err + } + + return report, nil +} diff --git a/components/crm/internal/services/resolve-alias_test.go b/components/crm/internal/services/resolve-alias_test.go new file mode 100644 index 000000000..8fea46820 --- /dev/null +++ b/components/crm/internal/services/resolve-alias_test.go @@ -0,0 +1,287 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package services + +import ( + "context" + "errors" + "testing" + + "github.com/LerianStudio/midaz/v3/components/crm/internal/adapters/mongodb/alias" + "github.com/LerianStudio/midaz/v3/components/crm/internal/adapters/mongodb/holder" + "github.com/LerianStudio/midaz/v3/pkg" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestResolveBankAccountReturnsDeterministicProofFields(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + holderRepo := holder.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo, HolderRepo: holderRepo} + + aliasID := uuid.New() + holderID := uuid.New() + document := "12345678901" + bankID := "12345678" + branch := "0001" + account := "001234567" + accountType := "CACC" + ledgerID := uuid.New().String() + accountID := uuid.New().String() + organizationID := uuid.New() + + input := &mmodel.ResolveBankAccountInput{ + Document: document, + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: bankID, + Branch: branch, + Account: account, + Type: accountType, + }, + } + + repo.EXPECT().ResolveBankAccount(gomock.Any(), input).Return([]*mmodel.Alias{{ + ID: &aliasID, + OrganizationID: &organizationID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + BankingDetails: &mmodel.BankingDetails{ + BankID: &bankID, + Branch: &branch, + Account: &account, + Type: &accountType, + }, + }}, nil) + + result, err := uc.ResolveBankAccount(context.Background(), input) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, aliasID, result.ID) + assert.Equal(t, organizationID, result.OrganizationID) + assert.Equal(t, uuid.MustParse(ledgerID), result.LedgerID) + assert.Equal(t, uuid.MustParse(accountID), result.AccountID) + assert.Equal(t, holderID, result.HolderID) + assert.Equal(t, document, result.HolderDocument) + assert.Equal(t, bankID, result.BankingDetails.BankID) + assert.Equal(t, branch, result.BankingDetails.Branch) + assert.Equal(t, account, result.BankingDetails.Account) + assert.Equal(t, accountType, result.BankingDetails.Type) +} + +func TestResolveBankAccountNoMatchReturnsAliasNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + + input := &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "12345678", Branch: "0001", Account: "1234567", Type: "CACC", + }, + } + + repo.EXPECT().ResolveBankAccount(gomock.Any(), input).Return(nil, nil) + + result, err := uc.ResolveBankAccount(context.Background(), input) + + require.Error(t, err) + assert.Nil(t, result) + var notFound pkg.EntityNotFoundError + require.True(t, errors.As(err, ¬Found)) + assert.Equal(t, cn.ErrAliasNotFound.Error(), notFound.Code) +} + +func TestResolveBankAccountDuplicateMatchesReturnConflict(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + input := &mmodel.ResolveBankAccountInput{ + Document: "12345678901", + BankingDetails: mmodel.ResolveBankAccountBankingDetailsInput{ + BankID: "12345678", Branch: "0001", Account: "1234567", Type: "CACC", + }, + } + repo.EXPECT().ResolveBankAccount(gomock.Any(), input).Return([]*mmodel.Alias{{ID: ptrUUID(uuid.New())}, {ID: ptrUUID(uuid.New())}}, nil) + + result, err := uc.ResolveBankAccount(context.Background(), input) + + require.Error(t, err) + assert.Nil(t, result) + var conflictErr pkg.EntityConflictError + assert.True(t, errors.As(err, &conflictErr)) +} + +func TestResolveAccountReturnsOrganizationID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + + aliasID := uuid.New() + holderID := uuid.New() + accountUUID := uuid.New() + organizationID := uuid.New() + ledgerID := uuid.New().String() + document := "12345678901" + bankID := "12345678" + branch := "1" + account := "1234567" + accountType := "CACC" + accountID := accountUUID.String() + + repo.EXPECT().ResolveAccount(gomock.Any(), accountUUID).Return([]*mmodel.Alias{{ + ID: &aliasID, + OrganizationID: &organizationID, + Document: &document, + LedgerID: &ledgerID, + AccountID: &accountID, + HolderID: &holderID, + BankingDetails: &mmodel.BankingDetails{BankID: &bankID, Branch: &branch, Account: &account, Type: &accountType}, + }}, nil) + + result, err := uc.ResolveAccount(context.Background(), &mmodel.ResolveAccountInput{AccountID: accountUUID}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, organizationID, result.OrganizationID) + assert.Equal(t, accountUUID, result.AccountID) +} + +func TestResolveAccountNoMatchReturnsAliasNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + accountID := uuid.New() + repo.EXPECT().ResolveAccount(gomock.Any(), accountID).Return(nil, nil) + + result, err := uc.ResolveAccount(context.Background(), &mmodel.ResolveAccountInput{AccountID: accountID}) + + require.Error(t, err) + assert.Nil(t, result) + var notFound pkg.EntityNotFoundError + assert.True(t, errors.As(err, ¬Found)) +} + +func TestResolveAccountDuplicateMatchesReturnConflict(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + accountID := uuid.New() + repo.EXPECT().ResolveAccount(gomock.Any(), accountID).Return([]*mmodel.Alias{{ID: ptrUUID(uuid.New())}, {ID: ptrUUID(uuid.New())}}, nil) + + result, err := uc.ResolveAccount(context.Background(), &mmodel.ResolveAccountInput{AccountID: accountID}) + + require.Error(t, err) + assert.Nil(t, result) + var conflictErr pkg.EntityConflictError + assert.True(t, errors.As(err, &conflictErr)) +} + +func TestResolveAccountRejectsZeroUUID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + + result, err := uc.ResolveAccount(context.Background(), &mmodel.ResolveAccountInput{AccountID: uuid.Nil}) + + require.Error(t, err) + assert.Nil(t, result) +} + +func TestResolveAccountRejectsNilInput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + + result, err := uc.ResolveAccount(context.Background(), nil) + + require.Error(t, err) + assert.Nil(t, result) +} + +func TestBackfillBankAccountIndexReturnsReport(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + expected := &mmodel.BankAccountIndexBackfillReport{DryRun: true, CollectionsScanned: 1} + repo.EXPECT().BackfillBankAccountIndex(gomock.Any(), true).Return(expected, nil) + + result, err := uc.BackfillBankAccountIndex(context.Background(), true) + + require.NoError(t, err) + assert.Equal(t, expected, result) +} + +func TestBackfillBankAccountIndexPropagatesError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + expectedErr := errors.New("backfill failed") + repo.EXPECT().BackfillBankAccountIndex(gomock.Any(), false).Return(nil, expectedErr) + + result, err := uc.BackfillBankAccountIndex(context.Background(), false) + + require.ErrorIs(t, err, expectedErr) + assert.Nil(t, result) +} + +func TestBackfillBankAccountIndexHonorsCanceledContext(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := uc.BackfillBankAccountIndex(ctx, false) + + require.ErrorIs(t, err, context.Canceled) + assert.Nil(t, result) +} + +func TestResolveBankAccountRejectsNilInput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := alias.NewMockRepository(ctrl) + uc := &UseCase{AliasRepo: repo} + + result, err := uc.ResolveBankAccount(context.Background(), nil) + + require.Error(t, err) + assert.Nil(t, result) +} + +func ptrUUID(id uuid.UUID) *uuid.UUID { + return &id +} diff --git a/components/crm/internal/services/update-alias.go b/components/crm/internal/services/update-alias.go index 78830f8eb..31afcd73c 100644 --- a/components/crm/internal/services/update-alias.go +++ b/components/crm/internal/services/update-alias.go @@ -32,6 +32,11 @@ func (uc *UseCase) UpdateAliasByID(ctx context.Context, organizationID string, h logger.Log(ctx, libLog.LevelInfo, fmt.Sprintf("Trying to update alias: %v", id.String())) + if err := validateBankingIdentityPatch(ctx, uai.BankingDetails, fieldsToRemove); err != nil { + libOpenTelemetry.HandleSpanBusinessErrorEvent(span, "Invalid banking identity", err) + return nil, err + } + if len(uai.RelatedParties) > 0 { err := uc.ValidateRelatedParties(ctx, uai.RelatedParties) if err != nil { diff --git a/components/crm/internal/services/update-alias_test.go b/components/crm/internal/services/update-alias_test.go index ef53c0c80..5771489e2 100644 --- a/components/crm/internal/services/update-alias_test.go +++ b/components/crm/internal/services/update-alias_test.go @@ -34,6 +34,9 @@ func TestUpdateAliasByID(t *testing.T) { ledgerID := uuid.Must(libCommons.GenerateUUIDv7()).String() holderDocument := "90217469051" branch := "0001" + bankID := "12345678" + account := "1234567" + bankingType := "CACC" participantDoc := "12345678912345" uc := &UseCase{ @@ -51,12 +54,15 @@ func TestUpdateAliasByID(t *testing.T) { expectedResult *mmodel.Alias }{ { - name: "Success with single field provided", + name: "Success with complete banking identity provided", id: id, holderID: holderID, input: &mmodel.UpdateAliasInput{ BankingDetails: &mmodel.BankingDetails{ - Branch: &branch, + BankID: &bankID, + Branch: &branch, + Account: &account, + Type: &bankingType, }, }, mockSetup: func() { @@ -69,7 +75,10 @@ func TestUpdateAliasByID(t *testing.T) { HolderID: &holderID, AccountID: &accountID, BankingDetails: &mmodel.BankingDetails{ - Branch: &branch, + BankID: &bankID, + Branch: &branch, + Account: &account, + Type: &bankingType, }, }, nil) }, @@ -81,7 +90,10 @@ func TestUpdateAliasByID(t *testing.T) { HolderID: &holderID, AccountID: &accountID, BankingDetails: &mmodel.BankingDetails{ - Branch: &branch, + BankID: &bankID, + Branch: &branch, + Account: &account, + Type: &bankingType, }, }, }, @@ -138,13 +150,31 @@ func TestUpdateAliasByID(t *testing.T) { }, }, }, + { + name: "Error when partial banking identity is provided", + id: id, + holderID: holderID, + input: &mmodel.UpdateAliasInput{ + BankingDetails: &mmodel.BankingDetails{ + BankID: &bankID, + Branch: &branch, + Account: &account, + }, + }, + mockSetup: func() {}, + expectedErr: cn.ErrMissingFieldsInRequest, + expectedResult: nil, + }, { name: "Error when alias not found by ID", id: id, holderID: holderID, input: &mmodel.UpdateAliasInput{ BankingDetails: &mmodel.BankingDetails{ - Branch: &branch, + BankID: &bankID, + Branch: &branch, + Account: &account, + Type: &bankingType, }, }, mockSetup: func() { @@ -255,7 +285,10 @@ func TestUpdateAliasByID(t *testing.T) { holderID: holderID, input: &mmodel.UpdateAliasInput{ BankingDetails: &mmodel.BankingDetails{ + BankID: &bankID, Branch: &branch, + Account: &account, + Type: &bankingType, ClosingDate: &mmodel.Date{Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, }, }, @@ -301,3 +334,28 @@ func TestUpdateAliasByID(t *testing.T) { }) } } + +func TestUpdateAliasByIDRejectsNestedBankingIdentityRemoval(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + uc := &UseCase{ + HolderRepo: holder.NewMockRepository(ctrl), + AliasRepo: alias.NewMockRepository(ctrl), + } + + result, err := uc.UpdateAliasByID( + context.Background(), + uuid.New().String(), + uuid.New(), + uuid.New(), + &mmodel.UpdateAliasInput{BankingDetails: &mmodel.BankingDetails{}}, + []string{"bankingDetails.type"}, + ) + + assert.Error(t, err) + assert.Nil(t, result) + var validationErr pkg.ValidationError + assert.True(t, errors.As(err, &validationErr)) + assert.Equal(t, cn.ErrMissingFieldsInRequest.Error(), validationErr.Code) +} diff --git a/components/crm/internal/services/validate-banking-details.go b/components/crm/internal/services/validate-banking-details.go new file mode 100644 index 000000000..235e5d13e --- /dev/null +++ b/components/crm/internal/services/validate-banking-details.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package services + +import ( + "context" + "strings" + + libCommons "github.com/LerianStudio/lib-commons/v5/commons" + libLog "github.com/LerianStudio/lib-commons/v5/commons/log" + "github.com/LerianStudio/midaz/v3/pkg" + cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/mmodel" +) + +func validateCompleteBankingIdentity(ctx context.Context, bankingDetails *mmodel.BankingDetails) error { + if bankingDetails == nil { + return nil + } + + hasAnyIdentityField := nonEmptyStringPtr(bankingDetails.BankID) || + nonEmptyStringPtr(bankingDetails.Branch) || + nonEmptyStringPtr(bankingDetails.Account) || + nonEmptyStringPtr(bankingDetails.Type) + if !hasAnyIdentityField { + return nil + } + + missingFields := make([]string, 0, 4) + if !nonEmptyStringPtr(bankingDetails.BankID) { + missingFields = append(missingFields, "bankingDetails.bankId") + } + + if !nonEmptyStringPtr(bankingDetails.Branch) { + missingFields = append(missingFields, "bankingDetails.branch") + } + + if !nonEmptyStringPtr(bankingDetails.Account) { + missingFields = append(missingFields, "bankingDetails.account") + } + + if !nonEmptyStringPtr(bankingDetails.Type) { + missingFields = append(missingFields, "bankingDetails.type") + } + + if len(missingFields) == 0 { + return nil + } + + return missingBankingIdentityFieldsError(ctx, missingFields) +} + +func validateBankingIdentityPatch(ctx context.Context, bankingDetails *mmodel.BankingDetails, fieldsToRemove []string) error { + if removesBankingIdentityField(fieldsToRemove) { + return missingBankingIdentityFieldsError(ctx, []string{"bankingDetails.bankId", "bankingDetails.branch", "bankingDetails.account", "bankingDetails.type"}) + } + + return validateCompleteBankingIdentity(ctx, bankingDetails) +} + +func missingBankingIdentityFieldsError(ctx context.Context, missingFields []string) error { + logger, tracer, _, _ := libCommons.NewTrackingFromContext(ctx) + _ = tracer + + err := pkg.ValidateBusinessError(cn.ErrMissingFieldsInRequest, cn.EntityAlias, strings.Join(missingFields, ", ")) + logger.Log(ctx, libLog.LevelWarn, "Incomplete banking identity", libLog.Err(err)) + + return err +} + +func removesBankingIdentityField(fieldsToRemove []string) bool { + for _, field := range fieldsToRemove { + switch strings.TrimSpace(field) { + case "bankingDetails.bankId", "bankingDetails.branch", "bankingDetails.account", "bankingDetails.type", + "banking_details.bank_id", "banking_details.branch", "banking_details.account", "banking_details.type": + return true + } + } + + return false +} + +func nonEmptyStringPtr(value *string) bool { + return value != nil && strings.TrimSpace(*value) != "" +} diff --git a/components/ledger/Dockerfile b/components/ledger/Dockerfile index c0aae5151..2901b47f0 100644 --- a/components/ledger/Dockerfile +++ b/components/ledger/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder WORKDIR /ledger-app @@ -26,4 +26,3 @@ USER nonroot:nonroot ENTRYPOINT ["/app"] - diff --git a/components/ledger/internal/adapters/http/in/transaction_overdraft_enrichment_test.go b/components/ledger/internal/adapters/http/in/transaction_overdraft_enrichment_test.go index 66fef5fac..1853a7eca 100644 --- a/components/ledger/internal/adapters/http/in/transaction_overdraft_enrichment_test.go +++ b/components/ledger/internal/adapters/http/in/transaction_overdraft_enrichment_test.go @@ -610,8 +610,10 @@ func TestEnrichOverdraftOperations_RefundCappedAtOverdraftUsed(t *testing.T) { // the handler continue. func TestRejectInternalScopeBalances_AllowsTransactionalBalances(t *testing.T) { transactional := []*mmodel.Balance{ - {Alias: "@alice", Key: constant.DefaultBalanceKey, - Settings: &mmodel.BalanceSettings{BalanceScope: mmodel.BalanceScopeTransactional}}, + { + Alias: "@alice", Key: constant.DefaultBalanceKey, + Settings: &mmodel.BalanceSettings{BalanceScope: mmodel.BalanceScopeTransactional}, + }, {Alias: "@bob", Key: constant.DefaultBalanceKey}, // nil balance entries can appear after failed lookups — the guard // must tolerate them without panicking or false-positiving. diff --git a/components/ledger/internal/adapters/postgres/organization/organization.postgresql_property_test.go b/components/ledger/internal/adapters/postgres/organization/organization.postgresql_property_test.go index 56257edee..f4fee1013 100644 --- a/components/ledger/internal/adapters/postgres/organization/organization.postgresql_property_test.go +++ b/components/ledger/internal/adapters/postgres/organization/organization.postgresql_property_test.go @@ -101,7 +101,6 @@ func TestProperty_GetDB_TenantConnectionReturned(t *testing.T) { ) db, err := repo.getDB(ctx) - // getDB must succeed and return the injected tenant DB. if err != nil { return false diff --git a/components/ledger/internal/adapters/postgres/transaction/transaction.postgresql_property_test.go b/components/ledger/internal/adapters/postgres/transaction/transaction.postgresql_property_test.go index 2b961c1b9..0621c14cf 100644 --- a/components/ledger/internal/adapters/postgres/transaction/transaction.postgresql_property_test.go +++ b/components/ledger/internal/adapters/postgres/transaction/transaction.postgresql_property_test.go @@ -105,7 +105,6 @@ func TestProperty_GetDB_TenantConnectionReturned(t *testing.T) { ) db, err := repo.getDB(ctx) - // getDB must succeed and return the injected tenant DB. if err != nil { return false diff --git a/components/ledger/internal/adapters/redis/transaction/consumer.redis_atomic_state_test.go b/components/ledger/internal/adapters/redis/transaction/consumer.redis_atomic_state_test.go index 5a8cd6b15..1453ecf0e 100644 --- a/components/ledger/internal/adapters/redis/transaction/consumer.redis_atomic_state_test.go +++ b/components/ledger/internal/adapters/redis/transaction/consumer.redis_atomic_state_test.go @@ -87,7 +87,6 @@ func TestKeyNamespacing_MalformedTenantID_FailsClosedBalanceSyncScripts(t *testi assert.Empty(t, scripter.evalShaCalls, "GetBalanceSyncKeys must fail closed before EVALSHA") assert.Empty(t, scripter.evalCalls, "GetBalanceSyncKeys must fail closed before EVAL") }) - } func TestKeyNamespacing_MalformedTenantID_FailsClosedGetBalancesByKeys(t *testing.T) { diff --git a/components/ledger/internal/adapters/redis/transaction/consumer.redis_settings_update_test.go b/components/ledger/internal/adapters/redis/transaction/consumer.redis_settings_update_test.go index 9f1143cb8..a93ab6fe2 100644 --- a/components/ledger/internal/adapters/redis/transaction/consumer.redis_settings_update_test.go +++ b/components/ledger/internal/adapters/redis/transaction/consumer.redis_settings_update_test.go @@ -343,19 +343,19 @@ func TestUpdateBalanceCacheSettings_WritesLuaCompatibleCasing(t *testing.T) { // state the Lua script would have written. The rewrite must converge // both to a single, Lua-native CamelCase key per field. legacy := map[string]any{ - "ID": "balance-id", - "Alias": "@alice", - "Key": "default", - "AccountID": "account-id", - "AssetCode": "USD", - "Available": "100", - "OnHold": "0", - "Version": 3, - "AccountType": "deposit", - "AllowSending": 1, - "AllowReceiving": 1, - "Direction": "credit", - "OverdraftUsed": "0", + "ID": "balance-id", + "Alias": "@alice", + "Key": "default", + "AccountID": "account-id", + "AssetCode": "USD", + "Available": "100", + "OnHold": "0", + "Version": 3, + "AccountType": "deposit", + "AllowSending": 1, + "AllowReceiving": 1, + "Direction": "credit", + "OverdraftUsed": "0", // Legacy camelCase keys from a pre-fix Go writer that must be dropped. "allowOverdraft": 0, "overdraftLimitEnabled": 0, diff --git a/components/ledger/internal/bootstrap/backward_compat_test.go b/components/ledger/internal/bootstrap/backward_compat_test.go index a65b6ad6f..3e0c00d89 100644 --- a/components/ledger/internal/bootstrap/backward_compat_test.go +++ b/components/ledger/internal/bootstrap/backward_compat_test.go @@ -78,17 +78,17 @@ func TestMultiTenant_BackwardCompatibility(t *testing.T) { // This ensures backward compat: all fields must be optional (no envDefault // that forces multi-tenant on). expectedFields := map[string]string{ - "MultiTenantEnabled": "MULTI_TENANT_ENABLED", - "MultiTenantURL": "MULTI_TENANT_URL", - "MultiTenantCircuitBreakerThreshold": "MULTI_TENANT_CIRCUIT_BREAKER_THRESHOLD", - "MultiTenantCircuitBreakerTimeoutSec": "MULTI_TENANT_CIRCUIT_BREAKER_TIMEOUT_SEC", - "MultiTenantServiceAPIKey": "MULTI_TENANT_SERVICE_API_KEY", + "MultiTenantEnabled": "MULTI_TENANT_ENABLED", + "MultiTenantURL": "MULTI_TENANT_URL", + "MultiTenantCircuitBreakerThreshold": "MULTI_TENANT_CIRCUIT_BREAKER_THRESHOLD", + "MultiTenantCircuitBreakerTimeoutSec": "MULTI_TENANT_CIRCUIT_BREAKER_TIMEOUT_SEC", + "MultiTenantServiceAPIKey": "MULTI_TENANT_SERVICE_API_KEY", "MultiTenantConnectionsCheckIntervalSec": "MULTI_TENANT_CONNECTIONS_CHECK_INTERVAL_SEC", - "MultiTenantCacheTTLSec": "MULTI_TENANT_CACHE_TTL_SEC", - "MultiTenantRedisHost": "MULTI_TENANT_REDIS_HOST", - "MultiTenantRedisPort": "MULTI_TENANT_REDIS_PORT", - "MultiTenantRedisPassword": "MULTI_TENANT_REDIS_PASSWORD", - "MultiTenantRedisTLS": "MULTI_TENANT_REDIS_TLS", + "MultiTenantCacheTTLSec": "MULTI_TENANT_CACHE_TTL_SEC", + "MultiTenantRedisHost": "MULTI_TENANT_REDIS_HOST", + "MultiTenantRedisPort": "MULTI_TENANT_REDIS_PORT", + "MultiTenantRedisPassword": "MULTI_TENANT_REDIS_PASSWORD", + "MultiTenantRedisTLS": "MULTI_TENANT_REDIS_TLS", } cfg := &Config{} diff --git a/components/ledger/internal/bootstrap/config.rabbitmq.go b/components/ledger/internal/bootstrap/config.rabbitmq.go index c5f73bd08..f99f303c3 100644 --- a/components/ledger/internal/bootstrap/config.rabbitmq.go +++ b/components/ledger/internal/bootstrap/config.rabbitmq.go @@ -77,10 +77,10 @@ type rabbitMQComponents struct { multiQueueConsumer *MultiQueueConsumer multiTenantConsumer *tmconsumer.MultiTenantConsumer circuitBreakerManager *CircuitBreakerManager - pgManager *tmpostgres.Manager // nil in single-tenant mode; used by consumer handler for per-tenant PG resolution - mongoManager *tmmongo.Manager // nil in single-tenant mode; used by consumer handler for per-tenant Mongo resolution - rabbitmqManager *tmrabbitmq.Manager // nil in single-tenant mode; used by event dispatcher to close tenant RabbitMQ connections - metricsFactory *metrics.MetricsFactory // nil in single-tenant mode or when telemetry disabled; used for tenant metrics emission + pgManager *tmpostgres.Manager // nil in single-tenant mode; used by consumer handler for per-tenant PG resolution + mongoManager *tmmongo.Manager // nil in single-tenant mode; used by consumer handler for per-tenant Mongo resolution + rabbitmqManager *tmrabbitmq.Manager // nil in single-tenant mode; used by event dispatcher to close tenant RabbitMQ connections + metricsFactory *metrics.MetricsFactory // nil in single-tenant mode or when telemetry disabled; used for tenant metrics emission // wireConsumer is a callback that wires the consumer with the UseCase. // Must be called after UseCase creation because the handler needs UseCase. diff --git a/pkg/constant/entity.go b/pkg/constant/entity.go index d7372be7c..c04017e52 100644 --- a/pkg/constant/entity.go +++ b/pkg/constant/entity.go @@ -10,6 +10,7 @@ const ( EntityAccount = "Account" EntityAccountRule = "AccountRule" EntityAccountType = "AccountType" + EntityAlias = "Alias" EntityAsset = "Asset" EntityAssetRate = "AssetRate" EntityBalance = "Balance" diff --git a/pkg/mmodel/alias.go b/pkg/mmodel/alias.go index a03023ea9..7b0b9c643 100644 --- a/pkg/mmodel/alias.go +++ b/pkg/mmodel/alias.go @@ -78,6 +78,7 @@ type UpdateAliasInput struct { // @Description AliasResponse payload type Alias struct { ID *uuid.UUID `json:"id,omitempty" example:"00000000-0000-0000-0000-000000000000"` + OrganizationID *uuid.UUID `json:"organizationId,omitempty" example:"00000000-0000-0000-0000-000000000000"` Document *string `json:"document,omitempty" example:"91315026015"` Type *string `json:"type,omitempty" example:"LEGAL_PERSON"` LedgerID *string `json:"ledgerId" example:"00000000-0000-0000-0000-000000000000"` @@ -92,6 +93,81 @@ type Alias struct { DeletedAt *time.Time `json:"deletedAt" example:"2025-01-01T00:00:00Z"` } // @name AliasResponse +// ResolveBankAccountInput is the tenant-wide alias resolver request payload. +// +// swagger:model ResolveBankAccountInput +// @Description ResolveBankAccountRequest payload +type ResolveBankAccountInput struct { + Document string `json:"document" validate:"required" example:"12345678901"` + BankingDetails ResolveBankAccountBankingDetailsInput `json:"bankingDetails" validate:"required"` +} // @name ResolveBankAccountRequest + +// ResolveBankAccountBankingDetailsInput contains exact bank-account identity fields. +// +// swagger:model ResolveBankAccountBankingDetailsInput +// @Description ResolveBankAccountBankingDetails object +type ResolveBankAccountBankingDetailsInput struct { + BankID string `json:"bankId" validate:"required" example:"12345678"` + Branch string `json:"branch" validate:"required" example:"0001"` + Account string `json:"account" validate:"required" example:"1234567"` + Type string `json:"type" validate:"required" example:"CACC"` +} // @name ResolveBankAccountBankingDetailsRequest + +// ResolveAccountInput is the tenant-wide account ID resolver request payload. +// +// swagger:model ResolveAccountInput +// @Description ResolveAccountRequest payload +type ResolveAccountInput struct { + AccountID uuid.UUID `json:"accountId" validate:"required" example:"00000000-0000-0000-0000-000000000000"` +} // @name ResolveAccountRequest + +// ResolveAliasResponse is the minimal deterministic alias resolver response. +// +// swagger:model ResolveAliasResponse +// @Description ResolveAliasResponse payload +type ResolveAliasResponse struct { + ID uuid.UUID `json:"id" example:"00000000-0000-0000-0000-000000000000"` + OrganizationID uuid.UUID `json:"organizationId" example:"00000000-0000-0000-0000-000000000000"` + LedgerID uuid.UUID `json:"ledgerId" example:"00000000-0000-0000-0000-000000000000"` + AccountID uuid.UUID `json:"accountId" example:"00000000-0000-0000-0000-000000000000"` + HolderID uuid.UUID `json:"holderId" example:"00000000-0000-0000-0000-000000000000"` + HolderDocument string `json:"holderDocument" example:"12345678901"` + BankingDetails ResolveAliasBankingDetailsResponse `json:"bankingDetails"` +} // @name ResolveAliasResponse + +// ResolveAliasBankingDetailsResponse contains proof fields returned by alias resolvers. +// +// swagger:model ResolveAliasBankingDetailsResponse +// @Description ResolveAliasBankingDetailsResponse object +type ResolveAliasBankingDetailsResponse struct { + BankID string `json:"bankId" example:"12345678"` + Branch string `json:"branch" example:"0001"` + Account string `json:"account" example:"1234567"` + Type string `json:"type" example:"CACC"` +} // @name ResolveAliasBankingDetailsResponse + +// BankAccountIndexBackfillReport summarizes a resolver-index repair run without PII. +type BankAccountIndexBackfillReport struct { + DryRun bool `json:"dryRun"` + CollectionsScanned int `json:"collectionsScanned"` + AliasesScanned int `json:"aliasesScanned"` + Upserted int `json:"upserted"` + Incomplete int `json:"incomplete"` + Duplicates int `json:"duplicates"` + IncompleteAliasIDs []uuid.UUID `json:"incompleteAliasIds,omitempty"` + DuplicateAliasIDs []uuid.UUID `json:"duplicateAliasIds,omitempty"` + IncompleteAliasIDsTruncated bool `json:"incompleteAliasIdsTruncated,omitempty"` + DuplicateAliasIDsTruncated bool `json:"duplicateAliasIdsTruncated,omitempty"` +} + +// BackfillBankAccountIndexInput is the resolver-index repair request payload. +// +// swagger:model BackfillBankAccountIndexInput +// @Description BackfillBankAccountIndexRequest payload +type BackfillBankAccountIndexInput struct { + DryRun bool `json:"dryRun" example:"true"` +} // @name BackfillBankAccountIndexRequest + // BankingDetails is a struct designed to store account banking details data. // // swagger:model BankingDetails diff --git a/pkg/net/http/httputils.go b/pkg/net/http/httputils.go index fe2fc9117..d7b2c247c 100644 --- a/pkg/net/http/httputils.go +++ b/pkg/net/http/httputils.go @@ -52,6 +52,8 @@ type QueryHeader struct { BankingDetailsBranch *string BankingDetailsAccount *string BankingDetailsIban *string + BankingDetailsBankID *string + BankingDetailsType *string EntityName *string RegulatoryFieldsParticipantDocument *string RelatedPartyDocument *string @@ -130,6 +132,8 @@ func ValidateParameters(params map[string]string) (*QueryHeader, error) { bankingDetailsBranch *string bankingDetailsAccount *string bankingDetailsIban *string + bankingDetailsBankID *string + bankingDetailsType *string entityName *string regulatoryFieldsParticipantDocument *string relatedPartyDocument *string @@ -189,11 +193,6 @@ func ValidateParameters(params map[string]string) (*QueryHeader, error) { portfolioID = value case strings.Contains(key, "segment_id"): segmentID = value - case strings.Contains(strings.ToLower(key), "type"): - operationType = strings.ToUpper(value) - // Also populate Type field for account filtering (lowercase to match DB normalization) - lowercaseType := strings.ToLower(value) - filterType = &lowercaseType case key == "direction": v := strings.ToLower(value) direction = &v @@ -221,6 +220,15 @@ func ValidateParameters(params map[string]string) (*QueryHeader, error) { bankingDetailsAccount = &value case strings.Contains(key, "banking_details_iban"): bankingDetailsIban = &value + case strings.Contains(key, "banking_details_bank_id"): + bankingDetailsBankID = &value + case strings.Contains(key, "banking_details_type"): + bankingDetailsType = &value + case strings.Contains(strings.ToLower(key), "type"): + operationType = strings.ToUpper(value) + // Also populate Type field for account filtering (lowercase to match DB normalization) + lowercaseType := strings.ToLower(value) + filterType = &lowercaseType case strings.Contains(key, "entity_name"): entityName = &value case strings.Contains(key, "regulatory_fields_participant_document"): @@ -340,6 +348,8 @@ func ValidateParameters(params map[string]string) (*QueryHeader, error) { BankingDetailsBranch: bankingDetailsBranch, BankingDetailsAccount: bankingDetailsAccount, BankingDetailsIban: bankingDetailsIban, + BankingDetailsBankID: bankingDetailsBankID, + BankingDetailsType: bankingDetailsType, EntityName: entityName, RegulatoryFieldsParticipantDocument: regulatoryFieldsParticipantDocument, RelatedPartyDocument: relatedPartyDocument, diff --git a/pkg/net/http/httputils_alias_bank_filters_test.go b/pkg/net/http/httputils_alias_bank_filters_test.go new file mode 100644 index 000000000..8f5218308 --- /dev/null +++ b/pkg/net/http/httputils_alias_bank_filters_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Lerian Studio. All rights reserved. +// Use of this source code is governed by the Elastic License 2.0 +// that can be found in the LICENSE file. + +package http + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateParameters_AcceptsAliasBankingDetailsBankIDAndType(t *testing.T) { + t.Parallel() + + query, err := ValidateParameters(map[string]string{ + "banking_details_bank_id": "12345678", + "banking_details_type": "CACC", + }) + + require.NoError(t, err) + require.NotNil(t, query.BankingDetailsBankID) + require.NotNil(t, query.BankingDetailsType) + assert.Equal(t, "12345678", *query.BankingDetailsBankID) + assert.Equal(t, "CACC", *query.BankingDetailsType) +} diff --git a/tests/utils/mongodb/container.go b/tests/utils/mongodb/container.go index 8baa65ce9..98f81509f 100644 --- a/tests/utils/mongodb/container.go +++ b/tests/utils/mongodb/container.go @@ -9,6 +9,8 @@ package mongodb import ( "context" "fmt" + "io" + "strings" "testing" "time" @@ -73,6 +75,7 @@ func SetupContainerWithConfig(tb testing.TB, cfg ContainerConfig) *ContainerResu req := testcontainers.ContainerRequest{ Image: cfg.Image, + Cmd: []string{"--replSet", "rs0", "--bind_ip_all"}, ExposedPorts: []string{"27017/tcp"}, WaitingFor: wait.ForAll( wait.ForLog("Waiting for connections"), @@ -91,7 +94,8 @@ func SetupContainerWithConfig(tb testing.TB, cfg ContainerConfig) *ContainerResu port, err := ctr.MappedPort(ctx, "27017") require.NoError(tb, err, "failed to get MongoDB container port") - uri := fmt.Sprintf("mongodb://%s:%s", host, port.Port()) + initializeReplicaSet(tb, ctx, ctr) + uri := fmt.Sprintf("mongodb://%s:%s/?directConnection=true&replicaSet=rs0", host, port.Port()) clientOpts := options.Client().ApplyURI(uri) client, err := mongo.Connect(ctx, clientOpts) @@ -120,6 +124,34 @@ func SetupContainerWithConfig(tb testing.TB, cfg ContainerConfig) *ContainerResu } } +func initializeReplicaSet(tb testing.TB, ctx context.Context, ctr testcontainers.Container) { + tb.Helper() + + initCommand := `rs.initiate({_id:"rs0",members:[{_id:0,host:"127.0.0.1:27017"}]})` + + _, reader, err := ctr.Exec(ctx, []string{"mongosh", "--quiet", "--eval", initCommand}) + require.NoError(tb, err, "failed to initiate MongoDB replica set") + + output := new(strings.Builder) + _, _ = io.Copy(output, reader) + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + _, reader, err = ctr.Exec(ctx, []string{"mongosh", "--quiet", "--eval", `db.hello().isWritablePrimary`}) + if err == nil { + status := new(strings.Builder) + _, _ = io.Copy(status, reader) + if strings.Contains(status.String(), "true") { + return + } + } + + time.Sleep(500 * time.Millisecond) + } + + require.Failf(tb, "MongoDB replica set did not become primary", "init output: %s", output.String()) +} + func startMongoContainerWithRetry(tb testing.TB, ctx context.Context, req testcontainers.ContainerRequest, failureMessage string) testcontainers.Container { tb.Helper()