@stamhoofd/backend 2.126.2 → 2.127.1
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/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -6
- package/src/crons/mollie-refunds.test.ts +310 -0
- package/src/crons/mollie-refunds.ts +157 -0
- package/src/crons/stripe-invoices.ts +4 -3
- package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +3 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.ts +12 -1
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +148 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.test.ts +490 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +72 -9
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +209 -0
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +97 -136
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +12 -2
- package/src/helpers/StripeInvoicer.ts +52 -59
- package/src/services/BalanceItemService.ts +19 -2
- package/src/services/InvoiceService.ts +9 -0
- package/src/services/MollieService.ts +108 -1
- package/src/services/PaymentService.ts +441 -34
- package/src/services/data/invoice.hbs.html +8 -6
- package/src/sql-filters/invoiced-balance-items.ts +5 -0
- package/tests/helpers/MollieMocker.ts +141 -1
- package/tests/vitest.setup.ts +5 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import type { Member, User } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItem, BalanceItemPayment, Group, Organization, PayconiqPayment, Payment, Platform, sendEmailTemplate, UsedRegisterCode } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItem, BalanceItemPayment, Group, MolliePayment, Organization, PayconiqPayment, Payment, Platform, sendEmailTemplate, UsedRegisterCode } from '@stamhoofd/models';
|
|
4
4
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
5
|
import type { Checkoutable, PaymentConfiguration, PrivatePaymentConfiguration } from '@stamhoofd/structures';
|
|
6
6
|
import { AuditLogSource, BalanceItemPaymentDetailed, BalanceItemType, EmailTemplateType, Invoice, PaymentCustomer, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PaymentType, Recipient, Replacement, VATExcemptReason, Version } from '@stamhoofd/structures';
|
|
@@ -71,6 +71,9 @@ export class PaymentService {
|
|
|
71
71
|
|
|
72
72
|
await BalanceItemService.updatePaidAndPending(balanceItemPayments.map(p => p.balanceItem));
|
|
73
73
|
|
|
74
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
75
|
+
await BalanceItemService.markOrdersUpdated(balanceItemPayments.map(p => p.balanceItem));
|
|
76
|
+
|
|
74
77
|
// Flush caches so data is up to date in response
|
|
75
78
|
await BalanceItemService.flushCaches(organization.id);
|
|
76
79
|
|
|
@@ -122,6 +125,9 @@ export class PaymentService {
|
|
|
122
125
|
|
|
123
126
|
await BalanceItemService.updatePaidAndPending(balanceItemPayments.map(p => p.balanceItem));
|
|
124
127
|
|
|
128
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
129
|
+
await BalanceItemService.markOrdersUpdated(balanceItemPayments.map(p => p.balanceItem));
|
|
130
|
+
|
|
125
131
|
// Flush caches so data is up to date in response
|
|
126
132
|
await BalanceItemService.flushCaches(organization.id);
|
|
127
133
|
|
|
@@ -143,8 +149,13 @@ export class PaymentService {
|
|
|
143
149
|
|
|
144
150
|
await BalanceItemService.updatePaidAndPending(balanceItemPayments.map(p => p.balanceItem));
|
|
145
151
|
|
|
152
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
153
|
+
await BalanceItemService.markOrdersUpdated(balanceItemPayments.map(p => p.balanceItem));
|
|
154
|
+
|
|
146
155
|
// Flush caches so data is up to date in response
|
|
147
156
|
await BalanceItemService.flushCaches(organization.id);
|
|
157
|
+
|
|
158
|
+
await this.updateReversedPaymentsFor(payment);
|
|
148
159
|
});
|
|
149
160
|
|
|
150
161
|
if (payment.type === PaymentType.Payment) {
|
|
@@ -165,13 +176,58 @@ export class PaymentService {
|
|
|
165
176
|
|
|
166
177
|
await BalanceItemService.updatePaidAndPending(balanceItemPayments.map(p => p.balanceItem));
|
|
167
178
|
|
|
179
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
180
|
+
await BalanceItemService.markOrdersUpdated(balanceItemPayments.map(p => p.balanceItem));
|
|
181
|
+
|
|
168
182
|
// Flush caches so data is up to date in response
|
|
169
183
|
await BalanceItemService.flushCaches(organization.id);
|
|
184
|
+
|
|
185
|
+
await this.updateReversedPaymentsFor(payment);
|
|
170
186
|
});
|
|
171
187
|
}
|
|
172
188
|
});
|
|
173
189
|
}
|
|
174
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Mainly makes sure a change in a payment is reloaded in the webshop orders client cache
|
|
193
|
+
*/
|
|
194
|
+
static async handlePaymentCreated(payment: Payment, organization: Organization) {
|
|
195
|
+
await AuditLogService.setContext({ fallbackUserId: payment.payingUserId, source: AuditLogSource.Payment, fallbackOrganizationId: payment.organizationId }, async () => {
|
|
196
|
+
await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
|
|
197
|
+
const unloaded = (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment));
|
|
198
|
+
const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
|
|
199
|
+
unloaded,
|
|
200
|
+
);
|
|
201
|
+
await BalanceItemService.updatePaidAndPending(balanceItemPayments.map(p => p.balanceItem));
|
|
202
|
+
|
|
203
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
204
|
+
await BalanceItemService.markOrdersUpdated(balanceItemPayments.map(p => p.balanceItem));
|
|
205
|
+
|
|
206
|
+
// Flush caches so data is up to date in response
|
|
207
|
+
await BalanceItemService.flushCaches(organization.id);
|
|
208
|
+
|
|
209
|
+
await this.updateReversedPaymentsFor(payment);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Mainly makes sure a change in a payment is reloaded in the webshop orders client cache
|
|
216
|
+
*/
|
|
217
|
+
static async handlePaymentUpdated(payment: Payment, organization: Organization) {
|
|
218
|
+
await AuditLogService.setContext({ fallbackUserId: payment.payingUserId, source: AuditLogSource.Payment, fallbackOrganizationId: payment.organizationId }, async () => {
|
|
219
|
+
await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
|
|
220
|
+
const unloaded = (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment));
|
|
221
|
+
const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
|
|
222
|
+
unloaded,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
226
|
+
await BalanceItemService.markOrdersUpdated(balanceItemPayments.map(p => p.balanceItem));
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
175
231
|
private static async handleChargebackCreated(payment: Payment, organization: Organization) {
|
|
176
232
|
if (!payment.payingOrganizationId) {
|
|
177
233
|
// For now only for B2B
|
|
@@ -304,7 +360,25 @@ export class PaymentService {
|
|
|
304
360
|
|
|
305
361
|
const testMode = organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production';
|
|
306
362
|
|
|
307
|
-
if (payment.
|
|
363
|
+
if (payment.type === PaymentType.Refund) {
|
|
364
|
+
// Refunds have their own status flow at the provider: they are never
|
|
365
|
+
// cancelable and can stay pending for days (e.g. bank refunds), so they are
|
|
366
|
+
// never manually expired either (that would allow refunding twice).
|
|
367
|
+
if ((payment.status === PaymentStatus.Pending || payment.status === PaymentStatus.Created) && payment.provider === PaymentProvider.Mollie) {
|
|
368
|
+
try {
|
|
369
|
+
const mollieService = await MollieService.create({ sellingOrganization: organization });
|
|
370
|
+
if (mollieService) {
|
|
371
|
+
const { status } = await mollieService.getRefundStatus(payment);
|
|
372
|
+
console.log('Mollie setting refund status to', status);
|
|
373
|
+
await this.handlePaymentStatusUpdate(payment, organization, status);
|
|
374
|
+
} else {
|
|
375
|
+
console.error('Missing Mollie credentials for refund payment', payment.id);
|
|
376
|
+
}
|
|
377
|
+
} catch (e) {
|
|
378
|
+
console.error('Refund check failed Mollie', payment.id, e);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} else if (payment.status === PaymentStatus.Pending || payment.status === PaymentStatus.Created || (payment.provider === PaymentProvider.Buckaroo && payment.status === PaymentStatus.Failed)) {
|
|
308
382
|
if (payment.provider === PaymentProvider.Stripe) {
|
|
309
383
|
try {
|
|
310
384
|
let status = await StripeHelper.getStatus(payment, cancel || this.shouldTryToCancel(payment.status, payment), testMode);
|
|
@@ -422,6 +496,10 @@ export class PaymentService {
|
|
|
422
496
|
// Update the status
|
|
423
497
|
await StripeHelper.getStatus(payment, false, testMode);
|
|
424
498
|
}
|
|
499
|
+
|
|
500
|
+
// Mollie calls the webhook of the original payment when the status of one
|
|
501
|
+
// of its refunds changes: update refunds that are still pending locally
|
|
502
|
+
await this.checkPendingRefunds(payment, organization);
|
|
425
503
|
}
|
|
426
504
|
}
|
|
427
505
|
return payment;
|
|
@@ -735,56 +813,385 @@ export class PaymentService {
|
|
|
735
813
|
// Creates issues to know what balance item was paid and what was not.
|
|
736
814
|
throw new Error('Cannot register chargeback with different amount than the payment for payment ' + payment.id);
|
|
737
815
|
}
|
|
738
|
-
|
|
739
|
-
const
|
|
816
|
+
|
|
817
|
+
const chargeback = await this.createReversalPayment({ payment, type: PaymentType.Chargeback });
|
|
818
|
+
const organization = await Organization.getByID(chargeback.organizationId!, true);
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
await this.handlePaymentStatusUpdate(chargeback, organization, PaymentStatus.Succeeded, date);
|
|
822
|
+
} catch (e) {
|
|
823
|
+
console.error('Failed to mark chargeback as succeeded', e);
|
|
824
|
+
await this.deleteReversalPayment(chargeback);
|
|
825
|
+
throw e;
|
|
826
|
+
}
|
|
827
|
+
return chargeback;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Register a refund that already exists at the payment provider (e.g. detected via the
|
|
832
|
+
* mollie-refunds cron because it was created in the provider's own dashboard). The (positive)
|
|
833
|
+
* amount is allocated automatically across the balance items of the original payment.
|
|
834
|
+
*
|
|
835
|
+
* Pass the current status of the refund at the provider: refunds that are still being
|
|
836
|
+
* processed stay pending locally until the provider reports them as executed.
|
|
837
|
+
*/
|
|
838
|
+
static async registerRefund(payment: Payment, amount: number, date: Date, status: PaymentStatus.Pending | PaymentStatus.Succeeded = PaymentStatus.Succeeded) {
|
|
839
|
+
const refund = await this.createReversalPayment({ payment, type: PaymentType.Refund, amount });
|
|
840
|
+
const organization = await Organization.getByID(refund.organizationId!, true);
|
|
841
|
+
|
|
842
|
+
try {
|
|
843
|
+
await this.handlePaymentStatusUpdate(refund, organization, status, date);
|
|
844
|
+
} catch (e) {
|
|
845
|
+
console.error('Failed to register refund', e);
|
|
846
|
+
// Nothing is linked to the provider yet: safe to delete, the cron will retry later
|
|
847
|
+
await this.deleteReversalPayment(refund);
|
|
848
|
+
throw e;
|
|
849
|
+
}
|
|
850
|
+
return refund;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Check the provider status of refunds of a source payment that are still pending locally,
|
|
855
|
+
* and update them when the provider reports a final state (refunded or failed). Called when
|
|
856
|
+
* the source payment is polled (e.g. because the provider called our webhook after a refund
|
|
857
|
+
* status change) and via the mollie-refunds cron.
|
|
858
|
+
*/
|
|
859
|
+
static async checkPendingRefunds(payment: Payment, organization: Organization) {
|
|
860
|
+
if (payment.provider !== PaymentProvider.Mollie) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const pendingRefunds = (await Payment.select()
|
|
865
|
+
.where('reversingPaymentId', payment.id)
|
|
866
|
+
.fetch())
|
|
867
|
+
.filter(p => p.type === PaymentType.Refund && (p.status === PaymentStatus.Pending || p.status === PaymentStatus.Created));
|
|
868
|
+
|
|
869
|
+
if (pendingRefunds.length === 0) {
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const mollieService = await MollieService.create({ sellingOrganization: organization });
|
|
874
|
+
if (!mollieService) {
|
|
875
|
+
console.error('Missing Mollie credentials while checking pending refunds of payment', payment.id);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
for (const refund of pendingRefunds) {
|
|
880
|
+
try {
|
|
881
|
+
const { status } = await mollieService.getRefundStatus(refund);
|
|
882
|
+
await this.handlePaymentStatusUpdate(refund, organization, status);
|
|
883
|
+
} catch (e) {
|
|
884
|
+
console.error('Failed to check pending refund', refund.id, e);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Create a refund via the API of the payment provider of the source payment.
|
|
891
|
+
* The refunded balance items can differ from the balance items of the source payment.
|
|
892
|
+
*
|
|
893
|
+
* Currently only Mollie is supported. Payconiq and Stripe will follow later.
|
|
894
|
+
*/
|
|
895
|
+
static async createRefund({ organization, sourcePayment, balanceItems, adminUserId, customer }: {
|
|
896
|
+
organization: Organization;
|
|
897
|
+
sourcePayment: Payment;
|
|
898
|
+
/**
|
|
899
|
+
* The (negative) prices per balance item that will be refunded
|
|
900
|
+
*/
|
|
901
|
+
balanceItems: Map<BalanceItem, number>;
|
|
902
|
+
adminUserId?: string | null;
|
|
903
|
+
customer?: PaymentCustomer | null;
|
|
904
|
+
}): Promise<Payment> {
|
|
905
|
+
if (organization.id !== sourcePayment.organizationId) {
|
|
906
|
+
throw new Error('Unexpected organization for source payment, expected ' + sourcePayment.organizationId + ', received ' + organization.id);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (sourcePayment.provider !== PaymentProvider.Mollie) {
|
|
910
|
+
throw new SimpleError({
|
|
911
|
+
code: 'refund_not_supported',
|
|
912
|
+
message: 'Refunds are only supported for payments via Mollie for now',
|
|
913
|
+
human: $t('%ZaK'),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const refund = await this.createReversalPayment({
|
|
918
|
+
payment: sourcePayment,
|
|
919
|
+
type: PaymentType.Refund,
|
|
920
|
+
balanceItems,
|
|
921
|
+
adminUserId,
|
|
922
|
+
customer,
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
let mollieRefund: { mollieRefundId: string; createdAt: Date; status: PaymentStatus };
|
|
926
|
+
|
|
927
|
+
try {
|
|
928
|
+
const mollieService = await MollieService.create({ sellingOrganization: organization });
|
|
929
|
+
if (!mollieService) {
|
|
930
|
+
throw new SimpleError({
|
|
931
|
+
code: 'refund_not_supported',
|
|
932
|
+
message: 'Missing Mollie credentials for organization ' + organization.id,
|
|
933
|
+
human: $t('%Zag'),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
mollieRefund = await mollieService.createRefund({
|
|
938
|
+
payment: sourcePayment,
|
|
939
|
+
amount: -refund.price,
|
|
940
|
+
description: organization.name + ' ' + refund.id,
|
|
941
|
+
metadata: {
|
|
942
|
+
refundPaymentId: refund.id,
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
} catch (e) {
|
|
946
|
+
// Mollie refused or never received the refund: it is safe to remove the local payment
|
|
947
|
+
await this.deleteReversalPayment(refund);
|
|
948
|
+
throw e;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// The refund now exists at Mollie and cannot be undone: never delete the local payment
|
|
952
|
+
// from this point on. If one of the steps below fails, the refund stays pending locally
|
|
953
|
+
// and the mollie-refunds cron will reconcile it later (via the link or via the
|
|
954
|
+
// refundPaymentId metadata on the Mollie refund).
|
|
955
|
+
const molliePayment = new MolliePayment();
|
|
956
|
+
molliePayment.paymentId = refund.id;
|
|
957
|
+
molliePayment.mollieId = mollieRefund.mollieRefundId;
|
|
958
|
+
await molliePayment.save();
|
|
959
|
+
|
|
960
|
+
// Most refunds are not executed immediately by Mollie (queued/pending/processing): only
|
|
961
|
+
// mark the refund as succeeded once Mollie reports it as refunded. Status changes are
|
|
962
|
+
// picked up via the payment webhook (pollStatus) and the mollie-refunds cron.
|
|
963
|
+
await this.handlePaymentStatusUpdate(refund, organization, mollieRefund.status, mollieRefund.createdAt);
|
|
964
|
+
|
|
965
|
+
return refund;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Create a (not yet succeeded) reversal payment (refund or chargeback) for an existing payment.
|
|
970
|
+
* When no explicit balance items are provided, the amount is allocated across the balance items
|
|
971
|
+
* of the original payment (taking into account earlier reversals of that payment).
|
|
972
|
+
*/
|
|
973
|
+
static async createReversalPayment({ payment, type, amount, balanceItems, adminUserId, customer }: {
|
|
974
|
+
payment: Payment;
|
|
975
|
+
type: PaymentType.Refund | PaymentType.Chargeback;
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Positive amount to reverse. Required when no explicit balance items are provided.
|
|
979
|
+
* Ignored when balanceItems is set (the amount is calculated from the items).
|
|
980
|
+
*/
|
|
981
|
+
amount?: number;
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Optional explicit (negative) prices per balance item
|
|
985
|
+
*/
|
|
986
|
+
balanceItems?: Map<BalanceItem, number>;
|
|
987
|
+
adminUserId?: string | null;
|
|
988
|
+
customer?: PaymentCustomer | null;
|
|
989
|
+
}): Promise<Payment> {
|
|
990
|
+
if (payment.status !== PaymentStatus.Succeeded) {
|
|
991
|
+
throw new SimpleError({
|
|
992
|
+
code: 'invalid_payment',
|
|
993
|
+
message: 'Cannot reverse a payment that did not succeed',
|
|
994
|
+
human: $t('%ZZz'),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (payment.price <= 0) {
|
|
999
|
+
throw new SimpleError({
|
|
1000
|
+
code: 'invalid_payment',
|
|
1001
|
+
message: 'Cannot reverse a payment without a positive amount',
|
|
1002
|
+
human: $t('%ZaU'),
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Earlier reversals of this payment (refunds and chargebacks) reduce the remaining
|
|
1007
|
+
// refundable amount. Pending reversals are included to prevent refunding twice.
|
|
1008
|
+
const previousReversals = (await Payment.select()
|
|
1009
|
+
.where('reversingPaymentId', payment.id)
|
|
1010
|
+
.fetch())
|
|
1011
|
+
.filter(p => p.status !== PaymentStatus.Failed);
|
|
1012
|
+
|
|
1013
|
+
// Note: prices of reversals are negative
|
|
1014
|
+
const reversedAmount = previousReversals.reduce((total, p) => total + p.price, 0);
|
|
1015
|
+
const remaining = payment.price + reversedAmount;
|
|
1016
|
+
|
|
1017
|
+
// The (negative) price per balance item id of the reversal we'll create
|
|
1018
|
+
const reversalPrices = new Map<string, number>();
|
|
1019
|
+
let price: number;
|
|
1020
|
+
let roundingAmount: number;
|
|
1021
|
+
let items: BalanceItem[];
|
|
1022
|
+
|
|
1023
|
+
if (balanceItems) {
|
|
1024
|
+
// Note: individual items can have a positive price (e.g. reversing a discount),
|
|
1025
|
+
// only the total of the refund has to be negative
|
|
1026
|
+
let total = 0;
|
|
1027
|
+
for (const [balanceItem, itemPrice] of balanceItems) {
|
|
1028
|
+
if (payment.organizationId !== balanceItem.organizationId) {
|
|
1029
|
+
throw new Error('Unexpected balance item from other organization');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (itemPrice !== 0) {
|
|
1033
|
+
reversalPrices.set(balanceItem.id, (reversalPrices.get(balanceItem.id) ?? 0) + itemPrice);
|
|
1034
|
+
}
|
|
1035
|
+
total += itemPrice;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (total >= 0) {
|
|
1039
|
+
throw new SimpleError({
|
|
1040
|
+
code: 'invalid_field',
|
|
1041
|
+
message: 'The total price should be smaller than zero',
|
|
1042
|
+
human: $t('%Zae'),
|
|
1043
|
+
field: 'price',
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
({ price, roundingAmount } = this.round(total));
|
|
1048
|
+
items = [...balanceItems.keys()];
|
|
1049
|
+
} else if (type === PaymentType.Chargeback || (amount === payment.price && previousReversals.length === 0)) {
|
|
1050
|
+
// Reverse the original payment exactly: negate all balance item payments
|
|
1051
|
+
// (chargebacks always reverse the full amount, validated in registerChargeback)
|
|
1052
|
+
const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payment.id).fetch();
|
|
1053
|
+
|
|
1054
|
+
for (const original of balanceItemPayments) {
|
|
1055
|
+
if (original.price !== 0) {
|
|
1056
|
+
reversalPrices.set(original.balanceItemId, (reversalPrices.get(original.balanceItemId) ?? 0) - original.price);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
price = -payment.price;
|
|
1061
|
+
roundingAmount = -payment.roundingAmount;
|
|
1062
|
+
items = await BalanceItem.getByIDs(...Formatter.uniqueArray(balanceItemPayments.map(b => b.balanceItemId)));
|
|
1063
|
+
} else {
|
|
1064
|
+
if (amount === undefined || amount <= 0) {
|
|
1065
|
+
throw new Error('Amount is required to create a reversal payment without explicit balance items');
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Partial refund: allocate the amount across the balance items of the original payment,
|
|
1069
|
+
// reduced by what was already reversed on those items
|
|
1070
|
+
const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payment.id).fetch();
|
|
1071
|
+
const previousItems = previousReversals.length > 0
|
|
1072
|
+
? await BalanceItemPayment.select().where('paymentId', previousReversals.map(p => p.id)).fetch()
|
|
1073
|
+
: [];
|
|
1074
|
+
|
|
1075
|
+
const available = new Map<string, number>();
|
|
1076
|
+
for (const original of balanceItemPayments) {
|
|
1077
|
+
available.set(original.balanceItemId, (available.get(original.balanceItemId) ?? 0) + original.price);
|
|
1078
|
+
}
|
|
1079
|
+
for (const reversed of previousItems) {
|
|
1080
|
+
if (available.has(reversed.balanceItemId)) {
|
|
1081
|
+
// Prices of reversals are negative
|
|
1082
|
+
available.set(reversed.balanceItemId, available.get(reversed.balanceItemId)! + reversed.price);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
let remainingToAllocate = amount;
|
|
1087
|
+
for (const [balanceItemId, availableAmount] of available) {
|
|
1088
|
+
if (remainingToAllocate <= 0) {
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
if (availableAmount <= 0) {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
const allocated = Math.min(availableAmount, remainingToAllocate);
|
|
1095
|
+
reversalPrices.set(balanceItemId, -allocated);
|
|
1096
|
+
remainingToAllocate -= allocated;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// A small remainder can occur because the original payment was rounded to 1 cent
|
|
1100
|
+
if (remainingToAllocate >= 100 || remainingToAllocate <= -100) {
|
|
1101
|
+
throw new SimpleError({
|
|
1102
|
+
code: 'refund_allocation_failed',
|
|
1103
|
+
message: 'Could not allocate the refund amount across the balance items of payment ' + payment.id,
|
|
1104
|
+
human: $t('%Za5'),
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
price = -amount;
|
|
1109
|
+
roundingAmount = -remainingToAllocate;
|
|
1110
|
+
items = await BalanceItem.getByIDs(...Formatter.uniqueArray([...reversalPrices.keys()]));
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (type === PaymentType.Refund && -price > remaining) {
|
|
1114
|
+
throw new SimpleError({
|
|
1115
|
+
code: 'refund_amount_too_high',
|
|
1116
|
+
message: 'The refund amount is higher than the remaining refundable amount of the payment',
|
|
1117
|
+
human: $t('%ZaP', {
|
|
1118
|
+
amount: Formatter.price(-price),
|
|
1119
|
+
remaining: Formatter.price(remaining),
|
|
1120
|
+
}),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
740
1123
|
|
|
741
1124
|
// Done validation
|
|
742
|
-
const
|
|
1125
|
+
const reversal = new Payment();
|
|
743
1126
|
|
|
744
1127
|
// Who will receive this money?
|
|
745
|
-
|
|
1128
|
+
reversal.organizationId = payment.organizationId!;
|
|
746
1129
|
|
|
747
1130
|
// Who paid
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1131
|
+
reversal.payingUserId = payment.payingUserId;
|
|
1132
|
+
reversal.payingOrganizationId = payment.payingOrganizationId;
|
|
1133
|
+
// Prefer the customer (company/VAT details) of the payment that is being reversed, so the
|
|
1134
|
+
// VAT number cannot change between the payment and the refund. Only when the original
|
|
1135
|
+
// payment has no customer, the provided customer is used.
|
|
1136
|
+
reversal.customer = payment.customer ?? customer ?? null;
|
|
1137
|
+
|
|
1138
|
+
reversal.status = PaymentStatus.Created;
|
|
1139
|
+
reversal.price = price;
|
|
1140
|
+
reversal.roundingAmount = roundingAmount;
|
|
1141
|
+
reversal.method = payment.method;
|
|
1142
|
+
reversal.type = type;
|
|
1143
|
+
reversal.mandateId = payment.mandateId;
|
|
1144
|
+
reversal.adminUserId = adminUserId !== undefined ? adminUserId : payment.adminUserId;
|
|
1145
|
+
|
|
1146
|
+
reversal.provider = payment.provider;
|
|
1147
|
+
reversal.stripeAccountId = payment.stripeAccountId;
|
|
1148
|
+
reversal.reversingPaymentId = payment.id;
|
|
1149
|
+
await reversal.save();
|
|
1150
|
+
|
|
1151
|
+
for (const [balanceItemId, itemPrice] of reversalPrices) {
|
|
766
1152
|
// Create one balance item payment to pay it in one payment
|
|
767
1153
|
const balanceItemPayment = new BalanceItemPayment();
|
|
768
|
-
balanceItemPayment.balanceItemId =
|
|
769
|
-
balanceItemPayment.paymentId =
|
|
770
|
-
balanceItemPayment.organizationId =
|
|
771
|
-
balanceItemPayment.price =
|
|
1154
|
+
balanceItemPayment.balanceItemId = balanceItemId;
|
|
1155
|
+
balanceItemPayment.paymentId = reversal.id;
|
|
1156
|
+
balanceItemPayment.organizationId = reversal.organizationId;
|
|
1157
|
+
balanceItemPayment.price = itemPrice;
|
|
772
1158
|
await balanceItemPayment.save();
|
|
773
1159
|
}
|
|
774
1160
|
|
|
775
1161
|
// Update cached balance items pending amount (only created balance items, because those are involved in the payment)
|
|
776
1162
|
await BalanceItemService.updatePaidAndPending(items);
|
|
777
1163
|
|
|
778
|
-
|
|
1164
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
1165
|
+
await BalanceItemService.markOrdersUpdated(items);
|
|
1166
|
+
|
|
1167
|
+
// The reversal is pending: update the pending refund amount of the reversed payment
|
|
1168
|
+
await this.updateReversedPaymentsFor(reversal);
|
|
779
1169
|
|
|
1170
|
+
return reversal;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Delete a reversal payment that could not be completed (e.g. the payment provider refused the refund)
|
|
1175
|
+
*/
|
|
1176
|
+
static async deleteReversalPayment(reversal: Payment) {
|
|
780
1177
|
try {
|
|
781
|
-
await
|
|
1178
|
+
const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', reversal.id).fetch();
|
|
1179
|
+
for (const balanceItemPayment of balanceItemPayments) {
|
|
1180
|
+
await balanceItemPayment.delete();
|
|
1181
|
+
}
|
|
1182
|
+
await reversal.delete();
|
|
1183
|
+
|
|
1184
|
+
const items = await BalanceItem.getByIDs(...Formatter.uniqueArray(balanceItemPayments.map(b => b.balanceItemId)));
|
|
1185
|
+
await BalanceItemService.updatePaidAndPending(items);
|
|
1186
|
+
|
|
1187
|
+
// Make sure clients refetch the orders these balance items belong to
|
|
1188
|
+
await BalanceItemService.markOrdersUpdated(items);
|
|
1189
|
+
|
|
1190
|
+
// Update the (pending) refund amount of the reversed payment
|
|
1191
|
+
await this.updateReversedPaymentsFor(reversal);
|
|
782
1192
|
} catch (e) {
|
|
783
|
-
console.error('Failed to
|
|
784
|
-
await chargeback.delete();
|
|
785
|
-
throw e;
|
|
1193
|
+
console.error('Failed to clean up reversal payment ' + reversal.id, e);
|
|
786
1194
|
}
|
|
787
|
-
return chargeback;
|
|
788
1195
|
}
|
|
789
1196
|
|
|
790
1197
|
static async createPayment({ balanceItems, organization, user, members, checkout, payingOrganization, serviceFeeType, createMandate, useMandate, paymentConfiguration, privatePaymentConfiguration, adminUserId }: {
|
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
<style>
|
|
10
10
|
@font-face {
|
|
11
11
|
font-family: 'Metropolis';
|
|
12
|
-
font-weight:
|
|
12
|
+
font-weight: 400 500;
|
|
13
13
|
font-style: normal;
|
|
14
|
-
font-display:
|
|
15
|
-
src: url(
|
|
14
|
+
font-display: block;
|
|
15
|
+
src: url("https://files.stamhoofd.be/fixtures/fonts/clarity-city/WOFF2/ClarityCity-Medium.woff2") format("woff2"),
|
|
16
|
+
url("https://files.stamhoofd.be/fixtures/fonts/clarity-city/WOFF/ClarityCity-Medium.woff") format("woff");
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
@font-face {
|
|
19
20
|
font-family: 'Metropolis';
|
|
20
|
-
font-weight:
|
|
21
|
+
font-weight: 600;
|
|
21
22
|
font-style: normal;
|
|
22
|
-
font-display:
|
|
23
|
-
src: url(
|
|
23
|
+
font-display: block;
|
|
24
|
+
src: url("https://files.stamhoofd.be/fixtures/fonts/clarity-city/WOFF2/ClarityCity-SemiBold.woff2") format("woff2"),
|
|
25
|
+
url("https://files.stamhoofd.be/fixtures/fonts/clarity-city/WOFF/ClarityCity-SemiBold.woff") format("woff");
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
:root {
|
|
@@ -8,6 +8,11 @@ export const invoicedBalanceItemCompilers: SQLFilterDefinitions = {
|
|
|
8
8
|
type: SQLValueType.String,
|
|
9
9
|
nullable: false,
|
|
10
10
|
}),
|
|
11
|
+
balanceItemId: createColumnFilter({
|
|
12
|
+
expression: SQL.column('balanceItemId'),
|
|
13
|
+
type: SQLValueType.String,
|
|
14
|
+
nullable: false,
|
|
15
|
+
}),
|
|
11
16
|
name: createColumnFilter({
|
|
12
17
|
expression: SQL.column('name'),
|
|
13
18
|
type: SQLValueType.String,
|