@engine9/schemas 1.9.0

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.
Files changed (122) hide show
  1. package/.eslintignore +1 -0
  2. package/.prettierrc +7 -0
  3. package/AGENTS.md +10 -0
  4. package/LICENSE +21 -0
  5. package/README.md +33 -0
  6. package/channels/email/README.md +83 -0
  7. package/channels/email/index.js +22 -0
  8. package/channels/email/search.js +86 -0
  9. package/channels/email/segments.js +81 -0
  10. package/eslint.config.js +21 -0
  11. package/event/README.md +79 -0
  12. package/event/index.js +17 -0
  13. package/event/schema.js +111 -0
  14. package/event/search.js +51 -0
  15. package/message/index.js +8 -0
  16. package/message/schema.js +130 -0
  17. package/message/ui.console.json5 +233 -0
  18. package/package.json +26 -0
  19. package/person/index.js +100 -0
  20. package/person/metrics.js +17 -0
  21. package/person/schema.js +47 -0
  22. package/person/transforms/inbound/normalize_field_names.js +16 -0
  23. package/person/transforms/inbound/upsert_tables.js +21 -0
  24. package/person/transforms/outbound/appendName.js +32 -0
  25. package/person/transforms/simpleMap.js +34 -0
  26. package/person/ui.console.json5 +165 -0
  27. package/person/upsert.test.js +72 -0
  28. package/person_address/index.js +30 -0
  29. package/person_address/normalize.js +232 -0
  30. package/person_address/normalize.test.js +69 -0
  31. package/person_address/schema.js +49 -0
  32. package/person_address/transforms/inbound/normalize.js +23 -0
  33. package/person_address/transforms/inbound/upsert_tables.js +51 -0
  34. package/person_address/transforms/outbound/appendAddress.js +87 -0
  35. package/person_address/ui.console.json5 +31 -0
  36. package/person_custom/index.js +24 -0
  37. package/person_custom/transforms/appendRemoteId.js +31 -0
  38. package/person_custom/transforms/inbound/upsert_tables.js +31 -0
  39. package/person_email/README.md +82 -0
  40. package/person_email/index.js +35 -0
  41. package/person_email/schema.js +49 -0
  42. package/person_email/search.js +61 -0
  43. package/person_email/segments.js +16 -0
  44. package/person_email/transforms/inbound/extract_identifiers.js +33 -0
  45. package/person_email/transforms/inbound/upsert_tables.js +142 -0
  46. package/person_email/transforms/outbound/appendEmail.js +32 -0
  47. package/person_email/transforms/outbound/appendEmailHash.js +20 -0
  48. package/person_email/transforms/outbound/tables.js +14 -0
  49. package/person_email/ui.console.json5 +58 -0
  50. package/person_email/upsert.test.js +126 -0
  51. package/person_hash/README.md +70 -0
  52. package/person_hash/hash.js +103 -0
  53. package/person_hash/hash.test.js +165 -0
  54. package/person_hash/index.js +37 -0
  55. package/person_hash/schema.js +54 -0
  56. package/person_hash/search.js +86 -0
  57. package/person_hash/transforms/inbound/extract_identifiers.js +70 -0
  58. package/person_hash/transforms/inbound/upsert_tables.js +105 -0
  59. package/person_hash/transforms/outbound/appendEmailHash.js +42 -0
  60. package/person_hash/transforms/outbound/appendPhoneHash.js +42 -0
  61. package/person_hash/ui.console.json5 +80 -0
  62. package/person_phone/README.md +79 -0
  63. package/person_phone/index.js +33 -0
  64. package/person_phone/schema.js +58 -0
  65. package/person_phone/search.js +79 -0
  66. package/person_phone/segments.js +26 -0
  67. package/person_phone/transforms/inbound/extract_identifiers.js +59 -0
  68. package/person_phone/transforms/inbound/upsert_tables.js +119 -0
  69. package/person_phone/transforms/outbound/appendPhoneHash.js +37 -0
  70. package/person_phone/ui.console.json5 +31 -0
  71. package/person_phone/upsert.test.js +50 -0
  72. package/person_remote/index.js +58 -0
  73. package/person_remote/metrics.js +17 -0
  74. package/person_remote/schema.js +21 -0
  75. package/person_remote/transforms/inbound/extract_identifiers.js +34 -0
  76. package/person_remote/transforms/inbound/upsert_tables.js +92 -0
  77. package/person_remote/transforms/outbound/appendRemotePersonId.js +59 -0
  78. package/plugin/index.js +13 -0
  79. package/plugin/schema.js +70 -0
  80. package/plugin/settings.js +19 -0
  81. package/plugin/ui.console.json5 +79 -0
  82. package/report/index.js +7 -0
  83. package/report/ui.console.json5 +21 -0
  84. package/segment/index.js +21 -0
  85. package/segment/schema.js +81 -0
  86. package/segment/search.js +64 -0
  87. package/segment/transforms/inbound/upsert.js +44 -0
  88. package/segment/ui.console.json5 +142 -0
  89. package/segment_stats/index.js +16 -0
  90. package/segment_stats/schema.js +30 -0
  91. package/source_code/index.js +16 -0
  92. package/source_code/metrics.js +28 -0
  93. package/source_code/schema.js +29 -0
  94. package/source_code/ui.console.json5 +31 -0
  95. package/stacks/limited-pii/index.js +8 -0
  96. package/stacks/limited-pii/stack.json +22 -0
  97. package/stacks/stacks.test.js +27 -0
  98. package/stacks/standard/index.js +7 -0
  99. package/stacks/standard/stack.json +20 -0
  100. package/tag/index.js +13 -0
  101. package/tag/schema.js +23 -0
  102. package/task/index.js +13 -0
  103. package/task/schema.js +54 -0
  104. package/task/ui.console.json5 +60 -0
  105. package/timeline/index.js +18 -0
  106. package/timeline/schema.js +18 -0
  107. package/timeline/transforms/inbound/upsert_tables.js +22 -0
  108. package/timeline/ui.console.json5 +31 -0
  109. package/transaction/contact_details/index.js +11 -0
  110. package/transaction/contact_details/schema.js +22 -0
  111. package/transaction/core/README.md +79 -0
  112. package/transaction/core/attribute.test.js +231 -0
  113. package/transaction/core/index.js +31 -0
  114. package/transaction/core/metrics.js +20 -0
  115. package/transaction/core/schema.js +45 -0
  116. package/transaction/core/search.js +87 -0
  117. package/transaction/core/segments.js +14 -0
  118. package/transaction/core/transforms/appendTransactionSummary.js +101 -0
  119. package/transaction/core/transforms/attribute.js +327 -0
  120. package/transaction/core/transforms/inbound/upsert_tables.js +57 -0
  121. package/utilities/limited-pii/index.js +14 -0
  122. package/utilities/limited-pii/settings.js +12 -0
@@ -0,0 +1,231 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import attribute from './transforms/attribute.js';
4
+ import upsert from './transforms/inbound/upsert_tables.js';
5
+
6
+ const pluginId = '00000000-0000-4000-8000-000000000001';
7
+ const inputId = '00000000-0000-4000-8000-000000000002';
8
+ const transactionId = '00000000-0000-4000-8000-000000000010';
9
+ const messageUuid = '00000000-0000-4000-8000-000000000003';
10
+ const laterMessageUuid = '00000000-0000-4000-8000-000000000004';
11
+
12
+ function row(extra = {}) {
13
+ return {
14
+ id: transactionId,
15
+ ts: '2024-06-15T12:00:00.000Z',
16
+ person_id: 7,
17
+ remote_entry_id: 'txn-1',
18
+ remote_input_id: 'form-1',
19
+ input_id: inputId,
20
+ source_code: 'SC_EG_123',
21
+ source_code_id: 42,
22
+ amount: 25,
23
+ ...extra
24
+ };
25
+ }
26
+
27
+ function pluginInputs() {
28
+ return [{ input_id: inputId, remote_input_id: 'form-1', remote_plugin_id: 'bot-9' }];
29
+ }
30
+
31
+ function message(extra = {}) {
32
+ return {
33
+ source_code: 'SC_EG_123',
34
+ message_id: 88,
35
+ publish_date: '2024-06-01T00:00:00.000Z',
36
+ message_uuid: messageUuid,
37
+ message_publish_date: '2024-06-01T00:00:00.000Z',
38
+ ...extra
39
+ };
40
+ }
41
+
42
+ describe('transaction attribute', () => {
43
+ it('requires a uuid pluginId on the lookup bindings', () => {
44
+ assert.throws(() => attribute.bindings({}), /pluginId is required/);
45
+ assert.throws(() => attribute.bindings({ pluginId: 'bot-9' }), /must be a uuid/);
46
+ });
47
+
48
+ it('patches a transaction already queued by upsert and writes attribution', async () => {
49
+ const batch = [row()];
50
+ const tablesToUpsert = { transaction: batch };
51
+ await attribute.transform({
52
+ batch,
53
+ tablesToUpsert,
54
+ pluginInputs: pluginInputs(),
55
+ messageSourceCodes: [message()]
56
+ });
57
+ assert.equal(tablesToUpsert.transaction.length, 1);
58
+ assert.equal(tablesToUpsert.transaction[0], batch[0]);
59
+ assert.equal(batch[0].amount, 25);
60
+ assert.equal(batch[0].recommended_message_id, messageUuid);
61
+ assert.equal(batch[0].final_message_id, messageUuid);
62
+ assert.equal(batch[0].final_source_code_id, 42);
63
+ assert.equal('entry_type_id' in batch[0], false);
64
+ assert.deepEqual(tablesToUpsert.attribution, [
65
+ {
66
+ transaction_bot_id: 'bot-9',
67
+ remote_transaction_id: 'txn-1',
68
+ recommended_message_id: 88
69
+ }
70
+ ]);
71
+ });
72
+
73
+ it('re-attributes an existing transaction from an override', async () => {
74
+ const tablesToUpsert = {};
75
+ await attribute.transform({
76
+ batch: [row({ source_code: 'SC_ORIGINAL', source_code_id: 7 })],
77
+ tablesToUpsert,
78
+ pluginInputs: pluginInputs(),
79
+ overrides: [
80
+ {
81
+ remote_transaction_id: 'txn-1',
82
+ transaction_bot_id: 'bot-9',
83
+ transaction_source_code_override: 'SC_OVERRIDE',
84
+ dictionary_source_code_id: 99,
85
+ matched_source_code: 'SC_OVERRIDE',
86
+ matched_message_id: 55,
87
+ matched_publish_date: '2024-06-02T00:00:00.000Z',
88
+ matched_message_uuid: laterMessageUuid,
89
+ matched_message_publish_date: '2024-06-02T00:00:00.000Z'
90
+ }
91
+ ],
92
+ messageSourceCodes: [message({ source_code: 'SC_ORIGINAL', message_id: 11, message_uuid: messageUuid })]
93
+ });
94
+ assert.deepEqual(tablesToUpsert.transaction, [
95
+ {
96
+ id: transactionId,
97
+ override_source_code_id: 99,
98
+ final_source_code_id: 99,
99
+ recommended_message_id: laterMessageUuid,
100
+ override_message_id: null,
101
+ final_message_id: laterMessageUuid
102
+ }
103
+ ]);
104
+ assert.equal(tablesToUpsert.attribution[0].recommended_message_id, 55);
105
+ });
106
+
107
+ it('matches a message published inside the 29 hour window and rejects one past it', async () => {
108
+ const within = {};
109
+ await attribute.transform({
110
+ batch: [row()],
111
+ tablesToUpsert: within,
112
+ pluginInputs: pluginInputs(),
113
+ messageSourceCodes: [message({ message_id: 11, publish_date: '2024-06-16T16:00:00.000Z', message_publish_date: null })]
114
+ });
115
+ assert.equal(within.attribution.length, 1);
116
+
117
+ const past = {};
118
+ await attribute.transform({
119
+ batch: [row()],
120
+ tablesToUpsert: past,
121
+ pluginInputs: pluginInputs(),
122
+ messageSourceCodes: [message({ message_id: 11, publish_date: '2024-06-16T17:00:01.000Z', message_publish_date: null })]
123
+ });
124
+ assert.equal(past.attribution.length, 0);
125
+ assert.equal(past.transaction[0].recommended_message_id, null);
126
+ });
127
+
128
+ it('picks the latest publish_date and skips do_not_attribute on the transaction source code', async () => {
129
+ const tablesToUpsert = {};
130
+ await attribute.transform({
131
+ batch: [row()],
132
+ tablesToUpsert,
133
+ pluginInputs: pluginInputs(),
134
+ messageSourceCodes: [
135
+ message({ message_id: 11, publish_date: '2024-06-01T00:00:00.000Z', message_publish_date: '2024-06-01T00:00:00.000Z' }),
136
+ message({
137
+ message_id: 22,
138
+ publish_date: '2024-06-10T00:00:00.000Z',
139
+ message_publish_date: '2024-06-10T00:00:00.000Z',
140
+ message_uuid: laterMessageUuid
141
+ }),
142
+ message({
143
+ message_id: 33,
144
+ publish_date: '2024-06-12T00:00:00.000Z',
145
+ message_publish_date: '2024-06-12T00:00:00.000Z',
146
+ message_uuid: '00000000-0000-4000-8000-000000000005',
147
+ do_not_attribute: 1
148
+ })
149
+ ]
150
+ });
151
+ assert.equal(tablesToUpsert.transaction[0].recommended_message_id, laterMessageUuid);
152
+ assert.equal(tablesToUpsert.attribution[0].recommended_message_id, 22);
153
+ });
154
+
155
+ it('does not fall back to the transaction source code when the override code is blank', async () => {
156
+ const tablesToUpsert = {};
157
+ await attribute.transform({
158
+ batch: [row()],
159
+ tablesToUpsert,
160
+ pluginInputs: pluginInputs(),
161
+ overrides: [
162
+ {
163
+ remote_transaction_id: 'txn-1',
164
+ transaction_bot_id: 'bot-9',
165
+ transaction_source_code_override: ''
166
+ }
167
+ ],
168
+ messageSourceCodes: [message()]
169
+ });
170
+ assert.equal(tablesToUpsert.attribution.length, 0);
171
+ assert.equal(tablesToUpsert.transaction[0].recommended_message_id, null);
172
+ });
173
+
174
+ it('does not attribute when the override code has no message', async () => {
175
+ const tablesToUpsert = {};
176
+ await attribute.transform({
177
+ batch: [row()],
178
+ tablesToUpsert,
179
+ pluginInputs: pluginInputs(),
180
+ overrides: [
181
+ {
182
+ remote_transaction_id: 'txn-1',
183
+ transaction_bot_id: 'bot-9',
184
+ transaction_source_code_override: 'SC_OVERRIDE'
185
+ }
186
+ ],
187
+ messageSourceCodes: [message()]
188
+ });
189
+ assert.equal(tablesToUpsert.attribution.length, 0);
190
+ assert.equal(tablesToUpsert.transaction[0].recommended_message_id, null);
191
+ });
192
+
193
+ it('throws when a legacy message id matches but remote_plugin_id was not loaded', async () => {
194
+ await assert.rejects(
195
+ () =>
196
+ attribute.transform({
197
+ batch: [
198
+ row({
199
+ remote_input_id: 'missing-form',
200
+ input_id: '00000000-0000-4000-8000-000000000099'
201
+ })
202
+ ],
203
+ tablesToUpsert: {},
204
+ pluginInputs: pluginInputs(),
205
+ messageSourceCodes: [message({ message_id: 11, message_uuid: null, message_publish_date: null })]
206
+ }),
207
+ /remote_plugin_id was not loaded/
208
+ );
209
+ });
210
+ });
211
+
212
+ describe('transaction upsert_tables', () => {
213
+ it('maps recurs onto recurs_id', async () => {
214
+ const tablesToUpsert = {};
215
+ await upsert.transform({
216
+ batch: [
217
+ {
218
+ ts: '2024-06-15T12:00:00.000Z',
219
+ person_id: 7,
220
+ entry_type: 'TRANSACTION',
221
+ remote_entry_id: 'txn-week',
222
+ recurs: 'weekly'
223
+ }
224
+ ],
225
+ tablesToUpsert,
226
+ pluginId
227
+ });
228
+ assert.equal(tablesToUpsert.transaction[0].recurs_id, 2);
229
+ assert.equal(tablesToUpsert.transaction[0].entry_type_id, 10);
230
+ });
231
+ });
@@ -0,0 +1,31 @@
1
+ import schema from './schema.js';
2
+ import search from './search.js';
3
+ import metrics from './metrics.js';
4
+ import segments from './segments.js';
5
+ import upsert from './transforms/inbound/upsert_tables.js';
6
+ import attribute from './transforms/attribute.js';
7
+ import appendTransactionSummary from './transforms/appendTransactionSummary.js';
8
+ const metadata = {
9
+ name: '@engine9/schemas/transaction/core',
10
+ dependencies: {
11
+ '@engine9/schemas/person': '>=1.9.0'
12
+ }
13
+ };
14
+ export const transforms = {
15
+ upsert,
16
+ attribute,
17
+ appendTransactionSummary
18
+ };
19
+ export { metadata };
20
+ export { schema };
21
+ export { metrics };
22
+ export { search };
23
+ export { segments };
24
+ export default {
25
+ metadata,
26
+ schema,
27
+ metrics,
28
+ transforms,
29
+ search,
30
+ segments
31
+ };
@@ -0,0 +1,20 @@
1
+ export const summaryByDate = ({ plugin: { tablePrefix } }) => {
2
+ return {
3
+ label: 'Transactions',
4
+ description: '',
5
+ eql: {
6
+ table: tablePrefix + 'transaction',
7
+ columns: [
8
+ { eql: 'month(ts)', name: 'date' },
9
+ { eql: 'count(*)', name: 'records' },
10
+ { eql: 'sum(amount)', name: 'revenue' }
11
+ ],
12
+ conditions: [],
13
+ groupBy: [{ eql: 'month(ts)' }],
14
+ orderBy: [{ eql: 'month(ts)' }]
15
+ }
16
+ };
17
+ };
18
+ export default {
19
+ summaryByDate
20
+ };
@@ -0,0 +1,45 @@
1
+ export const tables = [
2
+ {
3
+ name: 'transaction',
4
+ columns: {
5
+ id: 'id_uuid',
6
+ ts: 'datetime',
7
+ input_id: 'uuid', // keep as uuid because it's required
8
+ entry_type_id: 'int',
9
+ person_id: 'person_id',
10
+ amount: 'currency',
11
+ remote_entry_id: 'string',
12
+ remote_page_name: 'string',
13
+ remote_recurring_id: 'string',
14
+ recurs_id: {
15
+ type: 'int',
16
+ nullable: false,
17
+ default_value: 0
18
+ },
19
+ recurring_number: 'int',
20
+ refund_amount: 'currency',
21
+ given_name: 'string',
22
+ family_name: 'string',
23
+ email: 'string',
24
+ source_code_id: 'source_code_id',
25
+ override_source_code_id: 'source_code_id',
26
+ final_source_code_id: 'source_code_id',
27
+ recommended_message_id: 'uuid',
28
+ override_message_id: 'uuid',
29
+ final_message_id: 'uuid',
30
+ extra: 'json',
31
+ created_at: 'created_at',
32
+ modified_at: 'modified_at'
33
+ },
34
+ indexes: [
35
+ { columns: 'id', primary: true },
36
+ { columns: 'ts' },
37
+ { columns: 'person_id' },
38
+ { columns: 'remote_entry_id' },
39
+ { columns: 'modified_at' }
40
+ ]
41
+ }
42
+ ];
43
+ export default {
44
+ tables
45
+ };
@@ -0,0 +1,87 @@
1
+ import { relativeDate, isValidDate } from '@engine9/input-tools';
2
+
3
+ export const all = {
4
+ title: 'Transactions',
5
+ form: {
6
+ title: 'Transactions',
7
+ type: 'object',
8
+ properties: {
9
+ plugin_id: {
10
+ type: 'string'
11
+ },
12
+ start: {
13
+ title: 'Start',
14
+ type: 'string',
15
+ description:
16
+ "Optional; only include rows with ts >= start. Parsed with relativeDate; supports ISO datetimes or relative expressions like '-30d'."
17
+ },
18
+ end: {
19
+ title: 'End',
20
+ type: 'string',
21
+ description:
22
+ 'Optional; only include rows with ts < end (exclusive upper bound). Parsed with relativeDate; supports ISO datetimes or relative expressions.'
23
+ }
24
+ },
25
+ required: []
26
+ },
27
+ optionsToEQL: (options) => {
28
+ const { plugin_id, start, end } = options;
29
+ const conditions = [];
30
+ let text = 'Has any transactions';
31
+ if (plugin_id) {
32
+ conditions.push(`input.plugin_id='${plugin_id}'`);
33
+ text += ` from plugin ${plugin_id}`;
34
+ }
35
+ if (start) {
36
+ const s = relativeDate(start);
37
+ if (!isValidDate(s)) throw new Error('Invalid start: ' + start);
38
+ conditions.push(`transaction.ts>='${s.toISOString()}'`);
39
+ text += ` with ts >= ${s.toISOString()}`;
40
+ }
41
+ if (end) {
42
+ const e = relativeDate(end);
43
+ if (!isValidDate(e)) throw new Error('Invalid end: ' + end);
44
+ conditions.push(`transaction.ts<'${e.toISOString()}'`);
45
+ text += ` with ts < ${e.toISOString()}`;
46
+ }
47
+ return {
48
+ text,
49
+ eql: {
50
+ table: 'transaction',
51
+ joins: [
52
+ {
53
+ table: 'input',
54
+ join_eql: 'transaction.input_id=input.id'
55
+ }
56
+ ],
57
+ columns: ['person_id'],
58
+ conditions
59
+ }
60
+ };
61
+ }
62
+ };
63
+ export const minimum = {
64
+ title: 'Minimum transaction amount',
65
+ form: {
66
+ title: 'Minimum transaction amount',
67
+ type: 'object',
68
+ properties: {
69
+ amount: {
70
+ type: 'number'
71
+ },
72
+ filterType: {
73
+ type: 'string'
74
+ }
75
+ },
76
+ required: []
77
+ },
78
+ optionsToEQL: (options) => ({
79
+ table: 'transaction',
80
+ columns: ['person_id'],
81
+ conditions: [`amount>=${parseFloat(options.amount)}`]
82
+ })
83
+ };
84
+ export default {
85
+ all,
86
+ minimum
87
+ };
@@ -0,0 +1,14 @@
1
+ export const customers = {
2
+ name: 'Customers',
3
+ search: {
4
+ and: [
5
+ {
6
+ table: 'transaction',
7
+ columns: ['person_id']
8
+ }
9
+ ]
10
+ }
11
+ };
12
+ export default {
13
+ customers
14
+ };
@@ -0,0 +1,101 @@
1
+ const placeholder = {
2
+ count: null,
3
+ revenue: null,
4
+ recurring_count: null,
5
+ recurring_revenue: null,
6
+ refund_count: null,
7
+ refund_revenue: null,
8
+ first_id: null,
9
+ first_date: null,
10
+ first_amount: null,
11
+ last_id: null,
12
+ last_date: null,
13
+ last_amount: null,
14
+ smallest_id: null,
15
+ smallest_date: null,
16
+ smallest_amount: null,
17
+ largest_id: null,
18
+ largest_date: null,
19
+ largest_amount: null,
20
+ first_recurring_id: null,
21
+ first_recurring_date: null,
22
+ first_recurring_amount: null,
23
+ last_recurring_id: null,
24
+ last_recurring_date: null,
25
+ last_recurring_amount: null
26
+ };
27
+ export const bindings = {
28
+ allTransactions: {
29
+ path: 'sql.query',
30
+ options: {
31
+ table: 'transaction',
32
+ columns: 'id,person_id,ts,amount,recurs_id,refund_amount'.split(','),
33
+ lookup: ['person_id']
34
+ }
35
+ }
36
+ };
37
+ export async function transform({ batch, allTransactions }) {
38
+ function sumAmount(a, b) {
39
+ return (parseFloat(a) + parseFloat(b.amount || 0)).toFixed(2);
40
+ }
41
+ const personIdMap = {};
42
+ allTransactions.sort((a, b) => (a.ts < b.ts ? -1 : 1));
43
+ allTransactions.forEach((d) => {
44
+ d.ts = new Date(d.ts).toISOString();
45
+ personIdMap[d.person_id] = personIdMap[d.person_id] || { person_id: d.person_id, transactions: [] };
46
+ personIdMap[d.person_id].transactions.push(d);
47
+ });
48
+ batch.forEach((person) => {
49
+ Object.entries(placeholder).forEach(([k, v]) => {
50
+ person[`transaction.${k}`] = v;
51
+ });
52
+ const { transactions } = personIdMap[person.person_id] || { transactions: [] };
53
+ if (transactions.length === 0) return;
54
+ const recurring = transactions.filter((d) => d.recurs_id > 0 && d.amount > 0);
55
+ const largest = transactions.reduce((a, b) => (a.amount > b.amount ? a : b), { amount: -1 });
56
+ const smallest = transactions.reduce((a, b) => (a.amount < b.amount ? a : b), {
57
+ amount: 100000000
58
+ });
59
+ let refundCount = 0;
60
+ let refundAmount = 0;
61
+ transactions.forEach((t) => {
62
+ if (t.refund_amount) {
63
+ refundCount += 1;
64
+ refundAmount += parseFloat(t.refund_amount);
65
+ }
66
+ });
67
+ const output = {
68
+ count: transactions.length,
69
+ revenue: transactions.reduce(sumAmount, 0),
70
+ recurring_count: recurring.length,
71
+ recurring_revenue: recurring.reduce(sumAmount, 0),
72
+ refund_count: refundCount,
73
+ refund_revenue: refundAmount,
74
+ first_id: transactions[0].id || null,
75
+ first_date: transactions[0].ts || null,
76
+ first_amount: transactions[0].amount || null,
77
+ last_id: transactions.slice(-1)[0].id || null,
78
+ last_date: transactions.slice(-1)[0].ts || null,
79
+ last_amount: transactions.slice(-1)[0].amount || null,
80
+ largest_id: largest.id || null,
81
+ largest_date: largest.ts || null,
82
+ largest_amount: largest.amount || null,
83
+ smallest_id: smallest.id || null,
84
+ smallest_date: smallest.ts || null,
85
+ smallest_amount: smallest.amount || null,
86
+ first_recurring_id: (recurring[0] || {}).id || null,
87
+ first_recurring_date: (recurring[0] || {}).ts || null,
88
+ first_recurring_amount: (recurring[0] || {}).amount || null,
89
+ last_recurring_id: (recurring.slice(-1)[0] || {}).id || null,
90
+ last_recurring_date: (recurring.slice(-1)[0] || {}).ts || null,
91
+ last_recurring_amount: (recurring.slice(-1)[0] || {}).amount || null
92
+ };
93
+ Object.entries(output).forEach(([k, v]) => {
94
+ person[`transaction.${k}`] = v;
95
+ });
96
+ });
97
+ }
98
+ export default {
99
+ bindings,
100
+ transform
101
+ };