Saltar al contenido principal

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/:

FileWhat it decides
guatemala-catalog.tsField codes, types, labels, catalog order. Codes never change meaning. Bump ADDRESS_CATALOG_VERSION when an entry or preset changes.
presets.tsThe three presets and the implicit profile. No preset requires anything (FR-005).
resolve-definition.tscatalog → profile → override, and the version string.
validate-address-change.tsWhich address changes are refused, and why.
check-address-changes.tsPairs addresses before and after a write, validates the changed ones, stamps them.
render-address.tsCompleteness (FR-030) and display order (FR-031).
profile-rules.tsProfile and override validation, custom-field merging, presets, lock discards.
custom-code.tsx_ codes for custom fields and labels.
validation-mode.tsstaff / 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 ETag of GET /address-form, so a client can send If-None-Match and get a 304;
  • recorded on every validated address as formVersion, together with validatedAtLocationId (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.

WriteLocationMode
POST /customers/:id/addresses, PUT …/addresses/:itemIdbody locationId, if presentstaff
POST /customers with addresses[]top-level locationId, if presentstaff
POST/PATCH /customers with legacy address fieldsnone (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 global LocationAccessGuard checks, so the caller's location assignment is enforced for free. assertLocationInBusiness then 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.
  • addressMode has no default. The legacy create(customer, addressMode, …) and update(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.ts guards 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 null or "" clears it;
  • components merge, as before;
  • capturedAtLocationId is 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_lock audit 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 client x_ 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
R1LocationAccessGuard 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.
R2PATCH /customers/:id silently ignores an addresses array (pre-062 DTO shape). Harmless; FE-CUST retires the legacy paths.
R3A save validated against version N can commit just after version N+1 is saved. It records N, truthfully.
R4Legacy-field writes truncate long values instead of refusing them (062 legacy projection).