@zeniai/client-epic-state 5.0.90-betaAR7 → 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.
@@ -153,14 +153,17 @@ const getLineClassIdentifiersForTransaction = (transaction) => {
153
153
  return [];
154
154
  };
155
155
  /**
156
- * Type-narrowing guard for the three persisted line shapes that carry an
157
- * `account` + `class`. Kept as a single helper so the type list stays in
158
- * one place if a new categorizable line variant is added.
156
+ * Type-narrowing guard for the three persisted line shapes that extend
157
+ * `TransactionWithAccountAndClassLine` they carry an `account` + `class`
158
+ * AND `vendor` + `customer` as optional per-line fields. Kept as a single
159
+ * helper so the type list stays in one place if a new categorizable
160
+ * line variant is added; reused by the category, class, and payee
161
+ * per-line resolvers below.
159
162
  *
160
- * Typed against the union element produced by indexing `lines[number]` on
161
- * the broader transaction type the parameter is permitted to be
162
- * `undefined` so callers can pass a possibly-sparse `lines` element
163
- * without an extra null check.
163
+ * Typed against a structural `{type?: string}` shape rather than the
164
+ * concrete `Line` union so callers can pass a possibly-sparse element
165
+ * without an extra null check. The cast to
166
+ * `TransactionWithAccountAndClassLine` happens at the call sites.
164
167
  */
165
168
  const isAccountAndClassLine = (line) => line?.type === 'transaction_with_account_and_class_line' ||
166
169
  line?.type === 'transaction_with_product_or_service_line' ||
@@ -204,14 +207,112 @@ const resolveClassIdentifier = (classData) => {
204
207
  }
205
208
  return undefined;
206
209
  };
210
+ /**
211
+ * Resolve a single line's payee name. Vendor wins over customer
212
+ * (preserved from the single-scalar predecessor — TC transactions are
213
+ * overwhelmingly expense-side). `isNonEmptyString` so an empty-string
214
+ * placeholder on `vendor.name` falls through to `customer.name` rather
215
+ * than shadowing it.
216
+ */
217
+ const resolveLineLevelPayee = (vendor, customer) => {
218
+ if (isNonEmptyString(vendor?.name)) {
219
+ return vendor?.name;
220
+ }
221
+ if (isNonEmptyString(customer?.name)) {
222
+ return customer?.name;
223
+ }
224
+ return undefined;
225
+ };
226
+ /**
227
+ * All payee names that could legitimately identify a transaction, across
228
+ * every place TC stores payee data:
229
+ *
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.
242
+ *
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
267
+ * to the absent-value contract (only `not_equal` matches; every other
268
+ * operator excludes).
269
+ */
270
+ const getLinePayeesForTransaction = (transaction) => {
271
+ const localData = transaction?.transactionLocalData?.transactionReviewLocalData;
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);
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
+ });
287
+ }
288
+ // Persisted per-line vendor / customer (only on the three line
289
+ // shapes that actually carry them — see `isAccountAndClassLine`).
290
+ if (transaction?.transaction?.lines != null &&
291
+ transaction.transaction.lines.length > 0) {
292
+ transaction.transaction.lines.forEach((line) => {
293
+ if (!isAccountAndClassLine(line)) {
294
+ return;
295
+ }
296
+ const typedLine = line;
297
+ addIfPresent(resolveLineLevelPayee(typedLine.vendor, typedLine.customer));
298
+ });
299
+ }
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);
307
+ };
207
308
  /**
208
309
  * Resolve the comparable value for a transaction against a given filter field.
209
310
  * Returns undefined when the transaction doesn't carry the field — callers
210
311
  * decide whether absence counts as a match (for 'not_equal' it does).
211
312
  *
212
- * NOTE: the `'amount'`, `'category'`, and `'class'` fields are NOT handled
213
- * here. All three use per-line matching via the corresponding
214
- * `getLine*ForTransaction` helper and an early-return in
313
+ * NOTE: the `'amount'`, `'category'`, `'class'`, and `'payee'` fields
314
+ * are NOT handled here. All four use per-line matching via the
315
+ * corresponding `getLine*ForTransaction` helper and an early-return in
215
316
  * `transactionMatchesCategory` — they would otherwise need to return a
216
317
  * non-scalar value that doesn't fit this helper's contract. Cases are
217
318
  * preserved so the switch stays exhaustive over `TransactionFilterCategoryField`.
@@ -224,46 +325,9 @@ const getCategoryValueForTransaction = (key, transaction) => {
224
325
  return undefined;
225
326
  }
226
327
  case 'payee': {
227
- /*
228
- * localData wins over the top-level transaction fields. Two reasons:
229
- *
230
- * 1. Matches what the row renders. `TransactionCategorizationListRow`
231
- * reads `transaction.transactionReviewLocalData.vendor?.name` as
232
- * its canonical payee source — not `transaction.vendorName`. If
233
- * the user has manually picked a vendor in the grid (still
234
- * unsaved), the row updates immediately but the pre-fix filter
235
- * kept comparing against the stale top-level value, so filtering
236
- * "Acme" against a row the user just relabeled to "Acme" would
237
- * silently exclude it.
238
- *
239
- * 2. Brings payee in line with the other content-bearing fields.
240
- * After this change, every field that has a localData mirror
241
- * (`amount`, `category`, `class`, `payee`) reads localData
242
- * first. The remaining fields (`payment_account_name`,
243
- * `payment_account_type`) don't have a localData equivalent
244
- * yet — when they do, this precedence becomes the universal
245
- * rule.
246
- *
247
- * vendor is checked before customer at each level — TC transactions
248
- * are overwhelmingly expense-side, so vendor is the common case. A
249
- * transaction with both a vendor AND a customer locally set is rare
250
- * and the existing behavior (vendor wins) is preserved.
251
- */
252
- const localData = transaction.transactionLocalData?.transactionReviewLocalData;
253
- const localVendorName = localData?.vendor?.name;
254
- if (isNonEmptyString(localVendorName)) {
255
- return localVendorName;
256
- }
257
- const localCustomerName = localData?.customer?.name;
258
- if (isNonEmptyString(localCustomerName)) {
259
- return localCustomerName;
260
- }
261
- if (isNonEmptyString(transaction.vendorName)) {
262
- return transaction.vendorName;
263
- }
264
- if (isNonEmptyString(transaction.customerName)) {
265
- return transaction.customerName;
266
- }
328
+ // Handled per-line in `transactionMatchesCategory` — see
329
+ // `getLinePayeesForTransaction`. The transaction-level localData /
330
+ // top-level chain lives inside that helper's priority-3 fallback.
267
331
  return undefined;
268
332
  }
269
333
  case 'payment_account_name': {
@@ -339,6 +403,29 @@ const matchAmount = (amount, category) => {
339
403
  return op === 'not_equal' ? !matched : matched;
340
404
  };
341
405
  const transactionMatchesCategory = (transaction, category) => {
406
+ if (category.field === 'payee') {
407
+ // Per-line matching for payee — mirrors the amount / category /
408
+ // class pattern. The row's display reads `line.vendor?.name` for
409
+ // non-vendor-typed transactions (Journal Entries, etc.), so the
410
+ // filter has to look at line-level payee data too or it silently
411
+ // misses every JE whose vendor lives on the line. See the JSDoc
412
+ // on `getLinePayeesForTransaction` for the full precedence.
413
+ //
414
+ // `not_equal` reads the same way it does for category/class:
415
+ // "at least one line's payee is NOT one of the filter values".
416
+ // A JE with lines [Amazon, Acme] matches `payee != Amazon` via
417
+ // the Acme line; a bill or JE whose every line says "Amazon"
418
+ // does not match `payee != Amazon`.
419
+ const linePayees = getLinePayeesForTransaction(transaction);
420
+ if (linePayees.length === 0) {
421
+ return category.matchingOperator === 'not_equal';
422
+ }
423
+ const filterValuesAsStrings = new Set(category.values.map((v) => v.toString()));
424
+ return linePayees.some((name) => {
425
+ const inList = filterValuesAsStrings.has(name);
426
+ return category.matchingOperator === 'not_equal' ? !inList : inList;
427
+ });
428
+ }
342
429
  if (category.field === 'category' || category.field === 'class') {
343
430
  // Per-line matching for category / class — mirrors the amount
344
431
  // pattern below. A multi-line transaction surfaces when ANY of its
@@ -156,14 +156,17 @@ const getLineClassIdentifiersForTransaction = (transaction) => {
156
156
  return [];
157
157
  };
158
158
  /**
159
- * Type-narrowing guard for the three persisted line shapes that carry an
160
- * `account` + `class`. Kept as a single helper so the type list stays in
161
- * one place if a new categorizable line variant is added.
159
+ * Type-narrowing guard for the three persisted line shapes that extend
160
+ * `TransactionWithAccountAndClassLine` they carry an `account` + `class`
161
+ * AND `vendor` + `customer` as optional per-line fields. Kept as a single
162
+ * helper so the type list stays in one place if a new categorizable
163
+ * line variant is added; reused by the category, class, and payee
164
+ * per-line resolvers below.
162
165
  *
163
- * Typed against the union element produced by indexing `lines[number]` on
164
- * the broader transaction type the parameter is permitted to be
165
- * `undefined` so callers can pass a possibly-sparse `lines` element
166
- * without an extra null check.
166
+ * Typed against a structural `{type?: string}` shape rather than the
167
+ * concrete `Line` union so callers can pass a possibly-sparse element
168
+ * without an extra null check. The cast to
169
+ * `TransactionWithAccountAndClassLine` happens at the call sites.
167
170
  */
168
171
  const isAccountAndClassLine = (line) => line?.type === 'transaction_with_account_and_class_line' ||
169
172
  line?.type === 'transaction_with_product_or_service_line' ||
@@ -207,14 +210,112 @@ const resolveClassIdentifier = (classData) => {
207
210
  }
208
211
  return undefined;
209
212
  };
213
+ /**
214
+ * Resolve a single line's payee name. Vendor wins over customer
215
+ * (preserved from the single-scalar predecessor — TC transactions are
216
+ * overwhelmingly expense-side). `isNonEmptyString` so an empty-string
217
+ * placeholder on `vendor.name` falls through to `customer.name` rather
218
+ * than shadowing it.
219
+ */
220
+ const resolveLineLevelPayee = (vendor, customer) => {
221
+ if (isNonEmptyString(vendor?.name)) {
222
+ return vendor?.name;
223
+ }
224
+ if (isNonEmptyString(customer?.name)) {
225
+ return customer?.name;
226
+ }
227
+ return undefined;
228
+ };
229
+ /**
230
+ * All payee names that could legitimately identify a transaction, across
231
+ * every place TC stores payee data:
232
+ *
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.
245
+ *
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
270
+ * to the absent-value contract (only `not_equal` matches; every other
271
+ * operator excludes).
272
+ */
273
+ const getLinePayeesForTransaction = (transaction) => {
274
+ const localData = transaction?.transactionLocalData?.transactionReviewLocalData;
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);
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
+ });
290
+ }
291
+ // Persisted per-line vendor / customer (only on the three line
292
+ // shapes that actually carry them — see `isAccountAndClassLine`).
293
+ if (transaction?.transaction?.lines != null &&
294
+ transaction.transaction.lines.length > 0) {
295
+ transaction.transaction.lines.forEach((line) => {
296
+ if (!isAccountAndClassLine(line)) {
297
+ return;
298
+ }
299
+ const typedLine = line;
300
+ addIfPresent(resolveLineLevelPayee(typedLine.vendor, typedLine.customer));
301
+ });
302
+ }
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);
310
+ };
210
311
  /**
211
312
  * Resolve the comparable value for a transaction against a given filter field.
212
313
  * Returns undefined when the transaction doesn't carry the field — callers
213
314
  * decide whether absence counts as a match (for 'not_equal' it does).
214
315
  *
215
- * NOTE: the `'amount'`, `'category'`, and `'class'` fields are NOT handled
216
- * here. All three use per-line matching via the corresponding
217
- * `getLine*ForTransaction` helper and an early-return in
316
+ * NOTE: the `'amount'`, `'category'`, `'class'`, and `'payee'` fields
317
+ * are NOT handled here. All four use per-line matching via the
318
+ * corresponding `getLine*ForTransaction` helper and an early-return in
218
319
  * `transactionMatchesCategory` — they would otherwise need to return a
219
320
  * non-scalar value that doesn't fit this helper's contract. Cases are
220
321
  * preserved so the switch stays exhaustive over `TransactionFilterCategoryField`.
@@ -227,46 +328,9 @@ const getCategoryValueForTransaction = (key, transaction) => {
227
328
  return undefined;
228
329
  }
229
330
  case 'payee': {
230
- /*
231
- * localData wins over the top-level transaction fields. Two reasons:
232
- *
233
- * 1. Matches what the row renders. `TransactionCategorizationListRow`
234
- * reads `transaction.transactionReviewLocalData.vendor?.name` as
235
- * its canonical payee source — not `transaction.vendorName`. If
236
- * the user has manually picked a vendor in the grid (still
237
- * unsaved), the row updates immediately but the pre-fix filter
238
- * kept comparing against the stale top-level value, so filtering
239
- * "Acme" against a row the user just relabeled to "Acme" would
240
- * silently exclude it.
241
- *
242
- * 2. Brings payee in line with the other content-bearing fields.
243
- * After this change, every field that has a localData mirror
244
- * (`amount`, `category`, `class`, `payee`) reads localData
245
- * first. The remaining fields (`payment_account_name`,
246
- * `payment_account_type`) don't have a localData equivalent
247
- * yet — when they do, this precedence becomes the universal
248
- * rule.
249
- *
250
- * vendor is checked before customer at each level — TC transactions
251
- * are overwhelmingly expense-side, so vendor is the common case. A
252
- * transaction with both a vendor AND a customer locally set is rare
253
- * and the existing behavior (vendor wins) is preserved.
254
- */
255
- const localData = transaction.transactionLocalData?.transactionReviewLocalData;
256
- const localVendorName = localData?.vendor?.name;
257
- if (isNonEmptyString(localVendorName)) {
258
- return localVendorName;
259
- }
260
- const localCustomerName = localData?.customer?.name;
261
- if (isNonEmptyString(localCustomerName)) {
262
- return localCustomerName;
263
- }
264
- if (isNonEmptyString(transaction.vendorName)) {
265
- return transaction.vendorName;
266
- }
267
- if (isNonEmptyString(transaction.customerName)) {
268
- return transaction.customerName;
269
- }
331
+ // Handled per-line in `transactionMatchesCategory` — see
332
+ // `getLinePayeesForTransaction`. The transaction-level localData /
333
+ // top-level chain lives inside that helper's priority-3 fallback.
270
334
  return undefined;
271
335
  }
272
336
  case 'payment_account_name': {
@@ -342,6 +406,29 @@ const matchAmount = (amount, category) => {
342
406
  return op === 'not_equal' ? !matched : matched;
343
407
  };
344
408
  const transactionMatchesCategory = (transaction, category) => {
409
+ if (category.field === 'payee') {
410
+ // Per-line matching for payee — mirrors the amount / category /
411
+ // class pattern. The row's display reads `line.vendor?.name` for
412
+ // non-vendor-typed transactions (Journal Entries, etc.), so the
413
+ // filter has to look at line-level payee data too or it silently
414
+ // misses every JE whose vendor lives on the line. See the JSDoc
415
+ // on `getLinePayeesForTransaction` for the full precedence.
416
+ //
417
+ // `not_equal` reads the same way it does for category/class:
418
+ // "at least one line's payee is NOT one of the filter values".
419
+ // A JE with lines [Amazon, Acme] matches `payee != Amazon` via
420
+ // the Acme line; a bill or JE whose every line says "Amazon"
421
+ // does not match `payee != Amazon`.
422
+ const linePayees = getLinePayeesForTransaction(transaction);
423
+ if (linePayees.length === 0) {
424
+ return category.matchingOperator === 'not_equal';
425
+ }
426
+ const filterValuesAsStrings = new Set(category.values.map((v) => v.toString()));
427
+ return linePayees.some((name) => {
428
+ const inList = filterValuesAsStrings.has(name);
429
+ return category.matchingOperator === 'not_equal' ? !inList : inList;
430
+ });
431
+ }
345
432
  if (category.field === 'category' || category.field === 'class') {
346
433
  // Per-line matching for category / class — mirrors the amount
347
434
  // pattern below. A multi-line transaction surfaces when ANY of its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.0.90-betaAR7",
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",