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
phoneslist.SIN TELEFONOis rejected. - Consumidor Final means no billing profile.
CF,C/Fand "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 column | Comes from |
|---|---|
phone | The primary phone's e164 |
email | The primary email |
tax_id, tax_name, tax_address, taxpayer_type | The 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_code | The default address: reference, department and municipality, plus the components city, state and postal_code |
document_number | The 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:
- Lock the row with
SELECT … FOR UPDATE, filtered by business. - Check
expectedVersion. A mismatch is409 CUSTOMER_VERSION_CONFLICT. - Run the domain change, which validates every invariant.
- Stop if nothing changed (FR-035): no write, no version bump, no audit.
- Otherwise run one
UPDATEthat writes the row, the lists and the legacy projection, withversion + 1. - Rewrite this customer's
customer_lookuprows. - Record one masked
activity_logentry 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.tsfails on anyinsertInto/updateTable/ rawINSERT/UPDATEofcustomeroutsidecustomers/infrastructure/, and on any plainnew Logger(inside the module.customers:lookup:checkcomparescustomer_lookupagainst 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), whichmutatewould then write back; - re-parse any
::textcolumn 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:
| Input | Searches |
|---|---|
| Digits, with or without separators | Phones (any run of at least 4 digits) and tax IDs |
A NIT ending in K | Tax IDs |
| Text | Names: case- and accent-insensitive, words in any order, partial words allowed |
- Phone and tax ID queries read
customer_lookup. Names use the trigram expression indexcustomer_search_name_trgmonf_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
||becauseconcat_wsisn'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:
- Validate the NIT locally. An invalid NIT is a
400, and the certifier is never called. - Check the 24-hour cache, which is kept per business. A hit counts toward no limit.
- Apply the limits: 30 lookups per user per minute, and the business's daily cap (
business.taxpayer_lookup_daily_cap, default 500). - Call
FelService.getSharedInfo, racing a 5-second deadline.
Outcomes:
foundnot_foundunavailablenot_configured429 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)
| Role | View | Manage (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:
| Data | Recorded as |
|---|---|
| Phones | ••6975 |
| Emails | j•••@example.com |
| Addresses | { changedFields } only |
| Names | { changed, initials } |
| Tax ID, legal name | In 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 |
|---|---|
| R2 | PWA quick-create (useCustomerSelector.ts) predates 062; FE-CUST replaces it. |
| R3 | sale.customer_id may still CASCADE on hard delete. Only rpapos-catalog-reset hard-deletes customers. |
| R4 | Legacy routes accept unversioned saves until FE-CUST (FR-034a). |
| R5 | The 5-second lookup deadline does not cancel FEL's underlying request (up to 30 s). |
| R6 | FelController 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.