Skip to main content

Customer aggregate (feature 062)

Feature 062 turns the existing customer row into an aggregate. Each customer holds lists of phones, delivery addresses, billing profiles and emails, is shared by every location of its business, and is searchable by phone fragment, by name with or without accents, or by NIT.

The design record is in specs/062-customer-module/:

  • spec.md: requirements (FR-…) and success criteria (SC-…).
  • research.md: decisions D1–D21, facts F1–F29, residuals R1–R6.
  • data-model.md: columns, element shapes, invariants.
  • contracts/customers-api.md: the HTTP contract.

This page covers what a developer needs to change the module safely.

The model​

customer (one row per customer, business-scoped)
core first_name, last_name, alias, birth_date, gender, notes, tags, status, …
version integer: optimistic lock (ETag / If-Match)
deleted_at soft delete; anonymized_at: set 90 days later
phones jsonb [{id, type, e164, extension, comment, isPrimary}]
addresses jsonb [{id, label, department, municipality, zone, lat, lng, reference,
deliveryInstructions, recipientName, callPolicy, components{code: value},
capturedAtLocationId, formVersion, isDefault}]
billing_profiles jsonb [{id, taxIdType NIT|CUI, taxId, legalName, nameParts, rawSatName,
fiscalAddress, verifiedAt, isDefault}]
emails jsonb [{id, email, isPrimary, receivesFel}]
data_schema_version 0 = legacy columns only (upcast on read), 1 = collections authoritative

customer_lookup (derived, rebuildable)
business_id, customer_id, kind phone|tax_id, normalized_value, element_id

The rules for these lists:

  • Each list has exactly one primary or default element whenever it isn't empty. Removing that element promotes the oldest remaining one.
  • Element IDs are issued by the server and are never reused.
  • There are caps: 20 phones, 50 addresses, 20 billing profiles and 20 emails.
  • The same phone, tax ID or email can't appear twice on one customer.

Two absences, not records​

  • No phone means an empty phones list. SIN TELEFONO is rejected.
  • Consumidor Final means no billing profile. CF, C/F and "Consumidor Final" are rejected as billing profiles.

Legacy columns are a projection (FR-044a)​

About 13 files read customer.phone, tax_id, tax_name and similar columns directly, and nine modules write the old shape through CustomersService. Those columns are kept, but only as a projection of the lists:

Legacy columnComes from
phoneThe primary phone's e164
emailThe primary email
tax_id, tax_name, tax_address, taxpayer_typeThe default billing profile. With none: CF / CONSUMIDOR FINAL / CIUDAD / NIT, which is checkout's existing fallback. A row already stored as Consumidor Final keeps its stored values.
address, department_name, municipality_name, city, state_name, postal_codeThe default address: reference, department and municipality, plus the components city, state and postal_code
document_numberThe default billing profile's tax ID when it is a CUI

Old-style writes still work. A request with phone, taxId and similar fields is turned into an operation on the primary or default element (applyLegacyFields).

Old-style values aren't rejected. If such a value fails validation, it is stored as-is and marked legacyUnvalidated: true (D21), because existing screens and CSV imports send free text. The new element routes always validate strictly.

One write path (D3)​

Every insert, update and delete of customer goes through CustomersRepository.createAggregate or CustomersRepository.mutate. mutate runs these steps in one transaction:

  1. Lock the row with SELECT … FOR UPDATE, filtered by business.
  2. Check expectedVersion. A mismatch is 409 CUSTOMER_VERSION_CONFLICT.
  3. Run the domain change, which validates every invariant.
  4. Stop if nothing changed (FR-035): no write, no version bump, no audit.
  5. Otherwise run one UPDATE that writes the row, the lists and the legacy projection, with version + 1.
  6. Rewrite this customer's customer_lookup rows.
  7. Record one masked activity_log entry per change. If the audit fails, the whole transaction rolls back (500 CUSTOMER_AUDIT_FAILED, FR-042).

Two guards keep it that way:

  • customer-write-path.gate.spec.ts fails on any insertInto / updateTable / raw INSERT / UPDATE of customer outside customers/infrastructure/, and on any plain new Logger( inside the module.
  • customers:lookup:check compares customer_lookup against what each customer derives.

Reading customer rows: use COLLECTIONS_AS_TEXT​

The app's Kysely instance runs CamelCasePlugin and then ParseJSONResultsPlugin. Together they would:

  • rename nested JSONB keys (postal_code → postalCode, x_torre → xTorre), which mutate would then write back;
  • re-parse any ::text column that starts with { or [.

So the four lists are always read as sentinel-prefixed text (COLLECTIONS_AS_TEXT in customer-row.mapper.ts) and parsed in the mapper. toRecord throws if a saved row is read any other way.

The customer test helpers give the repository the app's full plugin stack. That gap once hid a read path that would have broken every customer read in production.

Search (FR-019–FR-025)​

GET /customers/search?q= works out what was typed:

InputSearches
Digits, with or without separatorsPhones (any run of at least 4 digits) and tax IDs
A NIT ending in KTax IDs
TextNames: case- and accent-insensitive, words in any order, partial words allowed
  • Phone and tax ID queries read customer_lookup. Names use the trigram expression index customer_search_name_trgm on f_unaccent(lower(coalesce(first_name,'') || ' ' || coalesce(last_name,'') || ' ' || coalesce(alias,''))).
  • Every name query must spell that expression exactly the same way. It is defined once, in customer-search-name.sql.ts.
  • The expression uses || because concat_ws isn't immutable.
  • Results come back exact matches first, then prefix matches, then partial matches. Ties go to the most recently updated customer.
  • Search filters by business twice: in the lookup query and again in the summary load.

Measured with 100,000 customers: search p95 13 ms, save p95 3.5 ms.

Duplicates warn, never block (FR-026–FR-028)​

POST /customers and the phone and billing-profile element routes return duplicateCandidates: live customers that share the normalized phone or tax ID. A shared phone is allowed, because households share landlines. POST /customers/duplicates runs the same check before creation.

Taxpayer lookup (FR-029–FR-031)​

GET /customers/taxpayers/:nit takes these steps in order:

  1. Validate the NIT locally. An invalid NIT is a 400, and the certifier is never called.
  2. Check the 24-hour cache, which is kept per business. A hit counts toward no limit.
  3. Apply the limits: 30 lookups per user per minute, and the business's daily cap (business.taxpayer_lookup_daily_cap, default 500).
  4. Call FelService.getSharedInfo, racing a 5-second deadline.

Outcomes:

  • found
  • not_found
  • unavailable
  • not_configured
  • 429 TAXPAYER_LOOKUP_RATE_LIMITED

A billing profile gets verifiedAt only if the server's own cache confirms the NIT, the raw SAT name, and the legal name. Create and later billing-profile writes use that same check. The client's verifiedFromLookup flag alone never sets it.

Checkout's lookup (POST /fel/get-shared-info) is now guarded. It requires membership plus any of customer create, customer update or sale create, and applies the per-user limit only. The daily cap never applies to checkout or invoicing (SC-011). A static gate fails if checkBusinessDaily is called from anywhere but the customer lookup adapter.

Permissions (FR-039/040)​

RoleViewManage (create, update, elements, lookup)Delete
owner, super, admin, administrator, store manager, IT support✓✓✓
cashier, customer-service representative✓✓—
accountant/finance, sales associate, salesman✓——
warehouse, inventory, marketing, purchasing———

On :id routes the business is resolved from the customer row by RolesGuard. A foreign ID and a nonexistent ID get the same 403, so the caller can't tell them apart.

Audit (FR-041/042)​

Entries are masked when written, because activity_log rows older than 13 months are archived to GCS and deleted, so they can never be scrubbed later:

DataRecorded as
Phones••6975
Emailsj•••@example.com
Addresses{ changedFields } only
Names{ changed, initials }
Tax ID, legal nameIn full (fiscal data)

The actor is the user's employee in the business. A user without an employee record is recorded as a system actor that carries their userId.

Retention (FR-038a)​

A daily job (customers-anonymize, cron 30 3 * * *) anonymizes customers soft-deleted more than 90 days ago:

  • Cleared: names, contact data and addresses.
  • Kept: the tax ID and legal name.
  • Skipped and logged: customers with an open receivable. They are retried on the next run.
  • Not touched: earlier audit entries.

Concurrency (FR-034, FR-034a)​

Every read returns ETag: "<version>", and every change sends If-Match. The element routes require it, and answer 428 without it. The four routes that existed before 062 (create, update, status, delete) may still omit it until the PWA slice (FE-CUST) ships. Such saves are last write wins and are audited as unversioned: true. The exempt set is UNVERSIONED_LEGACY_HANDLERS in domain/version-policy.ts.

Operations​

# Deploy order (research D20): the migration only does instant changes; the slow parts run outside it.
pnpm run migration:push # metadata-only, lock_timeout 10s
# deploy the backend
pnpm --filter @flowpos-workspace/backend-database run customers:finalize-schema # CONCURRENTLY + VALIDATE
pnpm --filter backend run customers:backfill # paced: 500/batch, 250 ms pauses; idempotent

pnpm --filter backend run customers:lookup:check # exit 1 on any drift
pnpm --filter backend run customers:lookup:rebuild # rewrite lookup rows from customers
pnpm --filter backend run customers:backfill -- --resync-legacy # only after a code rollback (D18)
pnpm --filter backend run customers:perf -- --customers 100000

Residuals​

#Residual
R2PWA quick-create (useCustomerSelector.ts) predates 062; FE-CUST replaces it.
R3sale.customer_id may still CASCADE on hard delete. Only rpapos-catalog-reset hard-deletes customers.
R4Legacy routes accept unversioned saves until FE-CUST (FR-034a).
R5The 5-second lookup deadline does not cancel FEL's underlying request (up to 30 s).
R6FelController has no RolesGuard, so @Permission on POST /fel/businesses/:businessId/token is not enforced. Pre-existing; outside 062.

R1 (unguarded get-shared-info) is closed by 062.