@zeniai/client-epic-state 5.0.90-betaAR8 → 5.0.90-betaAR9

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.
@@ -224,82 +224,86 @@ const resolveLineLevelPayee = (vendor, customer) => {
224
224
  return undefined;
225
225
  };
226
226
  /**
227
- * Per-line payee names for a transaction. Bridges the row's display
228
- * logic Journal Entries (and any future transaction type that
229
- * carries vendor/customer per line) put the payee on each line, while
230
- * Bills, Expenses, and Checks carry it at the transaction level. The
231
- * single-scalar predecessor only consulted the transaction level, so
232
- * JE rows whose vendor lived on the line silently missed every payee
233
- * filter even though the row visibly displayed the vendor name (the
234
- * row's `TransactionCategorizationListRow` already reads
235
- * `line.vendor?.name` for non-vendor-typed transactions).
227
+ * All payee names that could legitimately identify a transaction, across
228
+ * every place TC stores payee data:
236
229
  *
237
- * Source precedence:
238
- * 1. localData `lineItemById` locally-edited per-line vendor or
239
- * customer. Lines with neither field set are dropped from the
240
- * array; if at least one line has a name, that line set drives
241
- * matching (we don't mix line-level localData with the transaction-
242
- * level fallback below).
243
- * 2. Persisted `transaction.lines` per-line vendor or customer on
244
- * the three categorizable line shapes (see `isAccountAndClassLine`).
245
- * Other line types (`linked_transaction_line`, etc.) are skipped.
246
- * 3. Transaction-level fallback — localData transaction-level
247
- * vendor → localData transaction-level customer `transaction.
248
- * vendorName` → `transaction.customerName`. Wrapped in a single-
249
- * element array so the rest of the matcher stays per-line shaped.
230
+ * localData `lineItemById[*].vendor` / `.customer` — per-line
231
+ * vendor/customer the user has tagged in the grid. Populated for
232
+ * JEs (where the row reads per-line vendor) and sometimes for
233
+ * vendor-typed transactions too (e.g. when an AI vendor
234
+ * recommendation lands on a line).
235
+ * Persisted `transaction.lines[*].vendor` / `.customer` — same
236
+ * fields on the server snapshot of each line.
237
+ * localData transaction-level `vendor` / `customer` — what the row
238
+ * displays for vendor-typed transactions (Bills / Expenses /
239
+ * Checks).
240
+ * Top-level `transaction.vendorName` / `customerName` — pre-edit
241
+ * server snapshot.
250
242
  *
251
- * Returns `[]` when every source resolves empty; the caller falls back
243
+ * The function collects names from ALL of these into a single set
244
+ * rather than picking exclusively from the highest-priority source.
245
+ * Two reasons:
246
+ *
247
+ * 1. Different transaction types put the displayed payee in different
248
+ * places. JEs put it on the line; Bills put it at the transaction
249
+ * level. A strict-precedence rule that returns early on the first
250
+ * non-empty source ends up missing the OTHER place when both have
251
+ * data with different values — which is exactly the
252
+ * production failure mode that motivated this fix (a Bill whose
253
+ * localData line item carried an AI-recommended vendor name was
254
+ * excluded because the recommendation won priority over the
255
+ * tx-level "Amazon" the row actually displayed).
256
+ * 2. The single-scalar predecessor missed JE rows entirely because
257
+ * it only looked at the tx level. Unioning the line-level sources
258
+ * in fixes that without re-introducing the "first source wins"
259
+ * brittleness.
260
+ *
261
+ * False positives are bounded: a transaction matches `payee = X` only
262
+ * if `X` literally appears in one of its stored payee fields. Stale
263
+ * recommendation data being included means the filter is slightly more
264
+ * lenient than what the row displays, not arbitrary.
265
+ *
266
+ * Returns `[]` when no source carries any name; the caller falls back
252
267
  * to the absent-value contract (only `not_equal` matches; every other
253
268
  * operator excludes).
254
269
  */
255
270
  const getLinePayeesForTransaction = (transaction) => {
256
271
  const localData = transaction?.transactionLocalData?.transactionReviewLocalData;
257
- // Priority 1: localData line-level vendor / customer
258
- if (localData?.lineItemById != null) {
259
- const lineItems = Object.values(localData.lineItemById);
260
- if (lineItems.length > 0) {
261
- const linePayees = lineItems
262
- .map((lineItem) => resolveLineLevelPayee(lineItem?.vendor, lineItem?.customer))
263
- .filter(isNonEmptyString);
264
- if (linePayees.length > 0) {
265
- return linePayees;
266
- }
272
+ // Set used to dedupe — the same vendor name often appears in multiple
273
+ // sources (e.g. localData mirror of a persisted line) and we don't
274
+ // want it to be over-counted in the match list. Iteration order is
275
+ // insertion-order; `.some()` doesn't depend on order so this is fine.
276
+ const payees = new Set();
277
+ const addIfPresent = (name) => {
278
+ if (isNonEmptyString(name)) {
279
+ payees.add(name);
267
280
  }
281
+ };
282
+ // localData per-line vendor / customer
283
+ if (localData?.lineItemById != null) {
284
+ Object.values(localData.lineItemById).forEach((lineItem) => {
285
+ addIfPresent(resolveLineLevelPayee(lineItem?.vendor, lineItem?.customer));
286
+ });
268
287
  }
269
- // Priority 2: persisted line-level vendor / customer
288
+ // Persisted per-line vendor / customer (only on the three line
289
+ // shapes that actually carry them — see `isAccountAndClassLine`).
270
290
  if (transaction?.transaction?.lines != null &&
271
291
  transaction.transaction.lines.length > 0) {
272
- const linePayees = transaction.transaction.lines
273
- .map((line) => {
292
+ transaction.transaction.lines.forEach((line) => {
274
293
  if (!isAccountAndClassLine(line)) {
275
- return undefined;
294
+ return;
276
295
  }
277
296
  const typedLine = line;
278
- return resolveLineLevelPayee(typedLine.vendor, typedLine.customer);
279
- })
280
- .filter(isNonEmptyString);
281
- if (linePayees.length > 0) {
282
- return linePayees;
283
- }
284
- }
285
- // Priority 3: transaction-level fallback. localData wins over the
286
- // top-level fields to keep the existing precedence intact: unsaved
287
- // grid edits should drive the filter, not the pre-edit server state.
288
- const localVendorName = localData?.vendor?.name;
289
- if (isNonEmptyString(localVendorName)) {
290
- return [localVendorName];
291
- }
292
- const localCustomerName = localData?.customer?.name;
293
- if (isNonEmptyString(localCustomerName)) {
294
- return [localCustomerName];
295
- }
296
- if (isNonEmptyString(transaction.vendorName)) {
297
- return [transaction.vendorName];
298
- }
299
- if (isNonEmptyString(transaction.customerName)) {
300
- return [transaction.customerName];
297
+ addIfPresent(resolveLineLevelPayee(typedLine.vendor, typedLine.customer));
298
+ });
301
299
  }
302
- return [];
300
+ // Transaction-level: localData first so unsaved grid edits make it
301
+ // into the match set, then the pre-edit server fields.
302
+ addIfPresent(localData?.vendor?.name);
303
+ addIfPresent(localData?.customer?.name);
304
+ addIfPresent(transaction?.vendorName);
305
+ addIfPresent(transaction?.customerName);
306
+ return Array.from(payees);
303
307
  };
304
308
  /**
305
309
  * Resolve the comparable value for a transaction against a given filter field.
@@ -227,82 +227,86 @@ const resolveLineLevelPayee = (vendor, customer) => {
227
227
  return undefined;
228
228
  };
229
229
  /**
230
- * Per-line payee names for a transaction. Bridges the row's display
231
- * logic Journal Entries (and any future transaction type that
232
- * carries vendor/customer per line) put the payee on each line, while
233
- * Bills, Expenses, and Checks carry it at the transaction level. The
234
- * single-scalar predecessor only consulted the transaction level, so
235
- * JE rows whose vendor lived on the line silently missed every payee
236
- * filter even though the row visibly displayed the vendor name (the
237
- * row's `TransactionCategorizationListRow` already reads
238
- * `line.vendor?.name` for non-vendor-typed transactions).
230
+ * All payee names that could legitimately identify a transaction, across
231
+ * every place TC stores payee data:
239
232
  *
240
- * Source precedence:
241
- * 1. localData `lineItemById` locally-edited per-line vendor or
242
- * customer. Lines with neither field set are dropped from the
243
- * array; if at least one line has a name, that line set drives
244
- * matching (we don't mix line-level localData with the transaction-
245
- * level fallback below).
246
- * 2. Persisted `transaction.lines` per-line vendor or customer on
247
- * the three categorizable line shapes (see `isAccountAndClassLine`).
248
- * Other line types (`linked_transaction_line`, etc.) are skipped.
249
- * 3. Transaction-level fallback — localData transaction-level
250
- * vendor → localData transaction-level customer `transaction.
251
- * vendorName` → `transaction.customerName`. Wrapped in a single-
252
- * element array so the rest of the matcher stays per-line shaped.
233
+ * localData `lineItemById[*].vendor` / `.customer` — per-line
234
+ * vendor/customer the user has tagged in the grid. Populated for
235
+ * JEs (where the row reads per-line vendor) and sometimes for
236
+ * vendor-typed transactions too (e.g. when an AI vendor
237
+ * recommendation lands on a line).
238
+ * Persisted `transaction.lines[*].vendor` / `.customer` — same
239
+ * fields on the server snapshot of each line.
240
+ * localData transaction-level `vendor` / `customer` — what the row
241
+ * displays for vendor-typed transactions (Bills / Expenses /
242
+ * Checks).
243
+ * Top-level `transaction.vendorName` / `customerName` — pre-edit
244
+ * server snapshot.
253
245
  *
254
- * Returns `[]` when every source resolves empty; the caller falls back
246
+ * The function collects names from ALL of these into a single set
247
+ * rather than picking exclusively from the highest-priority source.
248
+ * Two reasons:
249
+ *
250
+ * 1. Different transaction types put the displayed payee in different
251
+ * places. JEs put it on the line; Bills put it at the transaction
252
+ * level. A strict-precedence rule that returns early on the first
253
+ * non-empty source ends up missing the OTHER place when both have
254
+ * data with different values — which is exactly the
255
+ * production failure mode that motivated this fix (a Bill whose
256
+ * localData line item carried an AI-recommended vendor name was
257
+ * excluded because the recommendation won priority over the
258
+ * tx-level "Amazon" the row actually displayed).
259
+ * 2. The single-scalar predecessor missed JE rows entirely because
260
+ * it only looked at the tx level. Unioning the line-level sources
261
+ * in fixes that without re-introducing the "first source wins"
262
+ * brittleness.
263
+ *
264
+ * False positives are bounded: a transaction matches `payee = X` only
265
+ * if `X` literally appears in one of its stored payee fields. Stale
266
+ * recommendation data being included means the filter is slightly more
267
+ * lenient than what the row displays, not arbitrary.
268
+ *
269
+ * Returns `[]` when no source carries any name; the caller falls back
255
270
  * to the absent-value contract (only `not_equal` matches; every other
256
271
  * operator excludes).
257
272
  */
258
273
  const getLinePayeesForTransaction = (transaction) => {
259
274
  const localData = transaction?.transactionLocalData?.transactionReviewLocalData;
260
- // Priority 1: localData line-level vendor / customer
261
- if (localData?.lineItemById != null) {
262
- const lineItems = Object.values(localData.lineItemById);
263
- if (lineItems.length > 0) {
264
- const linePayees = lineItems
265
- .map((lineItem) => resolveLineLevelPayee(lineItem?.vendor, lineItem?.customer))
266
- .filter(isNonEmptyString);
267
- if (linePayees.length > 0) {
268
- return linePayees;
269
- }
275
+ // Set used to dedupe — the same vendor name often appears in multiple
276
+ // sources (e.g. localData mirror of a persisted line) and we don't
277
+ // want it to be over-counted in the match list. Iteration order is
278
+ // insertion-order; `.some()` doesn't depend on order so this is fine.
279
+ const payees = new Set();
280
+ const addIfPresent = (name) => {
281
+ if (isNonEmptyString(name)) {
282
+ payees.add(name);
270
283
  }
284
+ };
285
+ // localData per-line vendor / customer
286
+ if (localData?.lineItemById != null) {
287
+ Object.values(localData.lineItemById).forEach((lineItem) => {
288
+ addIfPresent(resolveLineLevelPayee(lineItem?.vendor, lineItem?.customer));
289
+ });
271
290
  }
272
- // Priority 2: persisted line-level vendor / customer
291
+ // Persisted per-line vendor / customer (only on the three line
292
+ // shapes that actually carry them — see `isAccountAndClassLine`).
273
293
  if (transaction?.transaction?.lines != null &&
274
294
  transaction.transaction.lines.length > 0) {
275
- const linePayees = transaction.transaction.lines
276
- .map((line) => {
295
+ transaction.transaction.lines.forEach((line) => {
277
296
  if (!isAccountAndClassLine(line)) {
278
- return undefined;
297
+ return;
279
298
  }
280
299
  const typedLine = line;
281
- return resolveLineLevelPayee(typedLine.vendor, typedLine.customer);
282
- })
283
- .filter(isNonEmptyString);
284
- if (linePayees.length > 0) {
285
- return linePayees;
286
- }
287
- }
288
- // Priority 3: transaction-level fallback. localData wins over the
289
- // top-level fields to keep the existing precedence intact: unsaved
290
- // grid edits should drive the filter, not the pre-edit server state.
291
- const localVendorName = localData?.vendor?.name;
292
- if (isNonEmptyString(localVendorName)) {
293
- return [localVendorName];
294
- }
295
- const localCustomerName = localData?.customer?.name;
296
- if (isNonEmptyString(localCustomerName)) {
297
- return [localCustomerName];
298
- }
299
- if (isNonEmptyString(transaction.vendorName)) {
300
- return [transaction.vendorName];
301
- }
302
- if (isNonEmptyString(transaction.customerName)) {
303
- return [transaction.customerName];
300
+ addIfPresent(resolveLineLevelPayee(typedLine.vendor, typedLine.customer));
301
+ });
304
302
  }
305
- return [];
303
+ // Transaction-level: localData first so unsaved grid edits make it
304
+ // into the match set, then the pre-edit server fields.
305
+ addIfPresent(localData?.vendor?.name);
306
+ addIfPresent(localData?.customer?.name);
307
+ addIfPresent(transaction?.vendorName);
308
+ addIfPresent(transaction?.customerName);
309
+ return Array.from(payees);
306
310
  };
307
311
  /**
308
312
  * Resolve the comparable value for a transaction against a given filter field.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.0.90-betaAR8",
3
+ "version": "5.0.90-betaAR9",
4
4
  "description": "Shared module between Web & Mobile containing required abstractions for state management, async network communication. ",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/esm/index.js",