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

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,108 @@ 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
+ * 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).
236
+ *
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.
250
+ *
251
+ * Returns `[]` when every source resolves empty; the caller falls back
252
+ * to the absent-value contract (only `not_equal` matches; every other
253
+ * operator excludes).
254
+ */
255
+ const getLinePayeesForTransaction = (transaction) => {
256
+ 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
+ }
267
+ }
268
+ }
269
+ // Priority 2: persisted line-level vendor / customer
270
+ if (transaction?.transaction?.lines != null &&
271
+ transaction.transaction.lines.length > 0) {
272
+ const linePayees = transaction.transaction.lines
273
+ .map((line) => {
274
+ if (!isAccountAndClassLine(line)) {
275
+ return undefined;
276
+ }
277
+ 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];
301
+ }
302
+ return [];
303
+ };
207
304
  /**
208
305
  * Resolve the comparable value for a transaction against a given filter field.
209
306
  * Returns undefined when the transaction doesn't carry the field — callers
210
307
  * decide whether absence counts as a match (for 'not_equal' it does).
211
308
  *
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
309
+ * NOTE: the `'amount'`, `'category'`, `'class'`, and `'payee'` fields
310
+ * are NOT handled here. All four use per-line matching via the
311
+ * corresponding `getLine*ForTransaction` helper and an early-return in
215
312
  * `transactionMatchesCategory` — they would otherwise need to return a
216
313
  * non-scalar value that doesn't fit this helper's contract. Cases are
217
314
  * preserved so the switch stays exhaustive over `TransactionFilterCategoryField`.
@@ -224,46 +321,9 @@ const getCategoryValueForTransaction = (key, transaction) => {
224
321
  return undefined;
225
322
  }
226
323
  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
- }
324
+ // Handled per-line in `transactionMatchesCategory` — see
325
+ // `getLinePayeesForTransaction`. The transaction-level localData /
326
+ // top-level chain lives inside that helper's priority-3 fallback.
267
327
  return undefined;
268
328
  }
269
329
  case 'payment_account_name': {
@@ -339,6 +399,29 @@ const matchAmount = (amount, category) => {
339
399
  return op === 'not_equal' ? !matched : matched;
340
400
  };
341
401
  const transactionMatchesCategory = (transaction, category) => {
402
+ if (category.field === 'payee') {
403
+ // Per-line matching for payee — mirrors the amount / category /
404
+ // class pattern. The row's display reads `line.vendor?.name` for
405
+ // non-vendor-typed transactions (Journal Entries, etc.), so the
406
+ // filter has to look at line-level payee data too or it silently
407
+ // misses every JE whose vendor lives on the line. See the JSDoc
408
+ // on `getLinePayeesForTransaction` for the full precedence.
409
+ //
410
+ // `not_equal` reads the same way it does for category/class:
411
+ // "at least one line's payee is NOT one of the filter values".
412
+ // A JE with lines [Amazon, Acme] matches `payee != Amazon` via
413
+ // the Acme line; a bill or JE whose every line says "Amazon"
414
+ // does not match `payee != Amazon`.
415
+ const linePayees = getLinePayeesForTransaction(transaction);
416
+ if (linePayees.length === 0) {
417
+ return category.matchingOperator === 'not_equal';
418
+ }
419
+ const filterValuesAsStrings = new Set(category.values.map((v) => v.toString()));
420
+ return linePayees.some((name) => {
421
+ const inList = filterValuesAsStrings.has(name);
422
+ return category.matchingOperator === 'not_equal' ? !inList : inList;
423
+ });
424
+ }
342
425
  if (category.field === 'category' || category.field === 'class') {
343
426
  // Per-line matching for category / class — mirrors the amount
344
427
  // 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,108 @@ 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
+ * 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).
239
+ *
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.
253
+ *
254
+ * Returns `[]` when every source resolves empty; the caller falls back
255
+ * to the absent-value contract (only `not_equal` matches; every other
256
+ * operator excludes).
257
+ */
258
+ const getLinePayeesForTransaction = (transaction) => {
259
+ 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
+ }
270
+ }
271
+ }
272
+ // Priority 2: persisted line-level vendor / customer
273
+ if (transaction?.transaction?.lines != null &&
274
+ transaction.transaction.lines.length > 0) {
275
+ const linePayees = transaction.transaction.lines
276
+ .map((line) => {
277
+ if (!isAccountAndClassLine(line)) {
278
+ return undefined;
279
+ }
280
+ 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];
304
+ }
305
+ return [];
306
+ };
210
307
  /**
211
308
  * Resolve the comparable value for a transaction against a given filter field.
212
309
  * Returns undefined when the transaction doesn't carry the field — callers
213
310
  * decide whether absence counts as a match (for 'not_equal' it does).
214
311
  *
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
312
+ * NOTE: the `'amount'`, `'category'`, `'class'`, and `'payee'` fields
313
+ * are NOT handled here. All four use per-line matching via the
314
+ * corresponding `getLine*ForTransaction` helper and an early-return in
218
315
  * `transactionMatchesCategory` — they would otherwise need to return a
219
316
  * non-scalar value that doesn't fit this helper's contract. Cases are
220
317
  * preserved so the switch stays exhaustive over `TransactionFilterCategoryField`.
@@ -227,46 +324,9 @@ const getCategoryValueForTransaction = (key, transaction) => {
227
324
  return undefined;
228
325
  }
229
326
  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
- }
327
+ // Handled per-line in `transactionMatchesCategory` — see
328
+ // `getLinePayeesForTransaction`. The transaction-level localData /
329
+ // top-level chain lives inside that helper's priority-3 fallback.
270
330
  return undefined;
271
331
  }
272
332
  case 'payment_account_name': {
@@ -342,6 +402,29 @@ const matchAmount = (amount, category) => {
342
402
  return op === 'not_equal' ? !matched : matched;
343
403
  };
344
404
  const transactionMatchesCategory = (transaction, category) => {
405
+ if (category.field === 'payee') {
406
+ // Per-line matching for payee — mirrors the amount / category /
407
+ // class pattern. The row's display reads `line.vendor?.name` for
408
+ // non-vendor-typed transactions (Journal Entries, etc.), so the
409
+ // filter has to look at line-level payee data too or it silently
410
+ // misses every JE whose vendor lives on the line. See the JSDoc
411
+ // on `getLinePayeesForTransaction` for the full precedence.
412
+ //
413
+ // `not_equal` reads the same way it does for category/class:
414
+ // "at least one line's payee is NOT one of the filter values".
415
+ // A JE with lines [Amazon, Acme] matches `payee != Amazon` via
416
+ // the Acme line; a bill or JE whose every line says "Amazon"
417
+ // does not match `payee != Amazon`.
418
+ const linePayees = getLinePayeesForTransaction(transaction);
419
+ if (linePayees.length === 0) {
420
+ return category.matchingOperator === 'not_equal';
421
+ }
422
+ const filterValuesAsStrings = new Set(category.values.map((v) => v.toString()));
423
+ return linePayees.some((name) => {
424
+ const inList = filterValuesAsStrings.has(name);
425
+ return category.matchingOperator === 'not_equal' ? !inList : inList;
426
+ });
427
+ }
345
428
  if (category.field === 'category' || category.field === 'class') {
346
429
  // Per-line matching for category / class — mirrors the amount
347
430
  // 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-betaAR8",
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",