@zeniai/client-epic-state 5.0.87 → 5.0.88-betaAR1

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.
@@ -28,28 +28,69 @@ export const TRANSACTION_FILTER_CATEGORIES = [
28
28
  { value: 'class', type: 'dropdown' },
29
29
  { value: 'amount', type: 'numberRange' },
30
30
  ];
31
+ /**
32
+ * Per-line amounts for a transaction, preserving the canonical source
33
+ * precedence:
34
+ *
35
+ * 1. `transactionLocalData.transactionReviewLocalData.lineItemById` —
36
+ * the locally-edited line items the user sees in the grid. Reflects
37
+ * unsaved edits so the filter matches what the user actually has on
38
+ * screen, not the pre-edit server state.
39
+ * 2. `transaction.transaction.lines` — persisted lines from the
40
+ * transaction payload.
41
+ * 3. `[transaction.amount.amount]` — single-element fallback for
42
+ * transactions that don't have a `lines` array at all (treat the
43
+ * transaction-level amount as a one-line transaction).
44
+ *
45
+ * Returned as an array so the amount filter in `transactionMatchesCategory`
46
+ * can OR-combine per-line `matchAmount` results: a transaction is included
47
+ * if ANY of its lines satisfies the operator, and the full row (with all
48
+ * lines, matching or not) renders downstream. Before the per-line change
49
+ * this helper's predecessor in `getCategoryValueForTransaction` summed
50
+ * these values into a single aggregate and compared the sum.
51
+ *
52
+ * Defensive `?.` / `?? 0` on every nested access: the static types declare
53
+ * `transaction`, `transaction.transaction`, and `transaction.amount` as
54
+ * required, but in practice this selector chain can be fed partial
55
+ * payloads — e.g. while state is initializing, while a transaction is
56
+ * mid-sync after a Pusher delta, or when a `TransactionView` is
57
+ * constructed from a server response that hasn't fully resolved its
58
+ * embedded `amount` envelope. A missing amount field collapses to `0`
59
+ * rather than throwing; an absent line is treated as `0` so the operator
60
+ * comparison still produces a deterministic boolean (e.g. `greater_than
61
+ * 100` won't include a transaction just because one of its lines failed
62
+ * to deserialize).
63
+ */
64
+ const getLineAmountsForTransaction = (transaction) => {
65
+ const localData = transaction?.transactionLocalData?.transactionReviewLocalData;
66
+ if (localData?.lineItemById != null) {
67
+ const lineItems = Object.values(localData.lineItemById);
68
+ if (lineItems.length > 0) {
69
+ return lineItems.map((lineItem) => lineItem?.amount?.amount ?? 0);
70
+ }
71
+ }
72
+ if (transaction?.transaction?.lines != null &&
73
+ transaction.transaction.lines.length > 0) {
74
+ return transaction.transaction.lines.map((line) => line?.amount?.amount ?? 0);
75
+ }
76
+ return [transaction?.amount?.amount ?? 0];
77
+ };
31
78
  /**
32
79
  * Resolve the comparable value for a transaction against a given filter field.
33
80
  * Returns undefined when the transaction doesn't carry the field — callers
34
81
  * decide whether absence counts as a match (for 'not_equal' it does).
82
+ *
83
+ * NOTE: the `'amount'` field is NOT handled here. Amount matching is
84
+ * per-line (see `getLineAmountsForTransaction` + the early-return in
85
+ * `transactionMatchesCategory`) and would otherwise need to return a
86
+ * non-scalar value that doesn't fit this helper's contract.
35
87
  */
36
88
  const getCategoryValueForTransaction = (key, transaction) => {
37
89
  switch (key) {
38
90
  case 'amount': {
39
- // Prefer transactionLocalData line items (sum). Fall back to lines on
40
- // the transaction itself. Final fallback: transaction-level amount.
41
- const localDataForAmount = transaction.transactionLocalData?.transactionReviewLocalData;
42
- if (localDataForAmount?.lineItemById) {
43
- const lineItems = Object.values(localDataForAmount.lineItemById);
44
- if (lineItems.length > 0) {
45
- return lineItems.reduce((sum, lineItem) => sum + (lineItem.amount?.amount ?? 0), 0);
46
- }
47
- }
48
- if (transaction.transaction.lines &&
49
- transaction.transaction.lines.length > 0) {
50
- return transaction.transaction.lines.reduce((sum, line) => sum + (line.amount?.amount ?? 0), 0);
51
- }
52
- return transaction.amount.amount;
91
+ // Handled per-line in `transactionMatchesCategory` see the note on
92
+ // this function's JSDoc and `getLineAmountsForTransaction` above.
93
+ return undefined;
53
94
  }
54
95
  case 'payee': {
55
96
  if (isNonEmptyString(transaction.vendorName)) {
@@ -187,6 +228,27 @@ const matchAmount = (amount, category) => {
187
228
  return op === 'not_equal' ? !matched : matched;
188
229
  };
189
230
  const transactionMatchesCategory = (transaction, category) => {
231
+ if (category.field === 'amount') {
232
+ // Per-line matching: the transaction is included if ANY of its lines
233
+ // satisfies the operator. This is what the user expects when a
234
+ // multi-line transaction contains lines at different amounts — they
235
+ // want the row to surface if even one line falls in the filter's
236
+ // range. The full row (with non-matching lines too) renders
237
+ // downstream; this matcher only decides row inclusion. Same
238
+ // operator + sign semantics as the aggregate version, just applied
239
+ // per line.
240
+ //
241
+ // `not_equal` deserves a sanity-check: `matchAmount` already inverts
242
+ // internally for that operator, so for a line whose amount is NOT
243
+ // in the filter's `values`, matchAmount returns true; for a line
244
+ // whose amount IS in the values, it returns false. `.some()` over
245
+ // lines therefore means "the transaction has at least one line that
246
+ // is not equal to any of the filter values" — which is the right
247
+ // match-any-line interpretation of `not_equal` (a transaction
248
+ // matches if it has a line that satisfies the negation).
249
+ const lineAmounts = getLineAmountsForTransaction(transaction);
250
+ return lineAmounts.some((lineAmount) => matchAmount(lineAmount, category));
251
+ }
190
252
  const value = getCategoryValueForTransaction(category.field, transaction);
191
253
  if (value == null) {
192
254
  // Absent-value semantics — pinning these down explicitly so the
@@ -206,12 +268,10 @@ const transactionMatchesCategory = (transaction, category) => {
206
268
  // are filtered out.
207
269
  //
208
270
  // Only the `not_equal` branch returns `true` here; everything else
209
- // falls through to the `false` below.
271
+ // falls through to the `false` below. Note: 'amount' never lands here
272
+ // because it short-circuits to the per-line path above.
210
273
  return category.matchingOperator === 'not_equal';
211
274
  }
212
- if (category.field === 'amount') {
213
- return matchAmount(Number(value), category);
214
- }
215
275
  const valueStr = value.toString();
216
276
  const valueInList = category.values.some((v) => v.toString() === valueStr);
217
277
  return category.matchingOperator === 'not_equal' ? !valueInList : valueInList;
@@ -31,28 +31,69 @@ exports.TRANSACTION_FILTER_CATEGORIES = [
31
31
  { value: 'class', type: 'dropdown' },
32
32
  { value: 'amount', type: 'numberRange' },
33
33
  ];
34
+ /**
35
+ * Per-line amounts for a transaction, preserving the canonical source
36
+ * precedence:
37
+ *
38
+ * 1. `transactionLocalData.transactionReviewLocalData.lineItemById` —
39
+ * the locally-edited line items the user sees in the grid. Reflects
40
+ * unsaved edits so the filter matches what the user actually has on
41
+ * screen, not the pre-edit server state.
42
+ * 2. `transaction.transaction.lines` — persisted lines from the
43
+ * transaction payload.
44
+ * 3. `[transaction.amount.amount]` — single-element fallback for
45
+ * transactions that don't have a `lines` array at all (treat the
46
+ * transaction-level amount as a one-line transaction).
47
+ *
48
+ * Returned as an array so the amount filter in `transactionMatchesCategory`
49
+ * can OR-combine per-line `matchAmount` results: a transaction is included
50
+ * if ANY of its lines satisfies the operator, and the full row (with all
51
+ * lines, matching or not) renders downstream. Before the per-line change
52
+ * this helper's predecessor in `getCategoryValueForTransaction` summed
53
+ * these values into a single aggregate and compared the sum.
54
+ *
55
+ * Defensive `?.` / `?? 0` on every nested access: the static types declare
56
+ * `transaction`, `transaction.transaction`, and `transaction.amount` as
57
+ * required, but in practice this selector chain can be fed partial
58
+ * payloads — e.g. while state is initializing, while a transaction is
59
+ * mid-sync after a Pusher delta, or when a `TransactionView` is
60
+ * constructed from a server response that hasn't fully resolved its
61
+ * embedded `amount` envelope. A missing amount field collapses to `0`
62
+ * rather than throwing; an absent line is treated as `0` so the operator
63
+ * comparison still produces a deterministic boolean (e.g. `greater_than
64
+ * 100` won't include a transaction just because one of its lines failed
65
+ * to deserialize).
66
+ */
67
+ const getLineAmountsForTransaction = (transaction) => {
68
+ const localData = transaction?.transactionLocalData?.transactionReviewLocalData;
69
+ if (localData?.lineItemById != null) {
70
+ const lineItems = Object.values(localData.lineItemById);
71
+ if (lineItems.length > 0) {
72
+ return lineItems.map((lineItem) => lineItem?.amount?.amount ?? 0);
73
+ }
74
+ }
75
+ if (transaction?.transaction?.lines != null &&
76
+ transaction.transaction.lines.length > 0) {
77
+ return transaction.transaction.lines.map((line) => line?.amount?.amount ?? 0);
78
+ }
79
+ return [transaction?.amount?.amount ?? 0];
80
+ };
34
81
  /**
35
82
  * Resolve the comparable value for a transaction against a given filter field.
36
83
  * Returns undefined when the transaction doesn't carry the field — callers
37
84
  * decide whether absence counts as a match (for 'not_equal' it does).
85
+ *
86
+ * NOTE: the `'amount'` field is NOT handled here. Amount matching is
87
+ * per-line (see `getLineAmountsForTransaction` + the early-return in
88
+ * `transactionMatchesCategory`) and would otherwise need to return a
89
+ * non-scalar value that doesn't fit this helper's contract.
38
90
  */
39
91
  const getCategoryValueForTransaction = (key, transaction) => {
40
92
  switch (key) {
41
93
  case 'amount': {
42
- // Prefer transactionLocalData line items (sum). Fall back to lines on
43
- // the transaction itself. Final fallback: transaction-level amount.
44
- const localDataForAmount = transaction.transactionLocalData?.transactionReviewLocalData;
45
- if (localDataForAmount?.lineItemById) {
46
- const lineItems = Object.values(localDataForAmount.lineItemById);
47
- if (lineItems.length > 0) {
48
- return lineItems.reduce((sum, lineItem) => sum + (lineItem.amount?.amount ?? 0), 0);
49
- }
50
- }
51
- if (transaction.transaction.lines &&
52
- transaction.transaction.lines.length > 0) {
53
- return transaction.transaction.lines.reduce((sum, line) => sum + (line.amount?.amount ?? 0), 0);
54
- }
55
- return transaction.amount.amount;
94
+ // Handled per-line in `transactionMatchesCategory` see the note on
95
+ // this function's JSDoc and `getLineAmountsForTransaction` above.
96
+ return undefined;
56
97
  }
57
98
  case 'payee': {
58
99
  if (isNonEmptyString(transaction.vendorName)) {
@@ -190,6 +231,27 @@ const matchAmount = (amount, category) => {
190
231
  return op === 'not_equal' ? !matched : matched;
191
232
  };
192
233
  const transactionMatchesCategory = (transaction, category) => {
234
+ if (category.field === 'amount') {
235
+ // Per-line matching: the transaction is included if ANY of its lines
236
+ // satisfies the operator. This is what the user expects when a
237
+ // multi-line transaction contains lines at different amounts — they
238
+ // want the row to surface if even one line falls in the filter's
239
+ // range. The full row (with non-matching lines too) renders
240
+ // downstream; this matcher only decides row inclusion. Same
241
+ // operator + sign semantics as the aggregate version, just applied
242
+ // per line.
243
+ //
244
+ // `not_equal` deserves a sanity-check: `matchAmount` already inverts
245
+ // internally for that operator, so for a line whose amount is NOT
246
+ // in the filter's `values`, matchAmount returns true; for a line
247
+ // whose amount IS in the values, it returns false. `.some()` over
248
+ // lines therefore means "the transaction has at least one line that
249
+ // is not equal to any of the filter values" — which is the right
250
+ // match-any-line interpretation of `not_equal` (a transaction
251
+ // matches if it has a line that satisfies the negation).
252
+ const lineAmounts = getLineAmountsForTransaction(transaction);
253
+ return lineAmounts.some((lineAmount) => matchAmount(lineAmount, category));
254
+ }
193
255
  const value = getCategoryValueForTransaction(category.field, transaction);
194
256
  if (value == null) {
195
257
  // Absent-value semantics — pinning these down explicitly so the
@@ -209,12 +271,10 @@ const transactionMatchesCategory = (transaction, category) => {
209
271
  // are filtered out.
210
272
  //
211
273
  // Only the `not_equal` branch returns `true` here; everything else
212
- // falls through to the `false` below.
274
+ // falls through to the `false` below. Note: 'amount' never lands here
275
+ // because it short-circuits to the per-line path above.
213
276
  return category.matchingOperator === 'not_equal';
214
277
  }
215
- if (category.field === 'amount') {
216
- return matchAmount(Number(value), category);
217
- }
218
278
  const valueStr = value.toString();
219
279
  const valueInList = category.values.some((v) => v.toString() === valueStr);
220
280
  return category.matchingOperator === 'not_equal' ? !valueInList : valueInList;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.0.87",
3
+ "version": "5.0.88-betaAR1",
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",