@classytic/primitives 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.24.0] - 2026-08-24
7
+
8
+ ### Added — `@classytic/primitives/address`: capture-format system
9
+
10
+ `AddressFormat`, `AddressFieldDescriptor`, `AddressFieldKey`, `PartialAddress`, `AddressFieldError` — a description of HOW a country collects a `ContactAddress`: field labels, order, requiredness, subdivision taxonomy hooks, and postal code pattern. Country packs (e.g. `@classytic/bd-areas/address-format`) live beside their taxonomies; this subpath owns only the generic shape.
11
+
12
+ `validateAddress(address, format)` — validates a partial address against a format; returns `AddressFieldError[]`. Enforces `required` on both canonical and extension fields identically, and checks `postalCodePattern` only when a value is given and a pattern is declared. `isAddressValid` and `describeAddressError` are convenience wrappers.
13
+
14
+ Every `key` in a descriptor is a field of `ContactAddress`; co-location with the value object is what makes a mismatch obvious instead of invisible.
15
+
6
16
  ## [0.23.0] - 2026-08-15
7
17
 
8
18
  ### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
@@ -5,6 +5,11 @@
5
5
  * Kept deliberately generic — international postal formats vary too much to
6
6
  * enforce per-country rules in a shared type. Consumers can extend or narrow
7
7
  * as needed (e.g. a BD-specific schema might add `district`, `upazila`).
8
+ *
9
+ * That still holds, and the CAPTURE FORMAT section below does not weaken it. A
10
+ * format constrains no type and adds no field: it is opt-in DATA describing how
11
+ * a country asks for the fields declared here. The type stays generic precisely
12
+ * so one canonical shape can be captured many ways.
8
13
  */
9
14
  interface Address {
10
15
  line1: string;
@@ -34,5 +39,269 @@ interface GeoJsonPoint {
34
39
  }
35
40
  declare function toGeoJsonPoint(p: GeoPoint): GeoJsonPoint;
36
41
  declare function fromGeoJsonPoint(g: GeoJsonPoint): GeoPoint;
42
+ /**
43
+ * How a country's postal address is CAPTURED — labels, order, requiredness, and
44
+ * which fields come from a subdivision taxonomy rather than free text.
45
+ *
46
+ * ## Why this lives HERE, beside the value object
47
+ *
48
+ * Storage was already standardised: an address is `ContactAddress` above. What
49
+ * varied — and varied by being hardcoded — is CAPTURE. The checkout form asked
50
+ * for "Division", "District" and "Delivery Area" because the deployment is
51
+ * Bangladeshi, so selling in a second country meant editing the form. That is
52
+ * the defect `@classytic/ledger` already solved for charts of accounts: the
53
+ * ENGINE was generic and the BINDING was not.
54
+ *
55
+ * This first went into a separate `@classytic/address` package. Wrong, for the
56
+ * reason this codebase has just paid for twice: a description of a shape that
57
+ * lives away from the shape DRIFTS from it. `Order`'s Mongoose schema said
58
+ * `addressLine1` while the type it declared said `line1`, and nothing could see
59
+ * the contradiction because they were in different files. Every `key` below is
60
+ * a field of `ContactAddress`; putting them in the same file is what makes a
61
+ * mismatch obvious instead of invisible.
62
+ *
63
+ * The COUNTRY PACKS do not live here — they are localizations, and they follow
64
+ * the `ledger` / `ledger-bd` precedent: Bangladesh's is
65
+ * `@classytic/bd-areas/address-format`, beside the taxonomy it selects from.
66
+ * Nothing in this file knows what a division is.
67
+ *
68
+ * ## What a format does NOT do
69
+ *
70
+ * It does not introduce new stored fields. Every descriptor's `key` is a field
71
+ * of `ContactAddress`, so a format is a VIEW over one canonical shape:
72
+ * Bangladesh labels `state` "Division", Japan labels it "Prefecture", the United
73
+ * States labels it "State". Same column, same value object, different word.
74
+ *
75
+ * A country that genuinely needs an extra datum (a thana, an upazila, a landmark)
76
+ * puts it in `metadata` — primitives' own guidance — never in a new top-level
77
+ * field, because that would make the stored shape country-dependent again.
78
+ */
79
+ /** The `ContactAddress` fields a format may lay out. */
80
+ type AddressFieldKey = 'name' | 'phone' | 'email' | 'company' | 'line1' | 'line2' | 'city' | 'state' | 'postalCode' | 'country';
81
+ /**
82
+ * How a field is entered.
83
+ *
84
+ * `subdivision` is the interesting one: the value is still a plain string on the
85
+ * stored address, but it is CHOSEN from a taxonomy rather than typed. Bangladesh
86
+ * picks a division then a district; India picks a state; the US picks a state.
87
+ * The taxonomy itself is supplied by the host through `AddressTaxonomyPort` —
88
+ * this package ships no place names, so it stays country-agnostic and small.
89
+ */
90
+ type AddressFieldInput = 'text' | 'tel' | 'email' | 'subdivision';
91
+ interface AddressFieldSpec {
92
+ /** The canonical `ContactAddress` field this captures. */
93
+ key: AddressFieldKey;
94
+ /** Label in the deployment's language. */
95
+ label: string;
96
+ input: AddressFieldInput;
97
+ required?: boolean;
98
+ /** Spans the full row in a two-column form. */
99
+ wide?: boolean;
100
+ placeholder?: string;
101
+ /** Help text under the field. */
102
+ hint?: string;
103
+ /**
104
+ * For `input: 'subdivision'` — the taxonomy level this field selects, resolved
105
+ * through `AddressTaxonomyPort`. Opaque to this package: `'division'`,
106
+ * `'district'`, `'area'`, `'state'`, `'prefecture'` are all just strings.
107
+ */
108
+ taxonomy?: string;
109
+ /**
110
+ * The field whose value narrows this one's options — Bangladesh's district
111
+ * list depends on the chosen division. Must appear EARLIER in `fields`.
112
+ */
113
+ dependsOn?: string;
114
+ }
115
+ /**
116
+ * A field a country needs that `ContactAddress` does not model.
117
+ *
118
+ * ## Why this exists rather than growing the value object
119
+ *
120
+ * Bangladesh's checkout captures a **Delivery Area** — the locality couriers
121
+ * actually route and price on, finer than a district. It is required there and
122
+ * meaningless in most countries.
123
+ *
124
+ * The two obvious answers are both wrong. Adding `areaName` to `ContactAddress`
125
+ * puts one market's concept in the base type every country pays for; leaving it
126
+ * out entirely means the country that most needs a format cannot express its
127
+ * most important field, which is how the form ends up hardcoded again.
128
+ *
129
+ * So an extension field is DECLARED like any other — same labels, same
130
+ * requiredness, same taxonomy machinery — but its `name` is free-form and the
131
+ * consuming DOMAIN decides where the value lands. `@classytic/order` promotes
132
+ * `areaId` / `areaName` / `zoneId` to real columns because its carrier adapters
133
+ * read them; anything it does not recognise goes to `metadata`. Neither
134
+ * decision belongs in this package, and neither requires a release of it.
135
+ *
136
+ * Values live FLAT alongside the canonical ones, not in a nested bag — which is
137
+ * how `areaName` is already stored, and it means a form produces one object
138
+ * rather than two that can disagree.
139
+ */
140
+ interface AddressExtensionFieldSpec {
141
+ /** Discriminant. */
142
+ extension: true;
143
+ /**
144
+ * Free-form key. Not validated against `ContactAddress` — that is the point.
145
+ * The domain maps it to a column or to `metadata`.
146
+ */
147
+ name: string;
148
+ label: string;
149
+ input: AddressFieldInput;
150
+ required?: boolean;
151
+ wide?: boolean;
152
+ placeholder?: string;
153
+ hint?: string;
154
+ taxonomy?: string;
155
+ /** May name a canonical key OR another extension. Must appear EARLIER. */
156
+ dependsOn?: string;
157
+ }
158
+ type AnyAddressFieldSpec = AddressFieldSpec | AddressExtensionFieldSpec;
159
+ /** The key a spec writes to, canonical or extension. */
160
+ declare function fieldName(spec: AnyAddressFieldSpec): string;
161
+ /** Narrowing helper — `true` when the spec is an extension. */
162
+ declare function isExtensionField(spec: AnyAddressFieldSpec): spec is AddressExtensionFieldSpec;
163
+ interface AddressFormat {
164
+ /** ISO 3166-1 alpha-2, or `'*'` for the international fallback. */
165
+ country: string;
166
+ /** Display name of the country, for a read-only country field. */
167
+ countryLabel: string;
168
+ /** Ordered — this is the form layout. Canonical and extension fields interleave. */
169
+ fields: AnyAddressFieldSpec[];
170
+ /**
171
+ * Validate `postalCode` when present. Omit for countries where the code is
172
+ * free-form or absent; a wrong pattern is worse than none, because it refuses
173
+ * addresses that are perfectly valid.
174
+ */
175
+ postalCodePattern?: RegExp;
176
+ }
177
+ /**
178
+ * A taxonomy level's options, narrowed by the parent selection.
179
+ *
180
+ * A HOST PORT, not data in this package: Bangladesh's divisions live in
181
+ * `@classytic/bd-areas`, and a kernel that imported them would be a
182
+ * country-agnostic package with one country's place names compiled into it.
183
+ */
184
+ interface AddressTaxonomyPort {
185
+ /**
186
+ * @param level the `taxonomy` string from the field spec
187
+ * @param parentId the selected value of `dependsOn`, when the field has one
188
+ */
189
+ options(level: string, parentId?: string): Array<{
190
+ id: string;
191
+ label: string;
192
+ hint?: string;
193
+ }>;
194
+ }
195
+ /**
196
+ * The generic international format — the FALLBACK, and the proof the contract is
197
+ * not BD-shaped.
198
+ *
199
+ * Deliberately conservative. `state` is present but optional because many
200
+ * countries have no meaningful subdivision in a postal address (Singapore,
201
+ * Vatican City), and `postalCode` is optional because several have no postal
202
+ * code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
203
+ * the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
204
+ *
205
+ * The required set is exactly what `Fulfillment` needs to be deliverable:
206
+ * `line1`, `city`, `country`.
207
+ */
208
+ declare const INTERNATIONAL_ADDRESS_FORMAT: AddressFormat;
209
+ /** Look a CANONICAL field up by its key. Extension fields have no `key`. */
210
+ declare function fieldSpec(format: AddressFormat, key: AddressFieldKey): AddressFieldSpec | undefined;
211
+ /**
212
+ * The CANONICAL keys a format marks required.
213
+ *
214
+ * Canonical only, deliberately: callers use this to check that a format can
215
+ * produce a deliverable address (`line1` + `city` + `country`), and an extension
216
+ * name has no meaning in that question. Use `requiredFieldNames` for everything
217
+ * a form must collect.
218
+ */
219
+ declare function requiredKeys(format: AddressFormat): AddressFieldKey[];
220
+ /** Every field name a format requires, canonical and extension alike. */
221
+ declare function requiredFieldNames(format: AddressFormat): string[];
222
+ /**
223
+ * A capture in progress.
224
+ *
225
+ * Extension values sit FLAT beside the canonical ones — `areaName` alongside
226
+ * `city` — because that is how the order kernel already stores them, and a
227
+ * nested bag would be a second place for the same address to disagree with
228
+ * itself. The index signature is what lets a format declare a field this
229
+ * package has never heard of.
230
+ */
231
+ type PartialAddress = Partial<Record<AddressFieldKey, string>> & Partial<Pick<ContactAddress, 'line1' | 'city' | 'country'>> & {
232
+ [extension: string]: unknown;
233
+ };
234
+ /**
235
+ * Country code → capture format. The host builds this, exactly as it builds the
236
+ * ledger's `REGISTRY` (`be-prod/.../accounting/country-pack.ts`).
237
+ */
238
+ type AddressFormatRegistry = Readonly<Record<string, AddressFormat>>;
239
+ /**
240
+ * Resolve the format for a country, falling back to the international one.
241
+ *
242
+ * ## Falling back is the point, not a concession
243
+ *
244
+ * A deployment that has not authored a pack for Norway must still be able to
245
+ * ship to Norway. The fallback captures the deliverable minimum with neutral
246
+ * labels, which is strictly better than either refusing the address or showing
247
+ * a Bangladeshi form to a Norwegian.
248
+ *
249
+ * The lookup is case-insensitive because `country` reaches this from three
250
+ * directions — a form, a stored order, an API payload — and ISO codes get
251
+ * written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
252
+ * at every call site, one of which will be forgotten and will silently fall back
253
+ * to international for its own home country.
254
+ */
255
+ declare function resolveAddressFormat(country: string | null | undefined, registry: AddressFormatRegistry): AddressFormat;
256
+ /**
257
+ * The countries a deployment has packs for — for a country picker.
258
+ *
259
+ * A deployment that ships internationally should offer every ISO country and let
260
+ * unlisted ones resolve to the fallback; this is for the common case of an
261
+ * operator choosing among the markets actually configured.
262
+ */
263
+ declare function supportedCountries(registry: AddressFormatRegistry): Array<{
264
+ code: string;
265
+ label: string;
266
+ }>;
267
+ interface AddressFieldError {
268
+ /**
269
+ * The field's write key — a canonical `AddressFieldKey` or an extension name.
270
+ * Typed `string` because a format may declare fields this package does not
271
+ * know; a form matches it against `fieldName(spec)`.
272
+ */
273
+ key: string;
274
+ /** A closed code — callers map it to their own copy. */
275
+ code: 'required' | 'postal_code_format';
276
+ /** The field's own label, so a default message reads correctly per country. */
277
+ label: string;
278
+ }
279
+ /**
280
+ * Validate a captured address against its country's format.
281
+ *
282
+ * ## Returns errors; does not throw, and does not "clean"
283
+ *
284
+ * A form needs every failure at once to mark every field, so this returns a
285
+ * list. It also never mutates or drops anything: a validator that silently
286
+ * repaired input would make the stored value differ from what the operator
287
+ * typed, and they would have no way to see it.
288
+ *
289
+ * ## Codes, not sentences
290
+ *
291
+ * `code` is closed and stable so a host can translate. `label` rides along
292
+ * because the same `state` field is "Division" in Bangladesh and "Prefecture"
293
+ * in Japan, and a default message assembled from the code alone would say the
294
+ * wrong word in one of them.
295
+ *
296
+ * ## Whitespace is absence
297
+ *
298
+ * `' '` satisfies a naive truthiness check and then prints as a blank line on
299
+ * a courier label. Required means a value, not a character.
300
+ */
301
+ declare function validateAddress(address: PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
302
+ /** Convenience for a submit gate. */
303
+ declare function isAddressValid(address: PartialAddress | null | undefined, format: AddressFormat): boolean;
304
+ /** A readable default message, in the country's own vocabulary. */
305
+ declare function describeAddressError(error: AddressFieldError): string;
37
306
  //#endregion
38
- export { Address, ContactAddress, GeoJsonPoint, GeoPoint, fromGeoJsonPoint, toGeoJsonPoint };
307
+ export { Address, AddressExtensionFieldSpec, AddressFieldError, AddressFieldInput, AddressFieldKey, AddressFieldSpec, AddressFormat, AddressFormatRegistry, AddressTaxonomyPort, AnyAddressFieldSpec, ContactAddress, GeoJsonPoint, GeoPoint, INTERNATIONAL_ADDRESS_FORMAT, PartialAddress, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
package/dist/address.mjs CHANGED
@@ -12,5 +12,194 @@ function fromGeoJsonPoint(g) {
12
12
  latitude
13
13
  };
14
14
  }
15
+ /** The key a spec writes to, canonical or extension. */
16
+ function fieldName(spec) {
17
+ return "extension" in spec ? spec.name : spec.key;
18
+ }
19
+ /** Narrowing helper — `true` when the spec is an extension. */
20
+ function isExtensionField(spec) {
21
+ return "extension" in spec;
22
+ }
23
+ /**
24
+ * The generic international format — the FALLBACK, and the proof the contract is
25
+ * not BD-shaped.
26
+ *
27
+ * Deliberately conservative. `state` is present but optional because many
28
+ * countries have no meaningful subdivision in a postal address (Singapore,
29
+ * Vatican City), and `postalCode` is optional because several have no postal
30
+ * code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
31
+ * the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
32
+ *
33
+ * The required set is exactly what `Fulfillment` needs to be deliverable:
34
+ * `line1`, `city`, `country`.
35
+ */
36
+ const INTERNATIONAL_ADDRESS_FORMAT = {
37
+ country: "*",
38
+ countryLabel: "Country",
39
+ fields: [
40
+ {
41
+ key: "name",
42
+ label: "Recipient Name",
43
+ input: "text",
44
+ required: true
45
+ },
46
+ {
47
+ key: "phone",
48
+ label: "Phone",
49
+ input: "tel",
50
+ required: true
51
+ },
52
+ {
53
+ key: "line1",
54
+ label: "Address Line 1",
55
+ input: "text",
56
+ required: true,
57
+ wide: true
58
+ },
59
+ {
60
+ key: "line2",
61
+ label: "Address Line 2",
62
+ input: "text",
63
+ wide: true
64
+ },
65
+ {
66
+ key: "city",
67
+ label: "City",
68
+ input: "text",
69
+ required: true
70
+ },
71
+ {
72
+ key: "state",
73
+ label: "State / Province / Region",
74
+ input: "text"
75
+ },
76
+ {
77
+ key: "postalCode",
78
+ label: "Postal Code",
79
+ input: "text"
80
+ },
81
+ {
82
+ key: "country",
83
+ label: "Country",
84
+ input: "text",
85
+ required: true
86
+ }
87
+ ]
88
+ };
89
+ /** Look a CANONICAL field up by its key. Extension fields have no `key`. */
90
+ function fieldSpec(format, key) {
91
+ return format.fields.find((f) => !isExtensionField(f) && f.key === key);
92
+ }
93
+ /**
94
+ * The CANONICAL keys a format marks required.
95
+ *
96
+ * Canonical only, deliberately: callers use this to check that a format can
97
+ * produce a deliverable address (`line1` + `city` + `country`), and an extension
98
+ * name has no meaning in that question. Use `requiredFieldNames` for everything
99
+ * a form must collect.
100
+ */
101
+ function requiredKeys(format) {
102
+ return format.fields.filter((f) => !isExtensionField(f) && !!f.required).map((f) => f.key);
103
+ }
104
+ /** Every field name a format requires, canonical and extension alike. */
105
+ function requiredFieldNames(format) {
106
+ return format.fields.filter((f) => f.required).map(fieldName);
107
+ }
108
+ /**
109
+ * Resolve the format for a country, falling back to the international one.
110
+ *
111
+ * ## Falling back is the point, not a concession
112
+ *
113
+ * A deployment that has not authored a pack for Norway must still be able to
114
+ * ship to Norway. The fallback captures the deliverable minimum with neutral
115
+ * labels, which is strictly better than either refusing the address or showing
116
+ * a Bangladeshi form to a Norwegian.
117
+ *
118
+ * The lookup is case-insensitive because `country` reaches this from three
119
+ * directions — a form, a stored order, an API payload — and ISO codes get
120
+ * written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
121
+ * at every call site, one of which will be forgotten and will silently fall back
122
+ * to international for its own home country.
123
+ */
124
+ function resolveAddressFormat(country, registry) {
125
+ if (!country) return INTERNATIONAL_ADDRESS_FORMAT;
126
+ return registry[country.trim().toUpperCase()] ?? INTERNATIONAL_ADDRESS_FORMAT;
127
+ }
128
+ /**
129
+ * The countries a deployment has packs for — for a country picker.
130
+ *
131
+ * A deployment that ships internationally should offer every ISO country and let
132
+ * unlisted ones resolve to the fallback; this is for the common case of an
133
+ * operator choosing among the markets actually configured.
134
+ */
135
+ function supportedCountries(registry) {
136
+ return Object.entries(registry).map(([code, format]) => ({
137
+ code,
138
+ label: format.countryLabel
139
+ })).sort((a, b) => a.label.localeCompare(b.label));
140
+ }
141
+ /**
142
+ * Validate a captured address against its country's format.
143
+ *
144
+ * ## Returns errors; does not throw, and does not "clean"
145
+ *
146
+ * A form needs every failure at once to mark every field, so this returns a
147
+ * list. It also never mutates or drops anything: a validator that silently
148
+ * repaired input would make the stored value differ from what the operator
149
+ * typed, and they would have no way to see it.
150
+ *
151
+ * ## Codes, not sentences
152
+ *
153
+ * `code` is closed and stable so a host can translate. `label` rides along
154
+ * because the same `state` field is "Division" in Bangladesh and "Prefecture"
155
+ * in Japan, and a default message assembled from the code alone would say the
156
+ * wrong word in one of them.
157
+ *
158
+ * ## Whitespace is absence
159
+ *
160
+ * `' '` satisfies a naive truthiness check and then prints as a blank line on
161
+ * a courier label. Required means a value, not a character.
162
+ */
163
+ function validateAddress(address, format) {
164
+ const errors = [];
165
+ const value = (k) => String(address?.[k] ?? "").trim();
166
+ /**
167
+ * Canonical and extension fields are checked IDENTICALLY, keyed by
168
+ * `fieldName`. A required Delivery Area is enforced exactly as a required
169
+ * city is — an extension that validated more weakly than a core field would
170
+ * be an optional field wearing a required label.
171
+ */
172
+ for (const field of format.fields) {
173
+ const name = fieldName(field);
174
+ if (field.required && !value(name)) errors.push({
175
+ key: name,
176
+ code: "required",
177
+ label: field.label
178
+ });
179
+ }
180
+ /**
181
+ * Only when a pattern is declared AND a value was given. An absent optional
182
+ * postal code is not a format error, and a country with no declared pattern
183
+ * must not have one invented for it — see `AddressFormat.postalCodePattern`.
184
+ */
185
+ const postal = value("postalCode");
186
+ if (format.postalCodePattern && postal && !format.postalCodePattern.test(postal)) errors.push({
187
+ key: "postalCode",
188
+ code: "postal_code_format",
189
+ label: fieldSpec(format, "postalCode")?.label ?? "Postal Code"
190
+ });
191
+ return errors;
192
+ }
193
+ /** Convenience for a submit gate. */
194
+ function isAddressValid(address, format) {
195
+ return validateAddress(address, format).length === 0;
196
+ }
197
+ /** A readable default message, in the country's own vocabulary. */
198
+ function describeAddressError(error) {
199
+ switch (error.code) {
200
+ case "required": return `${error.label} is required`;
201
+ case "postal_code_format": return `${error.label} is not in the expected format`;
202
+ }
203
+ }
15
204
  //#endregion
16
- export { fromGeoJsonPoint, toGeoJsonPoint };
205
+ export { INTERNATIONAL_ADDRESS_FORMAT, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",