@stamhoofd/backend 2.143.0 → 2.144.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.143.0",
3
+ "version": "2.144.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -65,20 +65,20 @@
65
65
  "@simonbackx/simple-errors": "1.5.0",
66
66
  "@simonbackx/simple-logging": "1.0.1",
67
67
  "@simplewebauthn/server": "13.3.2",
68
- "@stamhoofd/backend-env": "2.143.0",
69
- "@stamhoofd/backend-i18n": "2.143.0",
70
- "@stamhoofd/backend-middleware": "2.143.0",
71
- "@stamhoofd/crons": "2.143.0",
72
- "@stamhoofd/email": "2.143.0",
73
- "@stamhoofd/excel-writer": "2.143.0",
74
- "@stamhoofd/logging": "2.143.0",
75
- "@stamhoofd/models": "2.143.0",
76
- "@stamhoofd/object-differ": "2.143.0",
77
- "@stamhoofd/queues": "2.143.0",
78
- "@stamhoofd/sql": "2.143.0",
79
- "@stamhoofd/structures": "2.143.0",
80
- "@stamhoofd/types": "2.143.0",
81
- "@stamhoofd/utility": "2.143.0",
68
+ "@stamhoofd/backend-env": "2.144.0",
69
+ "@stamhoofd/backend-i18n": "2.144.0",
70
+ "@stamhoofd/backend-middleware": "2.144.0",
71
+ "@stamhoofd/crons": "2.144.0",
72
+ "@stamhoofd/email": "2.144.0",
73
+ "@stamhoofd/excel-writer": "2.144.0",
74
+ "@stamhoofd/logging": "2.144.0",
75
+ "@stamhoofd/models": "2.144.0",
76
+ "@stamhoofd/object-differ": "2.144.0",
77
+ "@stamhoofd/queues": "2.144.0",
78
+ "@stamhoofd/sql": "2.144.0",
79
+ "@stamhoofd/structures": "2.144.0",
80
+ "@stamhoofd/types": "2.144.0",
81
+ "@stamhoofd/utility": "2.144.0",
82
82
  "archiver": "7.0.1",
83
83
  "argon2": "0.44.0",
84
84
  "axios": "1.18.1",
@@ -102,7 +102,7 @@
102
102
  "uuid": "14.0.1"
103
103
  },
104
104
  "devDependencies": {
105
- "@stamhoofd/test-utils": "2.143.0",
105
+ "@stamhoofd/test-utils": "2.144.0",
106
106
  "@types/cookie": "0.6.0",
107
107
  "@types/luxon": "3.7.2",
108
108
  "@types/mailparser": "3.4.6",
@@ -117,5 +117,5 @@
117
117
  "publishConfig": {
118
118
  "access": "public"
119
119
  },
120
- "gitHead": "8959c4f720d332be9769e033d40bb25f9e6aa322"
120
+ "gitHead": "31bc31b2a56d9516e8bc6be5779bc58af0022a88"
121
121
  }
package/src/boot.ts CHANGED
@@ -43,6 +43,7 @@ Column.setJSONVersion(Version);
43
43
 
44
44
  // Set timezone to UTC
45
45
  process.env.TZ = 'UTC';
46
+ Error.stackTraceLimit = 50;
46
47
 
47
48
  if (STAMHOOFD.environment === 'development') {
48
49
  Error.stackTraceLimit = 100;
@@ -90,8 +90,8 @@ describe('Cron.fake-settlements', () => {
90
90
  settledAt: Formatter.luxon(inWeek(2)).startOf('week').plus({ weeks: 1, days: 2 }).toJSDate(),
91
91
 
92
92
  // Refunds are taken out of the payout, just like the provider does, and so are the
93
- // fake Mollie costs: 3 payments x 0.30 + 21% VAT
94
- amount: 65_0000 - 90_00 - 18_90,
93
+ // fake Mollie fees: 3 payments x 0.363
94
+ amount: 65_0000 - 3 * 36_30,
95
95
  });
96
96
 
97
97
  // All payments of the week point to the same payout
@@ -135,8 +135,7 @@ describe('Cron.fake-settlements', () => {
135
135
  ];
136
136
 
137
137
  // Same week, but four accounts: four payouts, each holding only its own money. The Mollie
138
- // payouts are reduced by the fake cost of one payment (0.30 + 21% VAT); the Stripe payments
139
- // carry no fees here
138
+ // payouts are reduced by the fake fee of one payment; the Stripe payments carry no fees here
140
139
  expect(settlements.map(s => s!.amount)).toEqual([50_0000 - 36_30, 30_0000 - 36_30, 20_0000, 15_0000]);
141
140
  expect(new Set(settlements.map(s => s!.id)).size).toBe(4);
142
141
  expect(new Set(settlements.map(s => s!.reference)).size).toBe(4);
@@ -192,7 +191,7 @@ describe('Cron.fake-settlements', () => {
192
191
  return await Settlement.select().where('externalId', reference!.id).first(true);
193
192
  };
194
193
 
195
- test('A Mollie payout stores payment lines and cost rows, and reconciles to zero', async () => {
194
+ test('A Mollie payout stores payment lines and fee rows, and reconciles to zero', async () => {
196
195
  const first = await createPayment({ price: 50_0000 });
197
196
  const second = await createPayment({ price: -10_0000, type: PaymentType.Refund });
198
197
 
@@ -201,7 +200,7 @@ describe('Cron.fake-settlements', () => {
201
200
  const settlement = await getSettlementRow(first);
202
201
  expect(settlement.provider).toBe(PaymentProvider.Mollie);
203
202
  expect(settlement.organizationId).toBe(organization.id);
204
- expect(settlement.amount).toBe(40_0000 - 60_00 - 12_60);
203
+ expect(settlement.amount).toBe(40_0000 - 2 * 36_30);
205
204
  expect(settlement.unexplainedAmount).toBe(0);
206
205
  expect(settlement.syncedAt).not.toBeNull();
207
206
 
@@ -211,12 +210,15 @@ describe('Cron.fake-settlements', () => {
211
210
  [second.id, -10_0000],
212
211
  ].sort());
213
212
 
214
- const settledMonth = settlement.settledAt.getFullYear() + '-' + (settlement.settledAt.getMonth() + 1).toString().padStart(2, '0');
215
213
  const charges = await SettlementCharge.select().where('settlementId', settlement.id).fetch();
216
- expect(charges.map(c => ({ type: c.type, amount: c.amount, providerInvoiceId: c.providerInvoiceId })).sort((a, b) => a.amount - b.amount)).toEqual([
217
- { type: SettlementChargeType.ProviderTransactionFee, amount: -60_00, providerInvoiceId: 'fake-invoice-mollie-' + settledMonth },
218
- { type: SettlementChargeType.Tax, amount: -12_60, providerInvoiceId: 'fake-invoice-mollie-' + settledMonth },
219
- ]);
214
+ expect(charges.map(c => [c.paymentId, c.type, c.amount]).sort()).toEqual([
215
+ [first.id, SettlementChargeType.ProviderTransactionFee, -36_30],
216
+ [second.id, SettlementChargeType.ProviderTransactionFee, -36_30],
217
+ ].sort());
218
+
219
+ // Each settled payment holds its own fee
220
+ expect((await Payment.getByID(first.id))!.transferFee).toBe(36_30);
221
+ expect((await Payment.getByID(second.id))!.transferFee).toBe(36_30);
220
222
  });
221
223
 
222
224
  test('A Stripe application fee is mirrored on the payout and a monthly platform payout', async () => {
@@ -34,10 +34,9 @@ const PAYOUT_DELAY_DAYS = 2;
34
34
  const BATCH_SIZE = 1000;
35
35
 
36
36
  /**
37
- * Fake Mollie transaction fee per settled payment, excluding VAT.
37
+ * Fake Mollie transaction fee per settled payment, including VAT.
38
38
  */
39
- const MOLLIE_FEE_PER_PAYMENT = 30_00;
40
- const MOLLIE_FEE_VAT_PERCENTAGE = 21;
39
+ const MOLLIE_FEE_PER_PAYMENT = 36_30;
41
40
 
42
41
  export async function createFakeSettlements() {
43
42
  if (STAMHOOFD.environment !== 'development') {
@@ -183,8 +182,7 @@ async function settleWeek(payments: Payment[], weekStart: DateTime, settledAt: D
183
182
  return total + serviceFee + transferFee;
184
183
  }, 0);
185
184
  } else if (provider === PaymentProvider.Mollie) {
186
- const net = MOLLIE_FEE_PER_PAYMENT * group.length;
187
- fees = net + Math.round(net * MOLLIE_FEE_VAT_PERCENTAGE / 100);
185
+ fees = MOLLIE_FEE_PER_PAYMENT * group.length;
188
186
  }
189
187
 
190
188
  const grossAmount = group.reduce((total, payment) => total + payment.price, 0);
@@ -223,7 +221,7 @@ async function settleWeek(payments: Payment[], weekStart: DateTime, settledAt: D
223
221
  if (provider === PaymentProvider.Stripe) {
224
222
  await createStripeFeeRows(settlement, group, settledAt, platformOrganizationId);
225
223
  } else if (provider === PaymentProvider.Mollie) {
226
- await createMollieCostRows(settlement, group, settledAt);
224
+ await createMollieFeeRows(settlement, group);
227
225
  }
228
226
 
229
227
  await finishFakeSync(settlement);
@@ -322,43 +320,29 @@ async function createStripeFeeRows(settlement: Settlement, group: Payment[], set
322
320
 
323
321
  // A dev database can contain fee payments (e.g. copied history): keep their derived lines
324
322
  // in sync like a real payout walk does
325
- await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
323
+ await SettlementService.updatePaymentSettlementsForApplicationFeeBalanceItems([...invoicedFeeBalanceItemIds]);
326
324
 
327
325
  await finishFakeSync(platformSettlement);
328
326
  }
329
327
 
330
328
  /**
331
- * Mollie deducts its own costs from the settlement: one fake transaction fee line plus its VAT, so
332
- * the settlement reconciles to 0 like a real one.
329
+ * Mollie deducts its fee per transaction: one fake fee charge per settled payment, linked to it
330
+ * like the real balance transaction walk stores, so the settlement reconciles to 0.
333
331
  */
334
- async function createMollieCostRows(settlement: Settlement, group: Payment[], settledAt: Date) {
335
- const externalId = settlement.externalId + ':cost:0';
336
- const existing = await SettlementCharge.select().where('externalId', externalId).first(false);
337
-
338
- const addedNet = MOLLIE_FEE_PER_PAYMENT * group.length;
339
- const net = (existing ? -existing.amount : 0) + addedNet;
340
- const vat = Math.round(net * MOLLIE_FEE_VAT_PERCENTAGE / 100);
341
- const providerInvoiceId = 'fake-invoice-mollie-' + SettlementService.getPeriodKey(settledAt);
342
-
343
- await SettlementService.upsertCharge({
344
- type: SettlementChargeType.ProviderTransactionFee,
345
- externalId,
346
- amount: -net,
347
- settlementId: settlement.id,
348
- organizationId: settlement.organizationId,
349
- providerInvoiceId,
350
- description: 'DEV Mollie transactiekosten',
351
- occurredAt: settledAt,
352
- });
332
+ async function createMollieFeeRows(settlement: Settlement, group: Payment[]) {
333
+ for (const payment of group) {
334
+ await SettlementService.upsertCharge({
335
+ type: SettlementChargeType.ProviderTransactionFee,
336
+ externalId: 'fake-baltr-' + payment.id,
337
+ amount: -MOLLIE_FEE_PER_PAYMENT,
338
+ settlementId: settlement.id,
339
+ paymentId: payment.id,
340
+ organizationId: settlement.organizationId,
341
+ description: 'DEV Mollie transactiekosten',
342
+ occurredAt: payment.paidAt!,
343
+ });
353
344
 
354
- await SettlementService.upsertCharge({
355
- type: SettlementChargeType.Tax,
356
- externalId: externalId + ':tax',
357
- amount: -vat,
358
- settlementId: settlement.id,
359
- organizationId: settlement.organizationId,
360
- providerInvoiceId,
361
- description: 'DEV BTW op Mollie transactiekosten',
362
- occurredAt: settledAt,
363
- });
345
+ payment.transferFee = MOLLIE_FEE_PER_PAYMENT;
346
+ await payment.save();
347
+ }
364
348
  }
@@ -1,4 +1,4 @@
1
- import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; // ES Modules import
1
+ import { PutObjectCommand } from '@aws-sdk/client-s3'; // ES Modules import
2
2
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
3
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -11,7 +11,8 @@ import { v4 as uuidv4 } from 'uuid';
11
11
  import type { Decoder } from '@simonbackx/simple-encoding';
12
12
  import { AutoEncoder, BooleanDecoder, field } from '@simonbackx/simple-encoding';
13
13
  import { Context } from '../../../helpers/Context.js';
14
- import { limiter } from './UploadImage.js';
14
+ import { limiter, organizationLimiter } from './UploadImage.js';
15
+ import type { Organization } from '@stamhoofd/models';
15
16
  import { Image } from '@stamhoofd/models';
16
17
 
17
18
  type Params = Record<string, never>;
@@ -58,11 +59,20 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
58
59
  }
59
60
 
60
61
  async handle(request: DecodedRequest<Params, Query, Body>) {
61
- await Context.setOptionalOrganizationScope();
62
- const { user } = await Context.authenticate();
62
+ const organization = await Context.setOptionalOrganizationScope();
63
+ const { user } = await Context.optionalAuthenticate();
63
64
 
64
- if (!Context.auth.canUpload({ private: request.query.isPrivate })) {
65
- throw Context.auth.error();
65
+ // Webshops allow for file upload, but don't have a user attached
66
+ if (user) {
67
+ if (!Context.auth?.canUpload({ private: request.query.isPrivate })) {
68
+ throw Context.auth.error();
69
+ }
70
+ } else if (!organization) {
71
+ throw new SimpleError({
72
+ code: 'permission_denied',
73
+ message: 'Endpoints needs organization if no user is present',
74
+ human: $t('%ZlM'),
75
+ });
66
76
  }
67
77
 
68
78
  if (!STAMHOOFD.SPACES_BUCKET || !STAMHOOFD.SPACES_ENDPOINT || !STAMHOOFD.SPACES_KEY || !STAMHOOFD.SPACES_SECRET) {
@@ -77,7 +87,14 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
77
87
  throw new Error('Not supported without real request');
78
88
  }
79
89
 
80
- limiter.track(user.id, 1);
90
+ if (user) {
91
+ limiter.track(user.id, 1);
92
+ } else {
93
+ limiter.track(request.request.getIP(), 1);
94
+ if (organization) {
95
+ organizationLimiter.track(organization.id, 1);
96
+ }
97
+ }
81
98
 
82
99
  const form = formidable({ maxFileSize: 20 * 1024 * 1024, maxFields: 1, keepExtensions: true });
83
100
  const file = await new Promise<FormidableFile>((resolve, reject) => {
@@ -89,34 +106,43 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
89
106
  }));
90
107
  return;
91
108
  }
92
- form.parse(request.request.request, (err, fields, files) => {
93
- if (err) {
94
- reject(err);
95
- return;
109
+ try {
110
+ if (request.request.request?.destroyed) {
111
+ throw new Error('Request destroyed before parsing');
96
112
  }
97
-
98
- if (!files.file || !Array.isArray(files.file) || files.file.length !== 1) {
99
- reject(new SimpleError({
100
- code: 'missing_field',
101
- message: 'Missing file',
102
- field: 'file',
103
- }));
104
- return;
105
- }
106
-
107
- resolve(files.file[0]);
108
- });
113
+ form.parse(request.request.request, (err, fields, files) => {
114
+ console.log('form.parse', err, fields, files);
115
+ if (err) {
116
+ reject(err);
117
+ return;
118
+ }
119
+
120
+ if (!files.file || !Array.isArray(files.file) || files.file.length !== 1) {
121
+ reject(new SimpleError({
122
+ code: 'missing_field',
123
+ message: 'Missing file',
124
+ field: 'file',
125
+ }));
126
+ return;
127
+ }
128
+
129
+ resolve(files.file[0]);
130
+ });
131
+ } catch (e) {
132
+ console.error(e);
133
+ reject(e);
134
+ }
109
135
  });
110
136
 
111
137
  try {
112
- return await this.upload(request, file, user);
138
+ return await this.upload(request, file, organization, user);
113
139
  } finally {
114
140
  // Formidable wrote the upload to a temporary file
115
141
  await fs.rm(file.filepath, { force: true }).catch(() => { /* we can't do anything about this */ });
116
142
  }
117
143
  }
118
144
 
119
- private async upload(request: DecodedRequest<Params, Query, Body>, file: FormidableFile, user: { id: string }) {
145
+ private async upload(request: DecodedRequest<Params, Query, Body>, file: FormidableFile, organization: Organization | null, user?: { id: string }) {
120
146
  if (!STAMHOOFD.SPACES_BUCKET || !STAMHOOFD.SPACES_ENDPOINT || !STAMHOOFD.SPACES_KEY || !STAMHOOFD.SPACES_SECRET) {
121
147
  throw new SimpleError({
122
148
  code: 'not_available',
@@ -156,6 +182,8 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
156
182
  if (request.query.isPrivate && user) {
157
183
  // Private files
158
184
  prefix += 'users/' + user.id + '/';
185
+ } else if (request.query.isPrivate && !user && organization) {
186
+ prefix += 'anonymous/' + organization.id + '/';
159
187
  } else {
160
188
  // Public files
161
189
  prefix += 'p/';
@@ -3,6 +3,7 @@ import { AutoEncoder, BooleanDecoder, field, ObjectData } from '@simonbackx/simp
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
+ import type { Organization } from '@stamhoofd/models';
6
7
  import { Image, RateLimiter } from '@stamhoofd/models';
7
8
  import { Image as ImageStruct, ResolutionRequest, supportedImageTypes } from '@stamhoofd/structures';
8
9
  import formidable from 'formidable';
@@ -44,6 +45,15 @@ export const limiter = new RateLimiter({
44
45
  },
45
46
  ],
46
47
  });
48
+ export const organizationLimiter = new RateLimiter({
49
+ limits: [
50
+ {
51
+ // Max 200 per day
52
+ limit: 200,
53
+ duration: 60 * 1000 * 60 * 24,
54
+ },
55
+ ],
56
+ });
47
57
 
48
58
  export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
49
59
  queryDecoder = Query as Decoder<Query>;
@@ -63,11 +73,20 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
63
73
  }
64
74
 
65
75
  async handle(request: DecodedRequest<Params, Query, Body>) {
66
- await Context.setOptionalOrganizationScope();
67
- const { user } = await Context.authenticate();
76
+ const organization = await Context.setOptionalOrganizationScope();
77
+ const { user } = await Context.optionalAuthenticate();
68
78
 
69
- if (!Context.auth.canUpload({ private: request.query.isPrivate })) {
70
- throw Context.auth.error();
79
+ // Webshops allow for file upload, but don't have a user attached
80
+ if (user) {
81
+ if (!Context.auth?.canUpload({ private: request.query.isPrivate })) {
82
+ throw Context.auth.error();
83
+ }
84
+ } else if (!organization) {
85
+ throw new SimpleError({
86
+ code: 'permission_denied',
87
+ message: 'Endpoints needs organization if no user is present',
88
+ human: $t('%ZlM'),
89
+ });
71
90
  }
72
91
 
73
92
  if (!STAMHOOFD.SPACES_BUCKET || !STAMHOOFD.SPACES_ENDPOINT || !STAMHOOFD.SPACES_KEY || !STAMHOOFD.SPACES_SECRET) {
@@ -82,7 +101,14 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
82
101
  throw new Error('Not supported without real request');
83
102
  }
84
103
 
85
- limiter.track(user.id, 1);
104
+ if (user) {
105
+ limiter.track(user.id, 1);
106
+ } else {
107
+ limiter.track(request.request.getIP(), 1);
108
+ if (organization) {
109
+ organizationLimiter.track(organization.id, 1);
110
+ }
111
+ }
86
112
 
87
113
  const form = formidable({
88
114
  maxFileSize: 5 * 1024 * 1024,
@@ -147,7 +173,7 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
147
173
  }
148
174
 
149
175
  const fileContent = await fs.readFile(file.filepath);
150
- const image = await Image.create(fileContent, imageType.contentType, resolutions, request.query.isPrivate, user);
176
+ const image = await Image.create(fileContent, imageType.contentType, resolutions, request.query.isPrivate, user, organization as Organization | null | undefined);
151
177
  return new Response(ImageStruct.create(image));
152
178
  } finally {
153
179
  // Formidable wrote the upload to a temporary file
@@ -340,7 +340,7 @@ export class ApplicationFeeInvoicer {
340
340
  // Paid now, not in the billed month: the invoices cron only picks up recent payments, so a
341
341
  // month that is billed late would otherwise never end up on an invoice
342
342
  await PaymentService.handlePaymentStatusUpdate(payment, sellingOrganization, PaymentStatus.Succeeded, new Date());
343
- await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
343
+ await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
344
344
  }
345
345
 
346
346
  /**
@@ -47,11 +47,6 @@ export const apiUserRateLimiter = new RateLimiter({
47
47
  ],
48
48
  });
49
49
 
50
- export class AuthorizationPostBody extends AutoEncoder {
51
- @field({ decoder: StringDecoder })
52
- header_authorization: string;
53
- }
54
-
55
50
  export class ContextInstance {
56
51
  request: Request;
57
52
  queries: { query: string; time?: number }[] = [];
@@ -241,16 +236,7 @@ export class ContextInstance {
241
236
  async authenticate(options: { allowMFASetupToken: true; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }>;
242
237
  async authenticate(options?: { allowMFASetupToken?: false | undefined; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token }>;
243
238
  async authenticate({ allowWithoutAccount = false, allowUnscoped = false, allowMFASetupToken = false }: { allowMFASetupToken?: boolean; allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }> {
244
- let header = this.request.headers.authorization;
245
-
246
- if (!header && this.request.method === 'POST') {
247
- try {
248
- const decoded = await DecodedRequest.fromRequest(this.request, undefined, undefined, AuthorizationPostBody as Decoder<AuthorizationPostBody>);
249
- header = decoded.body.header_authorization;
250
- } catch (e) {
251
- // Ignore: failed to read from body
252
- }
253
- }
239
+ const header = this.request.headers.authorization;
254
240
 
255
241
  if (!header) {
256
242
  throw new SimpleError({