@ontemper/edi 1.1.7 → 1.2.0-beta.2

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/README.md CHANGED
@@ -350,43 +350,83 @@ IEA*1*000001000`;
350
350
  run().catch(console.error);
351
351
  ```
352
352
 
353
- ## Control Numbers
353
+ ## Control numbers
354
354
 
355
- The SDK provides functions for managing EDI control numbers (ISA13, GS06) via the centralized counter service:
355
+ The SDK manages ISA13 and GS06 control numbers for each sender and receiver pair. `toX12()` automatically stamps
356
+ empty or all-zero ISA13, GS06, and ST02 fields. It preserves valid non-zero values. ISA13 and GS06 use the
357
+ centralized counters. ST02 is assigned `"0001"`.
358
+
359
+ **By default, every environment draws from one shared sequence per trading partner. No setup is required.**
360
+ Separating test traffic onto its own sequence is an optional, per-partner opt-in for agreements that demand it.
361
+
362
+ ### Separate test counters (optional)
363
+
364
+ Skip this section unless a trading-partner agreement requires test traffic on an independent sequence. To opt in,
365
+ configure the trading partner once, then seed and verify the lanes before sending test traffic. A seeded value is the
366
+ last-used number, so the next allocation returns the seed plus one. To make the next ISA13 value 4601, seed 4600:
356
367
 
357
368
  ```typescript
358
- import { getNextControlNumber, getCurrentControlNumber, setControlNumber } from '@ontemper/edi';
369
+ import {
370
+ getControlNumberPolicy,
371
+ getCurrentControlNumber,
372
+ setControlNumber,
373
+ setControlNumberPolicy,
374
+ } from '@ontemper/edi';
359
375
 
360
- // Get next control number (atomic increment)
361
- const next = await getNextControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER');
376
+ const senderId = 'ZZ:SENDER';
377
+ const receiverId = 'ZZ:RECEIVER';
362
378
 
363
- // Peek at current value without incrementing
364
- const current = await getCurrentControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER');
379
+ await setControlNumberPolicy(senderId, receiverId, { separateCounters: true });
365
380
 
366
- // Set a starting value before the counter has incremented (for migrations)
367
- await setControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER', 1000);
381
+ // Seed the production lane explicitly before it starts.
382
+ await setControlNumber('ISA13', senderId, receiverId, 4600, 9, { lane: 'production' });
383
+
384
+ const policy = await getControlNumberPolicy(senderId, receiverId);
385
+ const production = await getCurrentControlNumber('ISA13', senderId, receiverId, 9, { lane: 'production' });
386
+ const test = await getCurrentControlNumber('ISA13', senderId, receiverId, 9, { lane: 'test' });
368
387
  ```
369
388
 
370
- In most cases you don't need these directly `toX12()` automatically stamps any empty or all-zero
371
- ISA13/GS06/ST02 fields. ISA13 and GS06 use `getNextControlNumber`; ST02 is assigned `"0001"`. This includes the
372
- zero placeholders returned by acknowledgment generation.
373
- Once a counter has incremented, the API rejects later `setControlNumber` calls to avoid resetting active sequences.
374
- Retried `setControlNumber` calls with the same already-stored seed are treated as idempotent no-ops.
375
- The SDK surfaces that as a `ControlNumberError` with `code: "counter_already_started"`:
389
+ `getControlNumberPolicy()` returns `configured` and `separateCounters`. After test traffic starts, you cannot turn
390
+ separate counters off. `setControlNumberPolicy()` reports this as a `ControlNumberError` with the
391
+ `counter_policy_locked` code.
392
+
393
+ ### Automatic environment routing
394
+
395
+ The SDK reads `UNNBOUND_ENVIRONMENT` and sends it with counter operations only when its value is `sandbox`,
396
+ `staging`, or `production`. The service selects the lane:
397
+
398
+ | Policy | Production environment | Sandbox or staging |
399
+ | --- | --- | --- |
400
+ | No policy row | Production lane | Production lane |
401
+ | `separateCounters: false` | Production lane | Production lane |
402
+ | `separateCounters: true` | Production lane | Test lane |
403
+
404
+ Older SDK versions do not send the environment, so their operations use the production lane. This is the historical
405
+ sequence that all environments use when separation is off. ISA15
406
+ (`UsageIndicator_15`) never selects a counter. Set ISA15 independently to the value required by the interchange.
407
+ `toX12()` does not change it.
408
+
409
+ ### Manage counters directly
376
410
 
377
411
  ```typescript
378
- import { isControlNumberError, setControlNumber } from '@ontemper/edi';
412
+ import { getCurrentControlNumber, getNextControlNumber, setControlNumber } from '@ontemper/edi';
379
413
 
380
- try {
381
- await setControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER', 1000);
382
- } catch (error) {
383
- if (isControlNumberError(error) && error.code === 'counter_already_started') {
384
- // Counter is already active; do not reset it.
385
- }
386
- }
414
+ const next = await getNextControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER');
415
+ const current = await getCurrentControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER');
416
+ // The seed is last-used. To allocate 4601 next, seed 4600.
417
+ await setControlNumber('ISA13', 'ZZ:SENDER', 'ZZ:RECEIVER', 4600, 9, { lane: 'production' });
387
418
  ```
388
419
 
389
- Requires `UNNBOUND_API_URL` environment variable (pre-configured in all workflow environments).
420
+ Use the optional `{ lane: 'production' | 'test' }` argument on `getCurrentControlNumber()` and `setControlNumber()`
421
+ to inspect or seed a specific lane. `getNextControlNumber()` always uses automatic environment routing and does not
422
+ accept a lane.
423
+
424
+ The seeded value is the last-used number, and the next allocation returns the seed plus one. For example, if you
425
+ expect the next ISA13 value to be 4601, seed 4600. Set this value before the counter increments. The service treats a
426
+ repeated seed with the same stored value as an idempotent operation. It rejects other updates after the counter
427
+ starts with a `ControlNumberError` whose code is `counter_already_started`.
428
+
429
+ These helpers require `UNNBOUND_API_URL`, which is preconfigured in workflow environments.
390
430
 
391
431
  ## Requirements
392
432
 
@@ -1,5 +1,27 @@
1
1
  /** @public */
2
- export type ControlNumberErrorCode = 'counter_already_started' | 'control_number_bad_request' | 'control_number_unauthorized' | 'control_number_unknown_error';
2
+ export type ControlNumberCounterName = 'ISA13' | 'GS06';
3
+ /** @public */
4
+ export type ControlNumberLane = 'production' | 'test';
5
+ /** @public */
6
+ export interface ControlNumberLaneOptions {
7
+ readonly lane?: ControlNumberLane;
8
+ }
9
+ /** @public */
10
+ export interface SetControlNumberPolicyOptions {
11
+ readonly separateCounters: boolean;
12
+ }
13
+ /** @public */
14
+ export interface SetControlNumberPolicyResponse {
15
+ readonly tradingPartnerKey: string;
16
+ readonly separateCounters: boolean;
17
+ }
18
+ /** @public */
19
+ export interface GetControlNumberPolicyResponse {
20
+ readonly configured: boolean;
21
+ readonly separateCounters: boolean;
22
+ }
23
+ /** @public */
24
+ export type ControlNumberErrorCode = 'counter_already_started' | 'counter_policy_locked' | 'control_number_bad_request' | 'control_number_unauthorized' | 'control_number_unknown_error';
3
25
  interface ControlNumberErrorOptions extends ErrorOptions {
4
26
  code: ControlNumberErrorCode;
5
27
  message: string;
@@ -23,15 +45,19 @@ export declare const isControlNumberError: (error: unknown) => error is ControlN
23
45
  * @param maxDigits - Zero-pad to this width (default: 9 for ISA13)
24
46
  */
25
47
  /** @public */
26
- export declare function getNextControlNumber(counterName: string, senderId: string, receiverId: string, maxDigits?: number): Promise<string>;
48
+ export declare function getNextControlNumber(counterName: ControlNumberCounterName, senderId: string, receiverId: string, maxDigits?: number): Promise<string>;
27
49
  /**
28
50
  * Get the current control number value without incrementing.
29
51
  */
30
52
  /** @public */
31
- export declare function getCurrentControlNumber(counterName: string, senderId: string, receiverId: string, maxDigits?: number): Promise<string>;
53
+ export declare function getCurrentControlNumber(counterName: ControlNumberCounterName, senderId: string, receiverId: string, maxDigits?: number, options?: ControlNumberLaneOptions): Promise<string>;
32
54
  /**
33
55
  * Set a control number to a specific value before the counter starts (for migration/seeding).
34
56
  */
35
57
  /** @public */
36
- export declare function setControlNumber(counterName: string, senderId: string, receiverId: string, value: number, maxDigits?: number): Promise<string>;
58
+ export declare function setControlNumber(counterName: ControlNumberCounterName, senderId: string, receiverId: string, value: number, maxDigits?: number, options?: ControlNumberLaneOptions): Promise<string>;
59
+ /** @public */
60
+ export declare function setControlNumberPolicy(senderId: string, receiverId: string, { separateCounters }: SetControlNumberPolicyOptions): Promise<SetControlNumberPolicyResponse>;
61
+ /** @public */
62
+ export declare function getControlNumberPolicy(senderId: string, receiverId: string): Promise<GetControlNumberPolicyResponse>;
37
63
  export {};
@@ -37,6 +37,8 @@ exports.isControlNumberError = exports.ControlNumberError = void 0;
37
37
  exports.getNextControlNumber = getNextControlNumber;
38
38
  exports.getCurrentControlNumber = getCurrentControlNumber;
39
39
  exports.setControlNumber = setControlNumber;
40
+ exports.setControlNumberPolicy = setControlNumberPolicy;
41
+ exports.getControlNumberPolicy = getControlNumberPolicy;
40
42
  const axios_1 = __importStar(require("axios"));
41
43
  let _client = null;
42
44
  function getApiClient() {
@@ -78,10 +80,23 @@ exports.ControlNumberError = ControlNumberError;
78
80
  /** @public */
79
81
  const isControlNumberError = (error) => error instanceof ControlNumberError;
80
82
  exports.isControlNumberError = isControlNumberError;
81
- function unwrapControlNumberError(error) {
83
+ const getEnvironment = () => {
84
+ const environment = process.env.UNNBOUND_ENVIRONMENT;
85
+ return environment === 'sandbox' || environment === 'staging' || environment === 'production'
86
+ ? environment
87
+ : undefined;
88
+ };
89
+ function unwrapControlNumberError(error, operation) {
82
90
  if (error instanceof ControlNumberError)
83
91
  throw error;
84
92
  if ((0, axios_1.isAxiosError)(error)) {
93
+ if (error.response?.status === 409 && operation === 'policy') {
94
+ throw new ControlNumberError({
95
+ code: 'counter_policy_locked',
96
+ message: 'Separate counters cannot be disabled after test traffic has started. Keep separate counters enabled or contact Temper support.',
97
+ cause: error,
98
+ });
99
+ }
85
100
  if (error.response?.status === 409) {
86
101
  throw new ControlNumberError({
87
102
  code: 'counter_already_started',
@@ -92,7 +107,7 @@ function unwrapControlNumberError(error) {
92
107
  if (error.response?.status === 400) {
93
108
  throw new ControlNumberError({
94
109
  code: 'control_number_bad_request',
95
- message: 'Invalid EDI control number request. Counter name, trading partner IDs, and value are required.',
110
+ message: "Invalid EDI control number request. Use counter name 'ISA13' or 'GS06' with trading partner IDs and, for set, a value.",
96
111
  cause: error,
97
112
  });
98
113
  }
@@ -110,6 +125,21 @@ function unwrapControlNumberError(error) {
110
125
  cause: error,
111
126
  });
112
127
  }
128
+ async function requestControlNumber(operation, input) {
129
+ const environment = getEnvironment();
130
+ const request = { ...input, ...(environment ? { environment } : {}) };
131
+ try {
132
+ const response = operation === 'count'
133
+ ? await getApiClient().get(`/api/v2/internal/edi/control-numbers/${operation}`, {
134
+ params: request,
135
+ })
136
+ : await getApiClient().post(`/api/v2/internal/edi/control-numbers/${operation}`, request);
137
+ return response.data;
138
+ }
139
+ catch (error) {
140
+ return unwrapControlNumberError(error, operation);
141
+ }
142
+ }
113
143
  /**
114
144
  * Get the next control number for an EDI counter.
115
145
  *
@@ -123,7 +153,7 @@ function unwrapControlNumberError(error) {
123
153
  */
124
154
  /** @public */
125
155
  async function getNextControlNumber(counterName, senderId, receiverId, maxDigits = 9) {
126
- const { data } = await getApiClient().post('/api/internal/edi/control-numbers/increment', {
156
+ const data = await requestControlNumber('increment', {
127
157
  tradingPartnerKey: `${senderId}:${receiverId}`,
128
158
  counterName,
129
159
  });
@@ -133,12 +163,11 @@ async function getNextControlNumber(counterName, senderId, receiverId, maxDigits
133
163
  * Get the current control number value without incrementing.
134
164
  */
135
165
  /** @public */
136
- async function getCurrentControlNumber(counterName, senderId, receiverId, maxDigits = 9) {
137
- const { data } = await getApiClient().get('/api/internal/edi/control-numbers/count', {
138
- params: {
139
- tradingPartnerKey: `${senderId}:${receiverId}`,
140
- counterName,
141
- },
166
+ async function getCurrentControlNumber(counterName, senderId, receiverId, maxDigits = 9, options) {
167
+ const data = await requestControlNumber('count', {
168
+ tradingPartnerKey: `${senderId}:${receiverId}`,
169
+ counterName,
170
+ ...(options?.lane ? { lane: options.lane } : {}),
142
171
  });
143
172
  return String(data.value).padStart(maxDigits, '0');
144
173
  }
@@ -146,13 +175,40 @@ async function getCurrentControlNumber(counterName, senderId, receiverId, maxDig
146
175
  * Set a control number to a specific value before the counter starts (for migration/seeding).
147
176
  */
148
177
  /** @public */
149
- async function setControlNumber(counterName, senderId, receiverId, value, maxDigits = 9) {
150
- const { data } = await getApiClient()
151
- .post('/api/internal/edi/control-numbers/set', {
178
+ async function setControlNumber(counterName, senderId, receiverId, value, maxDigits = 9, options) {
179
+ const data = await requestControlNumber('set', {
152
180
  tradingPartnerKey: `${senderId}:${receiverId}`,
153
181
  counterName,
154
182
  value,
155
- })
156
- .catch(unwrapControlNumberError);
183
+ ...(options?.lane ? { lane: options.lane } : {}),
184
+ });
157
185
  return String(data.value).padStart(maxDigits, '0');
158
186
  }
187
+ /** @public */
188
+ async function setControlNumberPolicy(senderId, receiverId, { separateCounters }) {
189
+ try {
190
+ const response = await getApiClient().post('/api/v2/internal/edi/control-numbers/policy', {
191
+ tradingPartnerKey: `${senderId}:${receiverId}`,
192
+ separateCounters,
193
+ });
194
+ return response.data;
195
+ }
196
+ catch (error) {
197
+ return unwrapControlNumberError(error, 'policy');
198
+ }
199
+ }
200
+ /** @public */
201
+ async function getControlNumberPolicy(senderId, receiverId) {
202
+ try {
203
+ const response = await getApiClient().get('/api/v2/internal/edi/control-numbers/policy', {
204
+ params: { tradingPartnerKey: `${senderId}:${receiverId}` },
205
+ });
206
+ return {
207
+ configured: response.data.configured,
208
+ separateCounters: response.data.separateCounters,
209
+ };
210
+ }
211
+ catch (error) {
212
+ return unwrapControlNumberError(error, 'policy');
213
+ }
214
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { type X12Interchange } from './edination-client';
2
- export type { ControlNumberErrorCode } from './control-numbers';
3
- export { ControlNumberError, getCurrentControlNumber, getNextControlNumber, isControlNumberError, setControlNumber, } from './control-numbers';
1
+ import type { X12Interchange } from './edination-client';
2
+ export type { ControlNumberCounterName, ControlNumberErrorCode, ControlNumberLane, ControlNumberLaneOptions, GetControlNumberPolicyResponse, SetControlNumberPolicyOptions, SetControlNumberPolicyResponse, } from './control-numbers';
3
+ export { ControlNumberError, getControlNumberPolicy, getCurrentControlNumber, getNextControlNumber, isControlNumberError, setControlNumber, setControlNumberPolicy, } from './control-numbers';
4
4
  export * from './edination-client/model';
5
5
  interface UnnboundErrorOptions<C extends string = string> extends ErrorOptions {
6
6
  message: string;
package/dist/index.js CHANGED
@@ -36,17 +36,20 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
36
36
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.TemperEdiClient = exports.isEdiInfrastructureError = exports.EdiInfrastructureError = exports.isTemperEdiClientError = exports.TemperEdiClientError = exports.setControlNumber = exports.isControlNumberError = exports.getNextControlNumber = exports.getCurrentControlNumber = exports.ControlNumberError = void 0;
39
+ exports.TemperEdiClient = exports.isEdiInfrastructureError = exports.EdiInfrastructureError = exports.isTemperEdiClientError = exports.TemperEdiClientError = exports.setControlNumberPolicy = exports.setControlNumber = exports.isControlNumberError = exports.getNextControlNumber = exports.getCurrentControlNumber = exports.getControlNumberPolicy = exports.ControlNumberError = void 0;
40
40
  const axios_1 = __importStar(require("axios"));
41
41
  const unnbound_logger_sdk_1 = require("unnbound-logger-sdk");
42
42
  const internal_1 = require("unnbound-logger-sdk/dist/internal");
43
+ const control_numbers_1 = require("./control-numbers");
43
44
  const edination_client_1 = require("./edination-client");
44
- var control_numbers_1 = require("./control-numbers");
45
- Object.defineProperty(exports, "ControlNumberError", { enumerable: true, get: function () { return control_numbers_1.ControlNumberError; } });
46
- Object.defineProperty(exports, "getCurrentControlNumber", { enumerable: true, get: function () { return control_numbers_1.getCurrentControlNumber; } });
47
- Object.defineProperty(exports, "getNextControlNumber", { enumerable: true, get: function () { return control_numbers_1.getNextControlNumber; } });
48
- Object.defineProperty(exports, "isControlNumberError", { enumerable: true, get: function () { return control_numbers_1.isControlNumberError; } });
49
- Object.defineProperty(exports, "setControlNumber", { enumerable: true, get: function () { return control_numbers_1.setControlNumber; } });
45
+ var control_numbers_2 = require("./control-numbers");
46
+ Object.defineProperty(exports, "ControlNumberError", { enumerable: true, get: function () { return control_numbers_2.ControlNumberError; } });
47
+ Object.defineProperty(exports, "getControlNumberPolicy", { enumerable: true, get: function () { return control_numbers_2.getControlNumberPolicy; } });
48
+ Object.defineProperty(exports, "getCurrentControlNumber", { enumerable: true, get: function () { return control_numbers_2.getCurrentControlNumber; } });
49
+ Object.defineProperty(exports, "getNextControlNumber", { enumerable: true, get: function () { return control_numbers_2.getNextControlNumber; } });
50
+ Object.defineProperty(exports, "isControlNumberError", { enumerable: true, get: function () { return control_numbers_2.isControlNumberError; } });
51
+ Object.defineProperty(exports, "setControlNumber", { enumerable: true, get: function () { return control_numbers_2.setControlNumber; } });
52
+ Object.defineProperty(exports, "setControlNumberPolicy", { enumerable: true, get: function () { return control_numbers_2.setControlNumberPolicy; } });
50
53
  __exportStar(require("./edination-client/model"), exports);
51
54
  class UnnboundError extends Error {
52
55
  code;
@@ -154,7 +157,6 @@ class TemperEdiClient {
154
157
  * Zero-filled values from acknowledgment generation are placeholders, not manual overrides.
155
158
  */
156
159
  async stampControlNumbers(input) {
157
- const { getNextControlNumber } = await import('./control-numbers.js');
158
160
  const isa = input.ISA;
159
161
  const senderQual = (isa.SenderIDQualifier_5 || '').trim();
160
162
  const senderId = (isa.InterchangeSenderID_6 || '').trim();
@@ -162,20 +164,18 @@ class TemperEdiClient {
162
164
  const receiverId = (isa.InterchangeReceiverID_8 || '').trim();
163
165
  const senderKey = `${senderQual}:${senderId}`;
164
166
  const receiverKey = `${receiverQual}:${receiverId}`;
165
- // Phase 1: Fetch all needed control numbers (no mutations yet).
166
- // If any fetch fails, no fields are mutated — avoids partial stamping.
167
+ // Allocate before mutating so a failed request cannot leave partial stamps.
167
168
  const isa13 = needsControlNumber(isa.InterchangeControlNumber_13)
168
- ? await getNextControlNumber('ISA13', senderKey, receiverKey, 9)
169
+ ? await (0, control_numbers_1.getNextControlNumber)('ISA13', senderKey, receiverKey, 9)
169
170
  : null;
170
171
  const gs06Values = [];
171
172
  const groups = input.Groups ?? [];
172
173
  for (let i = 0; i < groups.length; i++) {
173
174
  if (needsControlNumber(groups[i].GS.GroupControlNumber_6)) {
174
- const value = await getNextControlNumber('GS06', senderKey, receiverKey, 9);
175
+ const value = await (0, control_numbers_1.getNextControlNumber)('GS06', senderKey, receiverKey, 9);
175
176
  gs06Values.push({ index: i, value });
176
177
  }
177
178
  }
178
- // Phase 2: Apply all mutations (only reached if all fetches succeeded).
179
179
  if (isa13) {
180
180
  isa.InterchangeControlNumber_13 = isa13;
181
181
  if (input.IEATrailers?.length) {
@@ -189,7 +189,6 @@ class TemperEdiClient {
189
189
  group.GETrailers[0].GroupControlNumber_2 = value;
190
190
  }
191
191
  }
192
- // ST02 — always "0001" (pure assignment, no external calls)
193
192
  for (const group of groups) {
194
193
  for (const tx of group.Transactions) {
195
194
  if (needsControlNumber(tx?.ST?.TransactionSetControlNumber_02)) {
@@ -202,14 +201,12 @@ class TemperEdiClient {
202
201
  }
203
202
  }
204
203
  async toX12({ input }) {
205
- // Auto-stamp control numbers before writing X12
206
204
  try {
207
205
  await this.stampControlNumbers(input);
208
206
  }
209
207
  catch (error) {
210
208
  // Log but don't fail — workflows can still set control numbers manually
211
- const { logger } = await import('unnbound-logger-sdk');
212
- logger.warn({ err: error }, '[EDI SDK] Failed to auto-stamp control numbers, proceeding without');
209
+ unnbound_logger_sdk_1.logger.warn({ err: error }, '[EDI SDK] Failed to auto-stamp control numbers, proceeding without');
213
210
  }
214
211
  return (0, unnbound_logger_sdk_1.startSpan)('JSON to X12', () => {
215
212
  return this.X12.x12WritePost({ x12Interchange: input })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ontemper/edi",
3
3
  "description": "An EDI client with structured logging.",
4
- "version": "1.1.7",
4
+ "version": "1.2.0-beta.2",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "author": "Unnbound Team",
@@ -15,7 +15,7 @@
15
15
  "url": "https://github.com/unnbounddev/unnbound-sdks/issues"
16
16
  },
17
17
  "dependencies": {
18
- "axios": "1.16.0",
18
+ "axios": "1.18.0",
19
19
  "unnbound-logger-sdk": "3.0.37"
20
20
  },
21
21
  "devDependencies": {