@stamhoofd/backend 2.141.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.
- package/package.json +17 -17
- package/src/boot.ts +31 -16
- package/src/crons/settlement-sync.test.ts +59 -1
- package/src/crons/settlement-sync.ts +20 -9
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
- package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
- package/src/helpers/MollieSettlementSync.test.ts +43 -0
- package/src/helpers/MollieSettlementSync.ts +29 -4
- package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
- package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
- package/src/helpers/SettlementExporter.test.ts +22 -0
- package/src/helpers/SettlementExporter.ts +13 -3
- package/src/helpers/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +375 -1
- package/src/helpers/StripeSettlementSync.ts +264 -113
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +47 -11
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.test.ts +82 -9
- package/src/services/SettlementService.ts +72 -6
- package/src/services/WebshopCrowdfundingService.test.ts +433 -0
- package/src/services/WebshopCrowdfundingService.ts +98 -0
- package/tests/filters/orders.test.ts +24 -1
- 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
|
|
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 });
|
package/tests/vitest.setup.ts
CHANGED
|
@@ -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
|
|
60
|
-
// cascade below, so
|
|
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',
|