@stamhoofd/backend 2.142.0 → 2.143.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 (29) hide show
  1. package/package.json +17 -17
  2. package/src/boot.ts +31 -16
  3. package/src/crons/settlement-sync.test.ts +59 -1
  4. package/src/crons/settlement-sync.ts +19 -8
  5. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +1 -1
  6. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
  7. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
  8. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
  9. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  10. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
  11. package/src/helpers/MollieSettlementSync.test.ts +43 -0
  12. package/src/helpers/MollieSettlementSync.ts +29 -4
  13. package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
  14. package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
  15. package/src/helpers/SettlementSyncRunner.test.ts +53 -0
  16. package/src/helpers/SettlementSyncRunner.ts +20 -3
  17. package/src/helpers/StripeSettlementSync.test.ts +206 -1
  18. package/src/helpers/StripeSettlementSync.ts +106 -61
  19. package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
  20. package/src/helpers/StripeSettlementSyncRunner.ts +26 -9
  21. package/src/helpers/waitUntilDeadline.test.ts +48 -0
  22. package/src/helpers/waitUntilDeadline.ts +28 -0
  23. package/src/services/ApplicationFeeService.ts +20 -2
  24. package/src/services/BalanceItemService.ts +5 -0
  25. package/src/services/SettlementService.ts +26 -3
  26. package/src/services/WebshopCrowdfundingService.test.ts +433 -0
  27. package/src/services/WebshopCrowdfundingService.ts +98 -0
  28. package/tests/filters/orders.test.ts +24 -1
  29. package/tests/vitest.setup.ts +5 -2
@@ -0,0 +1,433 @@
1
+ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
+ import { PatchableArray } from '@simonbackx/simple-encoding';
3
+ import { Request } from '@simonbackx/simple-endpoints';
4
+ import type { Organization, StripeAccount } from '@stamhoofd/models';
5
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, Order, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
6
+ import type { OrderResponse } from '@stamhoofd/structures';
7
+ import { BalanceItemStatus, Cart, CartItem, Customer, OrderData, OrderStatus, PaymentConfiguration, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, PrivateOrder, PrivatePaymentConfiguration, PrivateWebshop, Product, ProductPrice, TransferSettings, WebshopCrowdfunding, WebshopMetaData, WebshopPrivateMetaData } from '@stamhoofd/structures';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ import type { StripeObject } from '../../tests/helpers/StripeMocker.js';
11
+ import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
12
+ import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
13
+ import { testServer } from '../../tests/helpers/TestServer.js';
14
+ import { PatchWebshopEndpoint } from '../endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.js';
15
+ import { PatchWebshopOrdersEndpoint } from '../endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.js';
16
+ import { PlaceOrderEndpoint } from '../endpoints/organization/webshops/PlaceOrderEndpoint.js';
17
+ import { BalanceItemService } from './BalanceItemService.js';
18
+ import { PaymentService } from './PaymentService.js';
19
+ import { WebshopCrowdfundingService } from './WebshopCrowdfundingService.js';
20
+
21
+ describe('WebshopCrowdfundingService', () => {
22
+ async function createWebshop(crowdfunding: WebshopCrowdfunding | null) {
23
+ const organization = await new OrganizationFactory({}).create();
24
+ return await new WebshopFactory({
25
+ organizationId: organization.id,
26
+ meta: WebshopMetaData.patch({ crowdfunding }),
27
+ }).create();
28
+ }
29
+
30
+ async function createOrderBalanceItem(order: Order, { pricePaid = 0, pricePending = 0, status = BalanceItemStatus.Due }: { pricePaid?: number; pricePending?: number; status?: BalanceItemStatus }) {
31
+ return await new BalanceItemFactory({
32
+ organizationId: order.organizationId,
33
+ orderId: order.id,
34
+ amount: 1,
35
+ unitPrice: pricePaid + pricePending,
36
+ pricePaid,
37
+ pricePending,
38
+ status,
39
+ }).create();
40
+ }
41
+
42
+ async function getMeta(webshop: Webshop) {
43
+ const updated = await Webshop.getByID(webshop.id);
44
+ return updated!.meta;
45
+ }
46
+
47
+ async function createPayment(balanceItem: BalanceItem, price: number, status: PaymentStatus) {
48
+ const payment = new Payment();
49
+ payment.organizationId = balanceItem.organizationId;
50
+ payment.method = PaymentMethod.Transfer;
51
+ payment.status = status;
52
+ payment.price = price;
53
+ await payment.save();
54
+
55
+ const balanceItemPayment = new BalanceItemPayment();
56
+ balanceItemPayment.organizationId = balanceItem.organizationId;
57
+ balanceItemPayment.paymentId = payment.id;
58
+ balanceItemPayment.balanceItemId = balanceItem.id;
59
+ balanceItemPayment.price = price;
60
+ await balanceItemPayment.save();
61
+
62
+ return payment;
63
+ }
64
+
65
+ test('updates the paid and pending amounts of the webshop', async () => {
66
+ const webshop = await createWebshop(WebshopCrowdfunding.create({ goalAmount: 500_0000 }));
67
+ const order1 = await new OrderFactory({ webshop }).create();
68
+ const order2 = await new OrderFactory({ webshop }).create();
69
+
70
+ await createOrderBalanceItem(order1, { pricePaid: 50_0000 });
71
+ await createOrderBalanceItem(order2, { pricePaid: 10_0000, pricePending: 20_0000 });
72
+
73
+ // Hidden balance items are ignored (online payments keep their balance item hidden until
74
+ // the payment succeeds)
75
+ await createOrderBalanceItem(order1, { pricePaid: 5_0000, pricePending: 3_0000, status: BalanceItemStatus.Hidden });
76
+
77
+ // Balance items of other webshops are ignored
78
+ const otherWebshop = await new WebshopFactory({ organizationId: webshop.organizationId }).create();
79
+ const otherOrder = await new OrderFactory({ webshop: otherWebshop }).create();
80
+ await createOrderBalanceItem(otherOrder, { pricePaid: 7_0000 });
81
+
82
+ await WebshopCrowdfundingService.updateForWebshop(webshop.id);
83
+
84
+ const meta = await getMeta(webshop);
85
+ expect(meta.crowdfunding).toEqual(WebshopCrowdfunding.create({
86
+ goalAmount: 500_0000,
87
+ progressAmount: 60_0000,
88
+ pendingAmount: 20_0000,
89
+ }));
90
+ });
91
+
92
+ test('resets the amounts when there are no balance items', async () => {
93
+ const webshop = await createWebshop(WebshopCrowdfunding.create({
94
+ progressAmount: 60_0000,
95
+ pendingAmount: 20_0000,
96
+ }));
97
+
98
+ await WebshopCrowdfundingService.updateForWebshop(webshop.id);
99
+
100
+ const meta = await getMeta(webshop);
101
+ expect(meta.crowdfunding).toEqual(WebshopCrowdfunding.create({
102
+ goalAmount: null,
103
+ progressAmount: 0,
104
+ pendingAmount: 0,
105
+ }));
106
+ });
107
+
108
+ test('does not calculate anything when crowdfunding is disabled', async () => {
109
+ const webshop = await createWebshop(null);
110
+ const order = await new OrderFactory({ webshop }).create();
111
+ await createOrderBalanceItem(order, { pricePaid: 50_0000 });
112
+
113
+ await WebshopCrowdfundingService.updateForWebshop(webshop.id);
114
+
115
+ const meta = await getMeta(webshop);
116
+ expect(meta.crowdfunding).toBeNull();
117
+ });
118
+
119
+ test('multiple scheduled updates result in a single update per webshop', async () => {
120
+ const webshop = await createWebshop(WebshopCrowdfunding.create({}));
121
+ const order1 = await new OrderFactory({ webshop }).create();
122
+ const order2 = await new OrderFactory({ webshop }).create();
123
+ await createOrderBalanceItem(order1, { pricePaid: 50_0000 });
124
+
125
+ // Drain the updates scheduled by the balance item creations (via model events)
126
+ await WebshopCrowdfundingService.flush();
127
+
128
+ const spy = vi.spyOn(WebshopCrowdfundingService, 'updateForWebshop');
129
+ try {
130
+ WebshopCrowdfundingService.scheduleUpdateForOrder(order1.id);
131
+ WebshopCrowdfundingService.scheduleUpdateForOrder(order1.id);
132
+ WebshopCrowdfundingService.scheduleUpdateForOrder(order2.id);
133
+ await WebshopCrowdfundingService.flush();
134
+
135
+ expect(spy).toHaveBeenCalledOnce();
136
+ } finally {
137
+ spy.mockRestore();
138
+ }
139
+
140
+ const meta = await getMeta(webshop);
141
+ expect(meta.crowdfunding!.progressAmount).toBe(50_0000);
142
+ });
143
+
144
+ test('placing an order with a balance item schedules a crowdfunding update', async () => {
145
+ const webshop = await createWebshop(WebshopCrowdfunding.create({}));
146
+ const order = await new OrderFactory({ webshop }).create();
147
+
148
+ // Creating the balance item fires a model event that schedules the update
149
+ await createOrderBalanceItem(order, { pricePaid: 30_0000, pricePending: 10_0000 });
150
+ await WebshopCrowdfundingService.flush();
151
+
152
+ const meta = await getMeta(webshop);
153
+ expect(meta.crowdfunding).toEqual(WebshopCrowdfunding.create({
154
+ progressAmount: 30_0000,
155
+ pendingAmount: 10_0000,
156
+ }));
157
+ });
158
+
159
+ test('paying an order updates the crowdfunding amounts', async () => {
160
+ const webshop = await createWebshop(WebshopCrowdfunding.create({}));
161
+ const order = await new OrderFactory({ webshop }).create();
162
+ const balanceItem = await createOrderBalanceItem(order, {});
163
+ balanceItem.unitPrice = 40_0000;
164
+ await balanceItem.save();
165
+
166
+ await createPayment(balanceItem, 30_0000, PaymentStatus.Succeeded);
167
+ await createPayment(balanceItem, 10_0000, PaymentStatus.Pending);
168
+
169
+ await BalanceItemService.updatePaidAndPending([balanceItem]);
170
+ await WebshopCrowdfundingService.flush();
171
+
172
+ const meta = await getMeta(webshop);
173
+ expect(meta.crowdfunding).toEqual(WebshopCrowdfunding.create({
174
+ progressAmount: 30_0000,
175
+ pendingAmount: 10_0000,
176
+ }));
177
+ });
178
+
179
+ test('canceling or deleting an order schedules a crowdfunding update', async () => {
180
+ const webshop = await createWebshop(WebshopCrowdfunding.create({}));
181
+ const order = await new OrderFactory({ webshop }).create();
182
+ await createOrderBalanceItem(order, { pricePaid: 50_0000 });
183
+ await WebshopCrowdfundingService.flush();
184
+
185
+ const spy = vi.spyOn(WebshopCrowdfundingService, 'scheduleUpdateForOrder');
186
+ try {
187
+ // Canceling/deleting an order cancels its balance items, which schedules an update
188
+ await BalanceItem.deleteForDeletedOrders([order.id]);
189
+ expect(spy).toHaveBeenCalledWith(order.id);
190
+ } finally {
191
+ spy.mockRestore();
192
+ }
193
+ });
194
+
195
+ describe('Order lifecycle', () => {
196
+ const placeOrderEndpoint = new PlaceOrderEndpoint();
197
+ const patchWebshopOrdersEndpoint = new PatchWebshopOrdersEndpoint();
198
+ const patchWebshopEndpoint = new PatchWebshopEndpoint();
199
+
200
+ let stripeMocker: StripeMocker;
201
+ let stripeAccount: StripeAccount;
202
+ let organization: Organization;
203
+ let token: Token;
204
+
205
+ const productPrice = ProductPrice.create({
206
+ name: 'productPrice',
207
+ price: 10_0000,
208
+ });
209
+
210
+ const product = Product.create({
211
+ name: 'product',
212
+ prices: [productPrice],
213
+ });
214
+
215
+ const customer = Customer.create({
216
+ firstName: 'John',
217
+ lastName: 'Doe',
218
+ email: 'john@example.com',
219
+ phone: '+32412345678',
220
+ });
221
+
222
+ beforeAll(async () => {
223
+ // Required for the service fee VAT settings of online payments
224
+ await initMembershipOrganization();
225
+
226
+ stripeMocker = new StripeMocker();
227
+ stripeMocker.start();
228
+ organization = await new OrganizationFactory({}).create();
229
+ stripeAccount = await stripeMocker.createStripeAccount(organization.id);
230
+
231
+ const user = await new UserFactory({
232
+ organization,
233
+ permissions: Permissions.create({
234
+ level: PermissionLevel.Full,
235
+ }),
236
+ }).create();
237
+ token = await Token.createToken(user);
238
+ });
239
+
240
+ afterAll(() => {
241
+ stripeMocker.stop();
242
+ });
243
+
244
+ beforeEach(() => {
245
+ stripeMocker.reset();
246
+ });
247
+
248
+ async function createLifecycleWebshop(crowdfunding: WebshopCrowdfunding | null = WebshopCrowdfunding.create({ goalAmount: 100_0000 })) {
249
+ const paymentConfiguration = PaymentConfiguration.patch({
250
+ transferSettings: TransferSettings.create({
251
+ iban: 'BE56587127952688', // = random IBAN
252
+ }),
253
+ });
254
+ paymentConfiguration.paymentMethods.addPut(PaymentMethod.Transfer);
255
+ paymentConfiguration.paymentMethods.addPut(PaymentMethod.Bancontact);
256
+
257
+ return await new WebshopFactory({
258
+ organizationId: organization.id,
259
+ meta: WebshopMetaData.patch({
260
+ crowdfunding,
261
+ paymentConfiguration,
262
+ }),
263
+ privateMeta: WebshopPrivateMetaData.patch({
264
+ paymentConfiguration: PrivatePaymentConfiguration.patch({
265
+ stripeAccountId: stripeAccount.id,
266
+ }),
267
+ }),
268
+ products: [product],
269
+ }).create();
270
+ }
271
+
272
+ function buildOrderData(paymentMethod: PaymentMethod) {
273
+ return OrderData.create({
274
+ paymentMethod,
275
+ cart: Cart.create({
276
+ items: [
277
+ CartItem.create({
278
+ product,
279
+ productPrice,
280
+ amount: 1,
281
+ }),
282
+ ],
283
+ }),
284
+ customer,
285
+ });
286
+ }
287
+
288
+ async function placeOrder(webshop: Webshop, paymentMethod: PaymentMethod) {
289
+ const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), buildOrderData(paymentMethod));
290
+ const response = await testServer.test(placeOrderEndpoint, r);
291
+ const body = response.body as OrderResponse;
292
+ const order = (await Order.getByID(body.order.id))!;
293
+ const payment = (await Payment.getByID(order.paymentId!))!;
294
+ return { order, payment };
295
+ }
296
+
297
+ async function getCrowdfunding(webshop: Webshop) {
298
+ await WebshopCrowdfundingService.flush();
299
+ return (await getMeta(webshop)).crowdfunding!;
300
+ }
301
+
302
+ test('an online payment only counts once it succeeds', async () => {
303
+ const webshop = await createLifecycleWebshop();
304
+ const { payment } = await placeOrder(webshop, PaymentMethod.Bancontact);
305
+
306
+ // Online payments don't count as pending: their balance item stays hidden
307
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 0 });
308
+
309
+ // Payment becomes pending at the payment provider
310
+ const intent = stripeMocker.getLastIntent() as StripeObject;
311
+ intent.status = 'processing';
312
+ await PaymentService.pollStatus(payment.id, organization);
313
+ expect((await Payment.getByID(payment.id))!.status).toBe(PaymentStatus.Pending);
314
+
315
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 0 });
316
+
317
+ // Payment succeeds
318
+ intent.status = 'succeeded';
319
+ intent.latest_charge = 'ch_mocked';
320
+ await PaymentService.pollStatus(payment.id, organization);
321
+ expect((await Payment.getByID(payment.id))!.status).toBe(PaymentStatus.Succeeded);
322
+
323
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
324
+ });
325
+
326
+ test('a failed online payment does not count', async () => {
327
+ const webshop = await createLifecycleWebshop();
328
+ const { payment } = await placeOrder(webshop, PaymentMethod.Bancontact);
329
+
330
+ const intent = stripeMocker.getLastIntent() as StripeObject;
331
+ intent.status = 'processing';
332
+ await PaymentService.pollStatus(payment.id, organization);
333
+
334
+ intent.status = 'canceled';
335
+ await PaymentService.pollStatus(payment.id, organization);
336
+ expect((await Payment.getByID(payment.id))!.status).toBe(PaymentStatus.Failed);
337
+
338
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 0 });
339
+ });
340
+
341
+ test('a transfer order counts as pending and counts as progress once an admin marks the payment as paid', async () => {
342
+ const webshop = await createLifecycleWebshop();
343
+ const { payment } = await placeOrder(webshop, PaymentMethod.Transfer);
344
+
345
+ // The transfer that has not been received yet counts as pending
346
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 10_0000 });
347
+
348
+ // This is what PatchPaymentsEndpoint calls when an admin marks the transfer as paid
349
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
350
+
351
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
352
+ });
353
+
354
+ test('a transfer order stops counting when the payment is marked as failed afterwards', async () => {
355
+ const webshop = await createLifecycleWebshop();
356
+ const { payment } = await placeOrder(webshop, PaymentMethod.Transfer);
357
+
358
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
359
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
360
+
361
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
362
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 0 });
363
+ });
364
+
365
+ test('an order created manually by an admin counts once paid', async () => {
366
+ const webshop = await createLifecycleWebshop();
367
+
368
+ const patchArray: PatchableArrayAutoEncoder<PrivateOrder> = new PatchableArray();
369
+ patchArray.addPut(PrivateOrder.create({
370
+ id: uuidv4(),
371
+ data: buildOrderData(PaymentMethod.Transfer),
372
+ webshopId: webshop.id,
373
+ }));
374
+
375
+ const r = Request.buildJson('PATCH', `/webshop/${webshop.id}/orders`, organization.getApiHost(), patchArray);
376
+ r.headers.authorization = 'Bearer ' + token.accessToken;
377
+ const response = await testServer.test(patchWebshopOrdersEndpoint, r);
378
+ expect(response.body).toHaveLength(1);
379
+
380
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 10_0000 });
381
+
382
+ const order = (await Order.getByID(response.body[0].id))!;
383
+ const payment = (await Payment.getByID(order.paymentId!))!;
384
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
385
+
386
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
387
+ });
388
+
389
+ test('an order canceled by an admin no longer counts', async () => {
390
+ const webshop = await createLifecycleWebshop();
391
+ const { order, payment } = await placeOrder(webshop, PaymentMethod.Transfer);
392
+
393
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
394
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
395
+
396
+ const patchArray: PatchableArrayAutoEncoder<PrivateOrder> = new PatchableArray();
397
+ patchArray.addPatch(PrivateOrder.patch({
398
+ id: order.id,
399
+ status: OrderStatus.Canceled,
400
+ }));
401
+
402
+ const r = Request.buildJson('PATCH', `/webshop/${webshop.id}/orders`, organization.getApiHost(), patchArray);
403
+ r.headers.authorization = 'Bearer ' + token.accessToken;
404
+ await testServer.test(patchWebshopOrdersEndpoint, r);
405
+
406
+ // Canceling cancels the balance items, so the paid amount no longer counts
407
+ expect(await getCrowdfunding(webshop)).toMatchObject({ progressAmount: 0, pendingAmount: 0 });
408
+ });
409
+
410
+ test('enabling crowdfunding recalculates the amounts of existing orders', async () => {
411
+ const webshop = await createLifecycleWebshop(null);
412
+ const { payment } = await placeOrder(webshop, PaymentMethod.Transfer);
413
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
414
+ await WebshopCrowdfundingService.flush();
415
+
416
+ expect((await getMeta(webshop)).crowdfunding).toBeNull();
417
+
418
+ const patch = PrivateWebshop.patch({
419
+ meta: WebshopMetaData.patch({
420
+ crowdfunding: WebshopCrowdfunding.create({ goalAmount: 100_0000 }),
421
+ }),
422
+ });
423
+
424
+ const r = Request.buildJson('PATCH', `/webshop/${webshop.id}`, organization.getApiHost(), patch);
425
+ r.headers.authorization = 'Bearer ' + token.accessToken;
426
+ const response = await testServer.test(patchWebshopEndpoint, r);
427
+
428
+ // The response already contains the recalculated amounts
429
+ expect(response.body.meta.crowdfunding).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
430
+ expect((await getMeta(webshop)).crowdfunding).toMatchObject({ progressAmount: 10_0000, pendingAmount: 0 });
431
+ });
432
+ });
433
+ });
@@ -0,0 +1,98 @@
1
+ import { BalanceItem, Order, Webshop } from '@stamhoofd/models';
2
+ import { QueueHandler } from '@stamhoofd/queues';
3
+ import { SQL, SQLAlias, SQLSelectAs, SQLSum } from '@stamhoofd/sql';
4
+ import { BalanceItemStatus } from '@stamhoofd/structures';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+
7
+ import { ThrottledQueue } from '../helpers/ThrottledQueue.js';
8
+
9
+ // Balance items only store the orderId, so the webshop is resolved inside the queue
10
+ const orderUpdateQueue = new ThrottledQueue(async (orderIds: string[]) => {
11
+ const orders = await Order.getByIDs(...orderIds);
12
+ const webshopIds = Formatter.uniqueArray(orders.map(o => o.webshopId));
13
+
14
+ for (const webshopId of webshopIds) {
15
+ await WebshopCrowdfundingService.updateForWebshop(webshopId);
16
+ }
17
+ });
18
+
19
+ export class WebshopCrowdfundingService {
20
+ /**
21
+ * Update the cached crowdfunding amounts of the webshop of this order in the background.
22
+ * Multiple calls within the throttle window (10s) result in a single update per webshop.
23
+ */
24
+ static scheduleUpdateForOrder(orderId: string) {
25
+ // STAMHOOFD is not available yet when this module is loaded
26
+ orderUpdateQueue.maxDelay = STAMHOOFD.environment === 'development' ? 1000 : 10_000;
27
+ orderUpdateQueue.addItem(orderId);
28
+ }
29
+
30
+ /**
31
+ * Make sure all scheduled updates have run (on shutdown and in tests).
32
+ */
33
+ static async flush() {
34
+ await orderUpdateQueue.flushAndWait();
35
+ }
36
+
37
+ /**
38
+ * Recalculates meta.crowdfunding.progressAmount (paid) and .pendingAmount (pending payments)
39
+ * by summing the balance items of all orders of this webshop in a single query.
40
+ * Does nothing when crowdfunding is not enabled (meta.crowdfunding is null).
41
+ */
42
+ static async updateForWebshop(webshopId: string) {
43
+ // Same queue as stock updates: all webshop writes are serialized to prevent conflicting saves
44
+ await QueueHandler.schedule('webshop-stock/' + webshopId, async () => {
45
+ const webshop = await Webshop.getByID(webshopId);
46
+ if (!webshop) {
47
+ return;
48
+ }
49
+
50
+ await this.updateWebshop(webshop);
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Same as updateForWebshop, but for an already loaded webshop. Should only be used inside
56
+ * the webshop-stock queue of this webshop (where updateForWebshop would deadlock).
57
+ */
58
+ static async updateWebshop(webshop: Webshop) {
59
+ const crowdfunding = webshop.meta.crowdfunding;
60
+ if (!crowdfunding) {
61
+ return;
62
+ }
63
+
64
+ // Only due balance items count: online payments keep their balance item hidden until
65
+ // the payment succeeds, so their unresolved payments don't increase the pending amount.
66
+ // Unconfirmed transfer and point of sale payments do (their balance item is due).
67
+ const row = await SQL.select(
68
+ new SQLSelectAs(
69
+ new SQLSum(SQL.column(BalanceItem.table, 'pricePaid')),
70
+ new SQLAlias('data__pricePaid'),
71
+ ),
72
+ new SQLSelectAs(
73
+ new SQLSum(SQL.column(BalanceItem.table, 'pricePending')),
74
+ new SQLAlias('data__pricePending'),
75
+ ),
76
+ )
77
+ .from(BalanceItem.table)
78
+ .join(
79
+ SQL.join(Order.table)
80
+ .where(SQL.column(BalanceItem.table, 'orderId'), SQL.column(Order.table, 'id')),
81
+ )
82
+ .where(SQL.column(Order.table, 'webshopId'), webshop.id)
83
+ .where(SQL.column(BalanceItem.table, 'status'), BalanceItemStatus.Due)
84
+ .first(false);
85
+
86
+ // Sums are null when the webshop has no balance items
87
+ const progressAmount = typeof row?.['data']?.['pricePaid'] === 'number' ? row['data']['pricePaid'] : 0;
88
+ const pendingAmount = typeof row?.['data']?.['pricePending'] === 'number' ? row['data']['pricePending'] : 0;
89
+
90
+ if (crowdfunding.progressAmount === progressAmount && crowdfunding.pendingAmount === pendingAmount) {
91
+ return;
92
+ }
93
+
94
+ crowdfunding.progressAmount = progressAmount;
95
+ crowdfunding.pendingAmount = pendingAmount;
96
+ await webshop.save();
97
+ }
98
+ }
@@ -19,7 +19,7 @@ import { orderFilterCompilers } from '../../src/sql-filters/orders.js';
19
19
  *
20
20
  * Filters that only exist in one engine are intentionally not covered here (no counterpart to compare to):
21
21
  * - in-memory only: location, openBalance, ticketScanStatus, ticketScannedAt, ticketCount
22
- * - SQL only: organizationId, updatedAt, paymentMethod
22
+ * - SQL only: organizationId, updatedAt
23
23
  */
24
24
  describe('Order filters (in-memory vs backend SQL parity)', () => {
25
25
  let organization: Organization;
@@ -72,6 +72,7 @@ describe('Order filters (in-memory vs backend SQL parity)', () => {
72
72
  discountCodes?: DiscountCode[];
73
73
  items?: CartItem[];
74
74
  recordAnswers?: Map<string, RecordCheckboxAnswer | RecordTextAnswer | RecordChooseOneAnswer | RecordMultipleChoiceAnswer | RecordDateAnswer | RecordIntegerAnswer>;
75
+ paymentMethod?: PaymentMethod;
75
76
  } = {}): OrderData {
76
77
  return OrderData.create({
77
78
  customer: Customer.create({
@@ -85,6 +86,7 @@ describe('Order filters (in-memory vs backend SQL parity)', () => {
85
86
  discountCodes: options.discountCodes ?? [],
86
87
  cart: Cart.create({ items: options.items ?? [] }),
87
88
  recordAnswers: options.recordAnswers ?? new Map(),
89
+ paymentMethod: options.paymentMethod ?? PaymentMethod.Unknown,
88
90
  });
89
91
  }
90
92
 
@@ -228,6 +230,27 @@ describe('Order filters (in-memory vs backend SQL parity)', () => {
228
230
  });
229
231
  });
230
232
 
233
+ describe('paymentMethod', () => {
234
+ it('$eq / $in / $neq on the enum', async () => {
235
+ const transfer = await createOrder({ data: orderData({ paymentMethod: PaymentMethod.Transfer }) });
236
+ const bancontact = await createOrder({ data: orderData({ paymentMethod: PaymentMethod.Bancontact }) });
237
+ const pointOfSale = await createOrder({ data: orderData({ paymentMethod: PaymentMethod.PointOfSale }) });
238
+
239
+ await expectFilter({ paymentMethod: { $eq: PaymentMethod.Transfer } }, [transfer]);
240
+ await expectFilter({ paymentMethod: { $eq: PaymentMethod.Bancontact } }, [bancontact]);
241
+ await expectFilter({ paymentMethod: { $in: [PaymentMethod.Transfer, PaymentMethod.PointOfSale] } }, [transfer, pointOfSale]);
242
+ await expectFilter({ paymentMethod: { $neq: PaymentMethod.Bancontact } }, [transfer, pointOfSale]);
243
+ });
244
+
245
+ it('$gt / $lt as used by the pagination filter when sorting', async () => {
246
+ const bancontact = await createOrder({ data: orderData({ paymentMethod: PaymentMethod.Bancontact }) });
247
+ const transfer = await createOrder({ data: orderData({ paymentMethod: PaymentMethod.Transfer }) });
248
+
249
+ await expectFilter({ paymentMethod: { $gt: PaymentMethod.Bancontact } }, [transfer]);
250
+ await expectFilter({ paymentMethod: { $lt: PaymentMethod.Transfer } }, [bancontact]);
251
+ });
252
+ });
253
+
231
254
  describe('number', () => {
232
255
  it('numeric comparisons', async () => {
233
256
  const a = await createOrder({ number: 10 });
@@ -12,6 +12,7 @@ import { sleep } from '@stamhoofd/utility';
12
12
  import * as jose from 'jose';
13
13
  import { GlobalHelper } from '../src/helpers/GlobalHelper.js';
14
14
  import { BalanceItemService } from '../src/services/BalanceItemService.js';
15
+ import { WebshopCrowdfundingService } from '../src/services/WebshopCrowdfundingService.js';
15
16
  import { PayconiqMocker } from './helpers/PayconiqMocker.js';
16
17
  import { resetNock } from './helpers/resetNock.js';
17
18
 
@@ -56,9 +57,10 @@ beforeAll(async () => {
56
57
  await Database.delete('DELETE FROM `groups`');
57
58
  await Database.delete('DELETE FROM `email_addresses`');
58
59
 
59
- // invoiced_balance_items restricts deleting balance items, which blocks the organizations
60
- // cascade below, so it has to be cleared first
60
+ // invoiced_balance_items and application_fees restrict deleting balance items, which blocks the
61
+ // organizations cascade below, so they have to be cleared first
61
62
  await Database.delete('DELETE FROM `invoiced_balance_items`');
63
+ await Database.delete('DELETE FROM `application_fees`');
62
64
  await Database.delete('DELETE FROM `invoices`');
63
65
 
64
66
  // payments restrict deleting stripe accounts (which cascade from organizations), and a payment
@@ -91,6 +93,7 @@ afterAll(async () => {
91
93
  // Call twice to also wait on items that are scheduled withing scheduled tasks
92
94
  await BalanceItemService.flushAll();
93
95
  await BalanceItemService.flushAll();
96
+ await WebshopCrowdfundingService.flush();
94
97
  QueueHandler.abortAll(
95
98
  new SimpleError({
96
99
  code: 'SHUTDOWN',