Address form configuration (feature 063)
Feature 063 lets each business choose which delivery-address fields its staff capture, and lets each location adjust that choice. The result validates every address write, reports which stored addresses are incomplete, and orders how an address is displayed.
The design record is in specs/063-address-form-config/:
spec.md: requirements (FR-001 to FR-034, FR-026a, FR-026b) and success criteria.research.md: decisions D1–D19, facts F1–F15, residuals R1–R4.data-model.md: the catalog, presets, tables and address changes.contracts/address-form-api.md: the HTTP contract.
This page covers what a developer needs to change the feature safely. It builds on the customer aggregate (062).
Three layers
Guatemala catalog (code) 47 fields: 7 map to address attributes, 40 are components
│ + presets: minimal, gt_standard (default), complete
▼
business profile address_form_profile — one row per business
│ no row ≡ gt_standard at version 0
▼
location override (sparse) location_address_form_override — one row per location
│ locked fields ignore it
▼
resolved definition computed per request, never stored
All of it lives in the customers module. The pure rules are in customers/domain/address-form/:
| File | What it decides |
|---|---|
guatemala-catalog.ts | Field codes, types, labels, catalog order. Codes never change meaning. Bump ADDRESS_CATALOG_VERSION when an entry or preset changes. |
presets.ts | The three presets and the implicit profile. No preset requires anything (FR-005). |
resolve-definition.ts | catalog → profile → override, and the version string. |
validate-address-change.ts | Which address changes are refused, and why. |
check-address-changes.ts | Pairs addresses before and after a write, validates the changed ones, stamps them. |
render-address.ts | Completeness (FR-030) and display order (FR-031). |
profile-rules.ts | Profile and override validation, custom-field merging, presets, lock discards. |
custom-code.ts | x_ codes for custom fields and labels. |
validation-mode.ts | staff / integration, and the temporary LEGACY_FIELDS_ADDRESS_MODE. |
The definition version
c{catalog}.p{profile}.o{override}, for example c1.p4.o2. Business level is o0. It is:
- the
ETagofGET /address-form, so a client can sendIf-None-Matchand get a304; - recorded on every validated address as
formVersion, together withvalidatedAtLocationId(null at business level).
An address with formVersion: null was never validated: it predates 063.
Which definition validates which write
Validation runs inside 062's single write path. CustomersRepository.mutate and createAggregate take an addressForm: { definition, mode } option, apply the change, call checkAddressChanges, and only then write the row. A refusal throws ADDRESS_FORM_VALIDATION_FAILED with every offending field, and the transaction rolls back with no audit entry.
| Write | Location | Mode |
|---|---|---|
POST /customers/:id/addresses, PUT …/addresses/:itemId | body locationId, if present | staff |
POST /customers with addresses[] | top-level locationId, if present | staff |
POST/PATCH /customers with legacy address fields | none (business level) | LEGACY_FIELDS_ADDRESS_MODE = integration, until FE-CUST |
Legacy repository create / update (rpapos sync, data import, legacy importer, every other caller) | none (business level) | the caller's required addressMode argument |
What to know:
- The location is named
locationId. That literal name is what the globalLocationAccessGuardchecks, so the caller's location assignment is enforced for free.assertLocationInBusinessthen checks it belongs to the business. - The server sets
capturedAtLocationId. On a new address it becomes the validation location, or null at business level. A client value is accepted for compatibility and ignored, so a capture location can never be one the caller was not checked against. It never changes after create. addressModehas no default. The legacycreate(customer, addressMode, …)andupdate(params, addressMode, …)make every caller say how its writes are validated. A new caller that forgets fails to compile.- Integration mode (FR-026a) checks codes, types and select options, but not required fields or the label. Systems that copy data from elsewhere cannot ask anyone to fill a field.
- FR-026b is temporary. The current PWA customer screens can only send the legacy single-value fields, so those writes use integration mode too.
af/__tests__/validation-mode.spec.tsguards the constant. FE-CUST removes both together.
What validation refuses, and what it never refuses
A field is touched when its value after the change differs from its stored value (trimmed). On create, everything present is touched.
Refused (FR-027):
- On create, an empty required field. On edit, a required field the edit cleared. An untouched missing required field is never checked; it only makes the address incomplete.
- A touched value that does not match its type: number, yes/no (
"true"/"false"), a select key that is not an active option, or text over the field's limit. - A new value for a code nobody defined, or for a retired custom field.
- A label that is not active, unless it is the unchanged stored label (staff mode only).
- A save that pushes the components past 100 keys by adding keys.
Never refused (FR-028): stored values of disabled, retired or unknown fields. They are kept on every save.
Marking an address as default, and removing one, are never validated.
PUT keeps what it omits
062's PUT …/addresses/:itemId used to reset every omitted field. A location whose form hides recipient_name would therefore erase it. Since 063 (research D6):
- an absent attribute keeps its stored value;
- explicit
nullor""clears it; - components merge, as before;
capturedAtLocationIdis ignored.
The 062 format check in parseAddress no longer caps the component count. Values may be up to 1,000 characters there; each field's own limit is applied by the definition.
Configuration routes
Owner and admin only, through the AddressForm policy resource (not in businessAdminConfigurationAll, so store managers do not get it). Every write needs If-Match with the version it was based on; 0 means nothing is saved yet. A stale version gets 409 with currentVersion.
GET /address-form?locationId=|businessId= resolved form (Customer Read is enough)
GET /businesses/:b/address-form profile + catalog + presets
PUT /businesses/:b/address-form replace the profile
POST /businesses/:b/address-form/apply-preset catalog fields only
GET /businesses/:b/locations/:l/address-form-override with inherited / overridden / locked markers
PUT /businesses/:b/locations/:l/address-form-override
DELETE …/address-form-override/fields/:code reset one field
DELETE …/address-form-override reset the location (row kept, version keeps counting)
Override routes skip LocationAccessGuard: owners configure every location regardless of their own assignments. The service checks the location belongs to the business instead.
Behaviours that surprise people:
- Locking a field discards it from every location override, in the same transaction, with one
override.discarded_by_lockaudit entry per location. Unlocking restores nothing (clarification Q2). Retiring a custom field does the same. - Applying a preset replaces only the catalog fields' enabled, required, order, group and label. Locks, custom fields, labels and overrides stay.
- An override equal to the inherited value reads as
inherited, and is stripped at the next override save. - Custom fields and labels are never deleted, only retired. Their codes are
x_plus 8 random base-32 characters, never reused. They are random rather than sequential because 062 accepted arbitrary clientx_codes.
Every accepted change writes one activity_log entry (entity_type = 'business_settings') with the old and new documents, in the same transaction. No-ops and refusals write nothing.
Reading addresses with a location
GET /customers/:id?locationId=&lang=es|en adds, on each address:
completeness:{ complete, missing }under that location's form;displayLines:[{ code, label, value }]in FR-031 order. That is the location's enabled fields first, then the remaining catalog fields, then custom fields by creation (retired included), then unknown codes. Yes/no and select values render as labels.
Without locationId the response is as before. BE-DLV renders addresses in process through AddressFormService.render(businessId, locationId, address, lang) and resolve, both exported by CustomersModule.
JSONB reads
Both tables keep their settings in JSONB. The app's Kysely runs CamelCasePlugin then ParseJSONResultsPlugin, so AddressFormRepository reads every JSONB column as '~' || col::text and parses it itself, exactly like 062's customer collections. Tests that read JSONB must add ParseJSONResultsPlugin (buildAddressFormRepository does).
Residuals
| # | Residual |
|---|---|
| R1 | LocationAccessGuard has no owner bypass: an owner without a business_user_location row cannot save an address naming that location. A business-level save works. House-wide behaviour. |
| R2 | PATCH /customers/:id silently ignores an addresses array (pre-062 DTO shape). Harmless; FE-CUST retires the legacy paths. |
| R3 | A save validated against version N can commit just after version N+1 is saved. It records N, truthfully. |
| R4 | Legacy-field writes truncate long values instead of refusing them (062 legacy projection). |