Skip to content

feat(contact): optimize contact import and pagination performance#40

Merged
DavidsonGomes merged 2 commits into
evolution-foundation:developfrom
lpcoutinho:feat/optimize-contact-import
May 5, 2026
Merged

feat(contact): optimize contact import and pagination performance#40
DavidsonGomes merged 2 commits into
evolution-foundation:developfrom
lpcoutinho:feat/optimize-contact-import

Conversation

@lpcoutinho
Copy link
Copy Markdown
Contributor

@lpcoutinho lpcoutinho commented May 4, 2026

🎯 Objetivo

Otimizar o processo de importação de contatos e melhorar a performance da listagem de contatos com mais de 1000 registros.

✅ Alterações Realizadas

1. Sanitização de CPF/CNPJ na Importação

  • Adicionado método privado sanitize_tax_id no ContactManager
  • CPF/CNPJ formatados agora são salvos apenas com números
  • Sanitização aplicada nos métodos find_contact_by_tax_id e update_contact_attributes

2. Otimização de Performance

  • Nova migração com índices para contact_inboxes e contatos
  • Query resolved_contacts otimizada com LEFT JOIN
  • Cache de contagem no controller (1 minuto)

3. Correções

  • Ajuste no format_phone_number para manter prefixo +
  • Melhoria no tratamento de contatos sem identificação

🧪 Testes

  • Importação de CSV com CPF/CNPJ formatados
  • Verificação de que o tax_id é armazenado apenas com números
  • Teste de carregamento com 1000+ registros

Summary by Sourcery

Optimize contact import, enrichment, and listing performance while integrating role-based admin notifications.

New Features:

  • Support importing separate person and company contacts with linkage between people and companies from CSV, including extended profile fields and custom attributes.
  • Introduce tax ID–based contact lookup and storage of sanitized CPF/CNPJ values for contacts.
  • Add Role and UserRole models to consume roles synced from evo-auth-service and expose role relationships on users.

Bug Fixes:

  • Ensure phone numbers are normalized while preserving the international prefix when formatting imported contacts.

Enhancements:

  • Refine contact attribute mapping during import, including richer name construction, location, social profiles, and descriptions, and treat unknown CSV columns as custom attributes.
  • Add detailed logging and error handling around data import processing and CSV failures.
  • Switch admin notification recipient selection to use role-based administrator detection instead of a single role field on users.
  • Update contact resolution logic to use a LEFT JOIN with contact_inboxes for better performance and correctness on resolved contacts.
  • Cache contact list counts in the contacts API to avoid repeated expensive COUNT queries on large datasets.
  • Ensure Rails containers install missing gems and run database preparation automatically on startup.

Build:

  • Add database indexes on contact_inboxes.contact_id and contacts identity/name fields to speed up resolved contact queries and listing operations.

lpcoutinho added 2 commits May 4, 2026 08:31
- Add support for person/company type differentiation
- Add processing for social profiles (linkedin, facebook, instagram, twitter, github)
- Add location attributes (city, country, country_code)
- Add custom attributes auto-mapping for unknown fields
- Add empresas_vinculadas field for linking persons to companies
- Import companies first, then persons, then process linkages
- Updated CSV sample with all 19 supported fields
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 4, 2026

Reviewer's Guide

Refactors the contact CSV import pipeline to classify and import companies and persons separately with richer field mapping and logging, introduces tax ID sanitization and improved phone formatting in ContactManager, adds database indexes and a LEFT JOIN-based scope to speed up resolved contacts listing, caches contact count in the contacts API, and introduces roles/user_roles models to identify admin users via evo-auth roles for admin mailers, along with Docker entrypoint and schema/migration adjustments.

Sequence diagram for the optimized contact CSV import process

sequenceDiagram
    actor Admin
    participant DataImportJob
    participant ContactManager
    participant Contact
    participant Mailer as AdministratorNotifications_AccountNotificationMailer

    Admin->>DataImportJob: enqueue(data_import)
    DataImportJob->>DataImportJob: perform(data_import)
    DataImportJob->>DataImportJob: process_import_file
    DataImportJob->>DataImportJob: parse_csv_and_classify_contacts
    loop each CSV row
        DataImportJob->>ContactManager: build_contact(params)
        ContactManager-->>DataImportJob: contact
        alt tipo == company
            DataImportJob->>DataImportJob: classify as company_rows
        else tipo == person
            DataImportJob->>DataImportJob: classify as person_rows
        end
    end

    DataImportJob->>DataImportJob: import_companies(company_rows)
    DataImportJob->>Contact: import(companies)
    Contact-->>DataImportJob: import_result_companies

    DataImportJob->>DataImportJob: import_persons(person_rows)
    DataImportJob->>Contact: import(persons)
    Contact-->>DataImportJob: import_result_persons

    DataImportJob->>DataImportJob: process_company_linkages(person_rows)
    loop each person with empresas_vinculadas
        DataImportJob->>Contact: companies.find_by(name, type company)
        alt company exists
            DataImportJob->>Contact: contact.add_company(company)
        else company missing
            DataImportJob->>Contact: create!(type company, name)
            Contact-->>DataImportJob: new_company
            DataImportJob->>Contact: contact.add_company(new_company)
        end
    end

    DataImportJob->>DataImportJob: update_data_import_status
    DataImportJob->>DataImportJob: save_failed_records_csv

    alt success
        DataImportJob->>Mailer: contact_import_complete(data_import)
        Mailer-->>Admin: completion email
    else CSV::MalformedCSVError
        DataImportJob->>DataImportJob: handle_csv_error
        DataImportJob->>Mailer: contact_import_failed
        Mailer-->>Admin: failure email
    end
Loading

Entity relationship diagram for users, roles, and user_roles

erDiagram
    users {
      uuid id
      varchar email
    }

    roles {
      uuid id
      varchar key
      varchar name
    }

    user_roles {
      uuid id
      uuid user_id
      uuid role_id
      uuid granted_by_id
    }

    users ||--o{ user_roles : has_many
    roles ||--o{ user_roles : has_many
    users ||--o{ user_roles : grants
Loading

Class diagram for ContactManager and contact import mapping

classDiagram
    class DataImport_ContactManager {
      +find_or_initialize_contact(params)
      +find_existing_contact(params)
      +build_contact(params)
      +find_contact_by_identifier(params)
      +find_contact_by_email(params)
      +find_contact_by_phone_number(params)
      +find_contact_by_tax_id(params)
      +format_phone_number(phone_number)
      +sanitize_tax_id(tax_id)
      +update_contact_with_merged_attributes(params, contact)
      -update_contact_attributes(params, contact)
      -build_name(params)
      -process_location_attributes(params, contact)
      -process_social_attributes(params, contact)
      -process_description(params, contact)
      -process_custom_attributes(params, contact)
    }

    class Contact {
      +String name
      +String last_name
      +String email
      +String phone_number
      +String tax_id
      +String type
      +String website
      +String industry
      +String country_code
      +Hash additional_attributes
      +Hash custom_attributes
      +add_company(company)
      +self.import(records)
      +self.companies()
      +self.resolved_contacts()
    }

    DataImport_ContactManager ..> Contact : builds_and_updates

    class DataImportJob {
      +perform(data_import)
      -process_import_file()
      -parse_csv_and_classify_contacts()
      -append_rejected_contact(row, contact, rejected_contacts)
      -import_contacts(contacts)
      -import_companies(company_rows)
      -import_persons(person_rows)
      -process_company_linkages(person_rows)
      -update_data_import_status(processed_records, rejected_records)
      -save_failed_records_csv(rejected_contacts)
      -handle_csv_error(error)
      -send_import_notification_to_admin()
      -send_import_failed_notification_to_admin()
    }

    DataImportJob ..> DataImport_ContactManager : uses
    DataImportJob ..> Contact : imports
Loading

Class diagram for User, Role, and UserRole relationships

classDiagram
    class User {
      +UUID id
      +String email
      +has_many user_roles
      +has_many roles
    }

    class Role {
      +UUID id
      +String key
      +String name
      +has_many user_roles
      +has_many users
      +administrator?()
      +self.administrator_role()
      +self.administrator_users()
    }

    class UserRole {
      +UUID id
      +UUID user_id
      +UUID role_id
      +UUID granted_by_id
      +belongs_to user
      +belongs_to role
      +belongs_to granted_by
    }

    User "1" -- "*" UserRole : has_many
    Role "1" -- "*" UserRole : has_many
    UserRole "*" -- "1" User : user
    UserRole "*" -- "1" Role : role
    UserRole "*" -- "0..1" User : granted_by

    class AdministratorNotifications_BaseMailer {
      -admin_emails()
      -settings_url(section)
    }

    AdministratorNotifications_BaseMailer ..> User : queries
    AdministratorNotifications_BaseMailer ..> Role : filters_by_role_key
Loading

File-Level Changes

Change Details Files
Refactor CSV contact import to classify person/company rows, import in stages, and handle company linkages with extensive logging and error reporting.
  • Replace flat parse_csv_and_build_contacts with parse_csv_and_classify_contacts that splits rows into person_rows, company_rows, and rejected_contacts while logging per-row details.
  • Introduce import_companies and import_persons that bulk import each type separately using Contact.import, logging results and failed instances.
  • Add process_company_linkages to link persons to companies (existing or newly created) based on empresas_vinculadas field after imports.
  • Enhance DataImportJob logging throughout perform, process_import_file, append_rejected_contact, update_data_import_status, save_failed_records_csv, and error/notification handlers to trace import lifecycle and failures.
app/jobs/data_import_job.rb
Extend ContactManager to normalize tax IDs and phone numbers and enrich contact attribute mapping from CSV into core, additional, and custom attributes.
  • Add sanitize_tax_id and use it in find_contact_by_tax_id and update_contact_attributes to strip non-digits from CPF/CNPJ-style identifiers before persistence and lookup.
  • Update find_or_initialize_contact and update_contact_attributes to set contact type (person/company) from params[:tipo]/:type and build names appropriately via build_name.
  • Expand update_contact_attributes into structured helpers (process_location_attributes, process_social_attributes, process_description, process_custom_attributes) to map known CSV columns into additional_attributes/custom_attributes while keeping a clear whitelist of non-custom fields.
  • Adjust format_phone_number to preserve a leading '+' while stripping non-digits and guard against nil input.
app/services/data_import/contact_manager.rb
Introduce DB-level optimizations and scope changes to speed up resolved contact queries and avoid expensive counts on large contact datasets.
  • Add migration OptimizeContactsPerformance to create partial indexes on contact_inboxes(contact_id) and contacts(id,name,type) for contacts with identity (email/phone/identifier), and wire them into schema.
  • Change Contact.resolved_contacts to use a LEFT JOIN with contact_inboxes and a where condition on contact_inboxes.id instead of an IN subquery.
  • Enable cached contact counts in Api::V1::ContactsController#index using Rails.cache keyed by filters via cache_key_for_contacts_count with 1-minute expiry.
db/migrate/20241020000100_optimize_contacts_performance.rb
db/schema.rb
app/models/contact.rb
app/controllers/api/v1/contacts_controller.rb
Align admin email resolution and user-role modeling with evo-auth-service, introducing Role and UserRole models and updating admin mailer queries.
  • Add Role and UserRole models as read-only reference models mapped to roles and user_roles tables, with associations between users and roles and validations on keys/names.
  • Extend User model with has_many :user_roles and has_many :roles through user_roles, with dependent: :destroy_async on user_roles.
  • Change AdministratorNotifications::BaseMailer#admin_emails to derive admin recipients via User.joins(:roles).where(roles: { key: %w[account_owner administrator admin] }).
  • Update schema foreign keys and indexes consistent with new roles/user_roles usage (e.g., removing some obsolete FKs/indices and introducing evo_core_community_schema_migrations).
app/models/role.rb
app/models/user_role.rb
app/models/user.rb
app/mailers/administrator_notifications/base_mailer.rb
db/schema.rb
Improve Docker Rails entrypoint to ensure dependencies and DB are ready before app start.
  • Replace unconditional bundle install loop with bundle check

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • The new format_phone_number implementation strips all non-digits before checking start_with?('+'), so an existing leading + will never be preserved and all formatted numbers will lose the plus sign; consider preserving a leading + before removing other non-digit characters.
  • Role.administrator_role uses find_by(key: %w[account_owner administrator admin]), which will look for a single row whose key equals the entire array; if you intend to match any of those keys, switch to where(key: %w[account_owner administrator admin]).first or a similar approach.
  • The schema.rb changes include removals/additions for several unrelated tables and foreign keys (e.g., events, sessions, user_tours); it would be good to verify schema was generated from a clean database so only the intended contact-related index changes are committed.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `format_phone_number` implementation strips all non-digits before checking `start_with?('+')`, so an existing leading `+` will never be preserved and all formatted numbers will lose the plus sign; consider preserving a leading `+` before removing other non-digit characters.
- `Role.administrator_role` uses `find_by(key: %w[account_owner administrator admin])`, which will look for a single row whose `key` equals the entire array; if you intend to match any of those keys, switch to `where(key: %w[account_owner administrator admin]).first` or a similar approach.
- The schema.rb changes include removals/additions for several unrelated tables and foreign keys (e.g., events, sessions, user_tours); it would be good to verify schema was generated from a clean database so only the intended contact-related index changes are committed.

## Individual Comments

### Comment 1
<location path="app/services/data_import/contact_manager.rb" line_range="55-59" />
<code_context>
+    Contact.find_by(tax_id: tax_id)
+  end
+
   def format_phone_number(phone_number)
-    phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
+    return unless phone_number.present?
+
+    cleaned = phone_number.to_s.gsub(/\D/, '')
+    cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
+  end
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Phone normalization logic strips non-digits and then checks for '+', which can never be present.

Because `gsub(/�5CD/, '')` removes `+`, `cleaned.start_with?('+')` is always false, so `+` will always be prepended, even when the original already had a country code. If you want `+<digits>` as the normalized form, either check the original value before stripping, or always prepend `+` after stripping and rely on callers to expect that format. For example:
```ruby
cleaned = phone_number.to_s.gsub(/\D/, '')
"+#{cleaned}" if cleaned.present?
```
</issue_to_address>

### Comment 2
<location path="app/models/contact.rb" line_range="199-201" />
<code_context>
-    where(
-      "contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '' OR contacts.id IN (SELECT DISTINCT contact_id FROM contact_inboxes WHERE contact_id IS NOT NULL)"
+    # This uses LEFT JOIN for better performance
+    joins(
+      "LEFT JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id"
+    ).where(
+      "contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '' OR contact_inboxes.id IS NOT NULL"
     )
</code_context>
<issue_to_address>
**issue (bug_risk):** Using a LEFT JOIN with a non-null condition on contact_inboxes may introduce duplicate contacts and unexpected pagination behavior.

This change alters the behavior of `resolved_contacts`: the LEFT JOIN plus `WHERE contact_inboxes.id IS NOT NULL` will now return one row per `contact_inboxes` record, so contacts with multiple inboxes will appear multiple times unless the caller uses `DISTINCT`. If callers expect one row per contact (for pagination, counts, etc.), this can introduce subtle bugs. Consider preserving the previous “one row per contact” behavior by adding `.distinct` or otherwise de-duplicating, e.g.:

```ruby
joins("LEFT JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id")
  .where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '' OR contact_inboxes.id IS NOT NULL")
  .distinct
```
</issue_to_address>

### Comment 3
<location path="app/mailers/administrator_notifications/base_mailer.rb" line_range="25" />
<code_context>

   def admin_emails
-    User.where(role: 'administrator').pluck(:email)
+    User.joins(:roles).where(roles: { key: %w[account_owner administrator admin] }).pluck(:email)
   end

</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using joins on roles can return duplicate admin emails when a user has multiple admin roles.

This could lead to users receiving the same email multiple times if they hold more than one admin-type role. To prevent that, add `distinct` before plucking:

```ruby
User.joins(:roles)
    .where(roles: { key: %w[account_owner administrator admin] })
    .distinct
    .pluck(:email)
```
</issue_to_address>

### Comment 4
<location path="app/jobs/data_import_job.rb" line_range="100" />
<code_context>
   def append_rejected_contact(row, contact, rejected_contacts)
     row['errors'] = contact.errors.full_messages.join(', ')
     rejected_contacts << row
+    Rails.logger.debug "📊 DataImportJob: Rejected contact - name: #{row[:nome] || row[:name]}, errors: #{row['errors']}"
   end

</code_context>
<issue_to_address>
**nitpick (bug_risk):** Mixed symbol and string access on CSV row may always log a blank name.

Because `row` comes from CSV data it will typically have string keys. Here you’re assigning `row['errors']` but reading the name via symbol keys (`row[:nome] || row[:name]`), so the logged name will likely be `nil` even when present.

Consider normalizing the keys before use (e.g. `row = row.to_h.with_indifferent_access`) or consistently using string keys:
```ruby
name = row['nome'] || row['name']
Rails.logger.debug "📊 DataImportJob: Rejected contact - name: #{name}, errors: #{row['errors']}"
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 55 to +59
def format_phone_number(phone_number)
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
return unless phone_number.present?

cleaned = phone_number.to_s.gsub(/\D/, '')
cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Phone normalization logic strips non-digits and then checks for '+', which can never be present.

Because gsub(/�5CD/, '') removes +, cleaned.start_with?('+') is always false, so + will always be prepended, even when the original already had a country code. If you want +<digits> as the normalized form, either check the original value before stripping, or always prepend + after stripping and rely on callers to expect that format. For example:

cleaned = phone_number.to_s.gsub(/\D/, '')
"+#{cleaned}" if cleaned.present?

Comment thread app/models/contact.rb
Comment on lines +199 to +201
joins(
"LEFT JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id"
).where(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Using a LEFT JOIN with a non-null condition on contact_inboxes may introduce duplicate contacts and unexpected pagination behavior.

This change alters the behavior of resolved_contacts: the LEFT JOIN plus WHERE contact_inboxes.id IS NOT NULL will now return one row per contact_inboxes record, so contacts with multiple inboxes will appear multiple times unless the caller uses DISTINCT. If callers expect one row per contact (for pagination, counts, etc.), this can introduce subtle bugs. Consider preserving the previous “one row per contact” behavior by adding .distinct or otherwise de-duplicating, e.g.:

joins("LEFT JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id")
  .where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '' OR contact_inboxes.id IS NOT NULL")
  .distinct


def admin_emails
User.where(role: 'administrator').pluck(:email)
User.joins(:roles).where(roles: { key: %w[account_owner administrator admin] }).pluck(:email)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Using joins on roles can return duplicate admin emails when a user has multiple admin roles.

This could lead to users receiving the same email multiple times if they hold more than one admin-type role. To prevent that, add distinct before plucking:

User.joins(:roles)
    .where(roles: { key: %w[account_owner administrator admin] })
    .distinct
    .pluck(:email)

def append_rejected_contact(row, contact, rejected_contacts)
row['errors'] = contact.errors.full_messages.join(', ')
rejected_contacts << row
Rails.logger.debug "📊 DataImportJob: Rejected contact - name: #{row[:nome] || row[:name]}, errors: #{row['errors']}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (bug_risk): Mixed symbol and string access on CSV row may always log a blank name.

Because row comes from CSV data it will typically have string keys. Here you’re assigning row['errors'] but reading the name via symbol keys (row[:nome] || row[:name]), so the logged name will likely be nil even when present.

Consider normalizing the keys before use (e.g. row = row.to_h.with_indifferent_access) or consistently using string keys:

name = row['nome'] || row['name']
Rails.logger.debug "📊 DataImportJob: Rejected contact - name: #{name}, errors: #{row['errors']}"

@DavidsonGomes DavidsonGomes added needs-review Postponed for deeper review (out of current release scope) and removed needs-review Postponed for deeper review (out of current release scope) labels May 5, 2026
@DavidsonGomes DavidsonGomes merged commit fdeff2d into evolution-foundation:develop May 5, 2026
1 check passed
lpcoutinho added a commit to lpcoutinho/evo-ai-crm-community that referenced this pull request May 9, 2026
Reverted the migration timestamp renames from the original PR evolution-foundation#40 feedback.
The develop branch decided to keep the original timestamps:
- 20241020000100_optimize_contacts_performance.rb (kept)
- 20251117132621_add_type_to_contacts.rb (kept)

This commit aligns our branch with develop, keeping only the test coverage
added in the previous commit (a638778).
@lpcoutinho
Copy link
Copy Markdown
Contributor Author

@DavidsonGomes - Oi Davidson, tudo bem?

Gostaria de compartilhar o status atual sobre os pontos que você mencionou no feedback do PR #40:

PR #52 - Cobertura de Testes ✅

Criei o PR #52 com cobertura de testes para o que foi mergeado:

  • Testes para Role e UserRole models
  • Testes para BaseMailer (admin_emails role-based)
  • Testes para migrations de performance

Status dos 4 PRs Mencionados

Item Status PR
Índices de performance ✅ Mergeado #40
Role models ✅ Mergeado #40
Mailer change ✅ Mergeado #40
Sanitização tax_id ❌ Parcial -

O que FALTA: Sanitização tax_id

Atualmente há apenas sanitização básica:

  • Remove não-dígitos (gsub(/\D/, ''))
  • Validação de unicidade e tamanho máximo (14 caracteres)

O que pode ser implementado:

  • Validação de CPF brasileiro (11 dígitos + algoritmo de validação)
  • Validação de CNPJ brasileiro (14 dígitos + algoritmo de validação)
  • Formatação com máscara para exibição

Como o PR #40 já foi mergeado, podemos criar um PR separado focado apenas em melhorar a sanitização/validação de tax_id (CPF/CNPJ). Isso seria mais simples de revisar e testar.

O que você acha?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants