@stamhoofd/backend 2.138.2 → 2.140.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 (41) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +165 -0
  3. package/src/crons/fake-settlements.ts +117 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
  6. package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
  8. package/src/endpoints/auth/MFA.security.test.ts +76 -1
  9. package/src/endpoints/auth/MFA.test.ts +187 -3
  10. package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -1
  11. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
  13. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
  17. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  18. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
  19. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
  20. package/src/excel-loaders/balance-item-payments.ts +145 -0
  21. package/src/excel-loaders/index.ts +1 -0
  22. package/src/excel-loaders/payments.ts +45 -37
  23. package/src/helpers/StripeInvoicer.ts +1 -1
  24. package/src/helpers/TwoFactorHelper.ts +91 -9
  25. package/src/helpers/breakdownRelations.ts +48 -0
  26. package/src/helpers/getNextPageRequest.ts +24 -0
  27. package/src/helpers/streamForBreakdown.test.ts +107 -0
  28. package/src/helpers/streamForBreakdown.ts +104 -0
  29. package/src/services/DocumentRenderService.test.ts +72 -1
  30. package/src/services/PasswordForgotService.ts +31 -1
  31. package/src/services/PaymentService.ts +9 -1
  32. package/src/services/SSOService.ts +14 -1
  33. package/src/sql-filters/balance-item-payments-root.ts +48 -0
  34. package/src/sql-filters/balance-items.ts +55 -0
  35. package/src/sql-filters/orders.ts +6 -2
  36. package/src/sql-filters/payment-settlement.test.ts +68 -0
  37. package/src/sql-filters/payment-settlement.ts +43 -0
  38. package/src/sql-filters/payments.ts +93 -31
  39. package/src/sql-sorters/balance-item-payments.ts +32 -0
  40. package/tests/filters/orders.test.ts +635 -0
  41. package/tests/helpers/ExportSlice.ts +71 -0
@@ -0,0 +1,68 @@
1
+ import { compileToSQLFilter } from '@stamhoofd/sql';
2
+ import { PaymentMethod, PaymentProvider, PaymentStatus, Settlement } from '@stamhoofd/structures';
3
+ import { toBalanceItemFilter } from '@stamhoofd/structures/breakdown/breakdownFilters.js';
4
+ import type { SettleablePayment } from '@stamhoofd/structures/PaymentSettlement.js';
5
+ import { ACCOUNT_DEDUCTIONS_ID, FAILED_PAYMENT_ID, getPaymentSettlement, PENDING_PAYMENT_ID } from '@stamhoofd/structures/PaymentSettlement.js';
6
+ import { balanceItemFilterCompilers } from './balance-items.js';
7
+ import { paymentFilterCompilers } from './payments.js';
8
+
9
+ describe('paymentSettlementFilterCompilers', () => {
10
+ const settlement = Settlement.create({
11
+ id: 'settlement-1',
12
+ reference: 'ST-2026-01',
13
+ settledAt: new Date(2026, 0, 15),
14
+ amount: 100_00,
15
+ });
16
+
17
+ /**
18
+ * At least one payment for every group getPaymentSettlement can hand out, so a group that gets a new
19
+ * field in its filter is covered here.
20
+ */
21
+ const payments: SettleablePayment[] = [
22
+ // Money that never arrived
23
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement: null, status: PaymentStatus.Pending },
24
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement: null, status: PaymentStatus.Failed },
25
+ // Paid out, and waiting to be paid out
26
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement, status: PaymentStatus.Succeeded },
27
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement: null, status: PaymentStatus.Succeeded },
28
+ // Online, but from a provider that tells us nothing about its payouts
29
+ { method: PaymentMethod.Payconiq, provider: PaymentProvider.Payconiq, settlement: null, status: PaymentStatus.Succeeded },
30
+ { method: PaymentMethod.CreditCard, provider: null, settlement: null, status: PaymentStatus.Succeeded },
31
+ // Never online
32
+ { method: PaymentMethod.Transfer, provider: null, settlement: null, status: PaymentStatus.Succeeded },
33
+ { method: PaymentMethod.PointOfSale, provider: null, settlement: null, status: PaymentStatus.Succeeded },
34
+ { method: PaymentMethod.AccountDeductions, provider: null, settlement: null, status: PaymentStatus.Succeeded },
35
+ ];
36
+
37
+ const groups = payments.map(payment => getPaymentSettlement(payment));
38
+
39
+ test('every payout group can be compiled against the payments table', async () => {
40
+ for (const group of groups) {
41
+ await expect(compileToSQLFilter(group.filter, paymentFilterCompilers)).resolves.toBeDefined();
42
+ }
43
+ });
44
+
45
+ test('every payout group survives being asked about the balance items it paid for', async () => {
46
+ // A balance item doesn't carry how it was paid, so the payout tab of a balance item breakdown
47
+ // selects the items through their payments. A field that only the payments table knows about
48
+ // would only break there, at runtime.
49
+ for (const group of groups) {
50
+ await expect(compileToSQLFilter(toBalanceItemFilter(group.filter), balanceItemFilterCompilers)).resolves.toBeDefined();
51
+ }
52
+ });
53
+
54
+ test('covers every kind of payout group', () => {
55
+ // Guards the fixtures above: a new kind of group has to be added here before it is covered
56
+ expect([...new Set(groups.map(group => group.id))].sort()).toEqual([
57
+ ACCOUNT_DEDUCTIONS_ID,
58
+ FAILED_PAYMENT_ID,
59
+ 'no-payout-info-none',
60
+ 'no-payout-info-' + PaymentProvider.Payconiq,
61
+ 'not-settled',
62
+ // Every method that brings money in outside a provider shares one group
63
+ 'offline',
64
+ PENDING_PAYMENT_ID,
65
+ 'settlement-' + PaymentProvider.Stripe + '-' + settlement.reference + '-' + settlement.settledAt.getTime(),
66
+ ].sort());
67
+ });
68
+ });
@@ -0,0 +1,43 @@
1
+ import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+
4
+ /**
5
+ * How a payment is selected by the payout it was part of (see PaymentSettlementGroup).
6
+ *
7
+ * Shared by the payments filters and by the balance items filters, which reach the same columns through
8
+ * the payments that paid for them, so both select exactly the same payments.
9
+ *
10
+ * All expressions are qualified with the payments table so they keep working in a subquery that joins
11
+ * payments (see balance-items.ts).
12
+ */
13
+ export const paymentSettlementFilterCompilers: SQLFilterDefinitions = {
14
+ ...baseSQLFilterCompilers,
15
+ method: createColumnFilter({
16
+ expression: SQL.column('payments', 'method'),
17
+ type: SQLValueType.String,
18
+ nullable: false,
19
+ }),
20
+ provider: createColumnFilter({
21
+ expression: SQL.column('payments', 'provider'),
22
+ type: SQLValueType.String,
23
+ nullable: true,
24
+ }),
25
+ status: createColumnFilter({
26
+ expression: SQL.column('payments', 'status'),
27
+ type: SQLValueType.String,
28
+ nullable: false,
29
+ }),
30
+ settlement: {
31
+ ...baseSQLFilterCompilers,
32
+ reference: createColumnFilter({
33
+ expression: SQL.jsonExtract(SQL.column('payments', 'settlement'), '$.value.reference'),
34
+ type: SQLValueType.JSONString,
35
+ nullable: true,
36
+ }),
37
+ settledAt: createColumnFilter({
38
+ expression: SQL.jsonExtract(SQL.column('payments', 'settlement'), '$.value.settledAt'),
39
+ type: SQLValueType.JSONDate,
40
+ nullable: true,
41
+ }),
42
+ },
43
+ };
@@ -1,41 +1,39 @@
1
1
  import { Payment } from '@stamhoofd/models';
2
2
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
3
3
  import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, createJoinedRelationFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLScalar, SQLValueType } from '@stamhoofd/sql';
4
+ import type { StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { TransferSettings } from '@stamhoofd/structures';
4
6
  import { balanceItemPaymentsCompilers } from './balance-item-payments.js';
5
7
  import { organizationFilterCompilers } from './organizations.js';
8
+ import { paymentSettlementFilterCompilers } from './payment-settlement.js';
6
9
 
7
10
  /**
8
11
  * Defines how to filter payments in the database from StamhoofdFilter objects
12
+ *
13
+ * All expressions are qualified with the payments table so they keep working when payments is joined
14
+ * into another query instead of being selected from (see balance-item-payments-root.ts).
9
15
  */
10
16
  export const paymentFilterCompilers: SQLFilterDefinitions = {
11
17
  ...baseSQLFilterCompilers,
18
+ // method, provider, status and settlement
19
+ ...paymentSettlementFilterCompilers,
12
20
  id: createColumnFilter({
13
- expression: SQL.column('id'),
14
- type: SQLValueType.String,
15
- nullable: false,
16
- }),
17
- method: createColumnFilter({
18
- expression: SQL.column(Payment.table, 'method'),
21
+ expression: SQL.column(Payment.table, 'id'),
19
22
  type: SQLValueType.String,
20
23
  nullable: false,
21
24
  }),
22
25
  type: createColumnFilter({
23
- expression: SQL.column('type'),
24
- type: SQLValueType.String,
25
- nullable: false,
26
- }),
27
- status: createColumnFilter({
28
- expression: SQL.column(Payment.table, 'status'),
26
+ expression: SQL.column(Payment.table, 'type'),
29
27
  type: SQLValueType.String,
30
28
  nullable: false,
31
29
  }),
32
30
  organizationId: createColumnFilter({
33
- expression: SQL.column('organizationId'),
31
+ expression: SQL.column(Payment.table, 'organizationId'),
34
32
  type: SQLValueType.String,
35
33
  nullable: true,
36
34
  }),
37
35
  payingOrganizationId: createColumnFilter({
38
- expression: SQL.column('payingOrganizationId'),
36
+ expression: SQL.column(Payment.table, 'payingOrganizationId'),
39
37
  type: SQLValueType.String,
40
38
  nullable: true,
41
39
  }),
@@ -44,22 +42,22 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
44
42
  organizationFilterCompilers,
45
43
  ),
46
44
  invoiceId: createColumnFilter({
47
- expression: SQL.column('invoiceId'),
45
+ expression: SQL.column(Payment.table, 'invoiceId'),
48
46
  type: SQLValueType.String,
49
47
  nullable: true,
50
48
  }),
51
49
  createdAt: createColumnFilter({
52
- expression: SQL.column('createdAt'),
50
+ expression: SQL.column(Payment.table, 'createdAt'),
53
51
  type: SQLValueType.Datetime,
54
52
  nullable: false,
55
53
  }),
56
54
  updatedAt: createColumnFilter({
57
- expression: SQL.column('updatedAt'),
55
+ expression: SQL.column(Payment.table, 'updatedAt'),
58
56
  type: SQLValueType.Datetime,
59
57
  nullable: false,
60
58
  }),
61
59
  paidAt: createColumnFilter({
62
- expression: SQL.column('paidAt'),
60
+ expression: SQL.column(Payment.table, 'paidAt'),
63
61
  type: SQLValueType.Datetime,
64
62
  nullable: true,
65
63
  }),
@@ -68,44 +66,73 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
68
66
  type: SQLValueType.Number,
69
67
  nullable: false,
70
68
  }),
71
- provider: createColumnFilter({
72
- expression: SQL.column('provider'),
69
+
70
+ stripeAccountId: createColumnFilter({
71
+ expression: SQL.column(Payment.table, 'stripeAccountId'),
73
72
  type: SQLValueType.String,
74
73
  nullable: true,
75
74
  }),
75
+
76
+ /**
77
+ * What a payment rounded away, which belongs to the payment as a whole instead of to one of the
78
+ * things it paid for (see PaymentBreakdownBuilder.addRounding).
79
+ */
80
+ roundingAmount: createColumnFilter({
81
+ expression: SQL.column(Payment.table, 'roundingAmount'),
82
+ type: SQLValueType.Number,
83
+ nullable: false,
84
+ }),
85
+
86
+ /**
87
+ * The account a transfer was made to. Used to narrow a breakdown down to the money that arrived on
88
+ * one account (see PaymentBreakdown).
89
+ */
90
+ transferSettings: {
91
+ ...baseSQLFilterCompilers,
92
+ iban: createColumnFilter({
93
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'transferSettings'), '$.value.iban'),
94
+ type: SQLValueType.JSONString,
95
+ nullable: true,
96
+ }),
97
+ creditor: createColumnFilter({
98
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'transferSettings'), '$.value.creditor'),
99
+ type: SQLValueType.JSONString,
100
+ nullable: true,
101
+ }),
102
+ },
76
103
  transferDescription: createColumnFilter({
77
104
  expression: SQL.column(Payment.table, 'transferDescription'),
78
105
  type: SQLValueType.String,
79
106
  nullable: true,
80
107
  }),
81
108
  hasInvoice: createColumnFilter({
82
- expression: SQL.isNull(SQL.column('invoiceId')),
109
+ expression: SQL.isNull(SQL.column(Payment.table, 'invoiceId')),
83
110
  type: SQLValueType.Boolean,
84
111
  nullable: false,
85
112
  }),
86
113
  customer: {
87
114
  ...baseSQLFilterCompilers,
88
115
  email: createColumnFilter({
89
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.email'),
116
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.email'),
90
117
  type: SQLValueType.JSONString,
91
118
  nullable: true,
92
119
  }),
93
120
  firstName: createColumnFilter({
94
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.firstName'),
121
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.firstName'),
95
122
  type: SQLValueType.JSONString,
96
123
  nullable: true,
97
124
  }),
98
125
  lastName: createColumnFilter({
99
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.lastName'),
126
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.lastName'),
100
127
  type: SQLValueType.JSONString,
101
128
  nullable: true,
102
129
  }),
103
130
  name: createColumnFilter({
104
131
  expression: new SQLCast(
105
132
  new SQLConcat(
106
- new SQLJsonUnquote(SQL.jsonExtract(SQL.column('customer'), '$.value.firstName')),
133
+ new SQLJsonUnquote(SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.firstName')),
107
134
  new SQLScalar(' '),
108
- new SQLJsonUnquote(SQL.jsonExtract(SQL.column('customer'), '$.value.lastName')),
135
+ new SQLJsonUnquote(SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.lastName')),
109
136
  ),
110
137
  'CHAR',
111
138
  ),
@@ -115,22 +142,22 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
115
142
  company: {
116
143
  ...baseSQLFilterCompilers,
117
144
  name: createColumnFilter({
118
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.company.name'),
145
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.company.name'),
119
146
  type: SQLValueType.JSONString,
120
147
  nullable: true,
121
148
  }),
122
149
  VATNumber: createColumnFilter({
123
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.company.VATNumber'),
150
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.company.VATNumber'),
124
151
  type: SQLValueType.JSONString,
125
152
  nullable: true,
126
153
  }),
127
154
  companyNumber: createColumnFilter({
128
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.company.companyNumber'),
155
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.company.companyNumber'),
129
156
  type: SQLValueType.JSONString,
130
157
  nullable: true,
131
158
  }),
132
159
  administrationEmail: createColumnFilter({
133
- expression: SQL.jsonExtract(SQL.column('customer'), '$.value.company.administrationEmail'),
160
+ expression: SQL.jsonExtract(SQL.column(Payment.table, 'customer'), '$.value.company.administrationEmail'),
134
161
  type: SQLValueType.JSONString,
135
162
  nullable: true,
136
163
  }),
@@ -148,9 +175,44 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
148
175
  SQL.column('balance_item_payments', 'balanceItemId'),
149
176
  ),
150
177
  ).where(
151
- SQL.column('paymentId'),
178
+ SQL.column('balance_item_payments', 'paymentId'),
152
179
  SQL.column('payments', 'id'),
153
180
  ),
154
181
  balanceItemPaymentsCompilers,
155
182
  ),
156
183
  };
184
+
185
+ /**
186
+ * What a search term typed in a list of payments selects, as a filter on the payments table.
187
+ *
188
+ * Shared by everything that lists or exports payments, so a search always selects the same payments no
189
+ * matter which of those it went through.
190
+ */
191
+ export function getPaymentSearchFilter(search: string): StamhoofdFilter {
192
+ const transferDescription = search.replaceAll('+', '').replaceAll('/', '');
193
+
194
+ if (transferDescription.length === '562100153542'.length && !isNaN(parseInt(transferDescription))) {
195
+ // A structured transfer reference is only ever meant to find that one payment
196
+ return {
197
+ transferDescription: TransferSettings.structureOGM(transferDescription),
198
+ };
199
+ }
200
+
201
+ if (search.includes('@')) {
202
+ return {
203
+ $or: [
204
+ { customer: { email: { $contains: search } } },
205
+ { customer: { company: { administrationEmail: { $contains: search } } } },
206
+ ],
207
+ };
208
+ }
209
+
210
+ return {
211
+ $or: [
212
+ { customer: { name: { $contains: search } } },
213
+ { customer: { company: { name: { $contains: search } } } },
214
+ { balanceItemPayments: { $elemMatch: { balanceItem: { description: { $contains: search } } } } },
215
+ { transferDescription: { $contains: search } },
216
+ ],
217
+ };
218
+ }
@@ -0,0 +1,32 @@
1
+ import type { BalanceItemPayment } from '@stamhoofd/models';
2
+ import type { SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { SQL, SQLOrderBy } from '@stamhoofd/sql';
4
+ import { Formatter } from '@stamhoofd/utility';
5
+
6
+ export const balanceItemPaymentSorters: SQLSortDefinitions<BalanceItemPayment> = {
7
+ // WARNING! TEST NEW SORTERS THOROUGHLY! See balanceItemSorters for why sorting on anything that is
8
+ // not 1:1 with a column breaks pagination.
9
+
10
+ id: {
11
+ getValue(a) {
12
+ return a.id;
13
+ },
14
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
15
+ return new SQLOrderBy({
16
+ column: SQL.column('balance_item_payments', 'id'),
17
+ direction,
18
+ });
19
+ },
20
+ },
21
+ createdAt: {
22
+ getValue(a) {
23
+ return Formatter.dateTimeIso(a.createdAt, 'UTC');
24
+ },
25
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
26
+ return new SQLOrderBy({
27
+ column: SQL.column('balance_item_payments', 'createdAt'),
28
+ direction,
29
+ });
30
+ },
31
+ },
32
+ };