@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.
@@ -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.status === PaymentStatus.Pending || payment.status === PaymentStatus.Created || (payment.provider === PaymentProvider.Buckaroo && payment.status === PaymentStatus.Failed)) {
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
- const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payment.id).fetch();
739
- const items = await BalanceItem.getByIDs(...Formatter.uniqueArray(balanceItemPayments.map(b => b.balanceItemId)));
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 chargeback = new Payment();
1125
+ const reversal = new Payment();
743
1126
 
744
1127
  // Who will receive this money?
745
- chargeback.organizationId = payment.organizationId!;
1128
+ reversal.organizationId = payment.organizationId!;
746
1129
 
747
1130
  // Who paid
748
- chargeback.payingUserId = payment.payingUserId;
749
- chargeback.payingOrganizationId = payment.payingOrganizationId;
750
- chargeback.customer = payment.customer;
751
-
752
- chargeback.status = PaymentStatus.Created;
753
- chargeback.price = -payment.price;
754
- chargeback.roundingAmount = -payment.roundingAmount;
755
- chargeback.method = payment.method;
756
- chargeback.type = PaymentType.Chargeback;
757
- chargeback.mandateId = payment.mandateId; ;
758
- chargeback.adminUserId = payment.adminUserId;
759
-
760
- chargeback.provider = payment.provider;
761
- chargeback.stripeAccountId = payment.stripeAccountId;
762
- chargeback.reversingPaymentId = payment.id;
763
- await chargeback.save();
764
-
765
- for (const original of balanceItemPayments) {
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 = original.balanceItemId;
769
- balanceItemPayment.paymentId = chargeback.id;
770
- balanceItemPayment.organizationId = chargeback.organizationId;
771
- balanceItemPayment.price = -original.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
- const organization = await Organization.getByID(chargeback.organizationId, true);
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 this.handlePaymentStatusUpdate(chargeback, organization, PaymentStatus.Succeeded, date);
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 mark chargeback as succeeded', e);
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: 600;
12
+ font-weight: 400 500;
13
13
  font-style: normal;
14
- font-display: swap;
15
- src: url('https://www.stamhoofd.be/assets/fonts/clarity-city/WOFF2/ClarityCity-Medium.woff2') format('woff2');
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: 500;
21
+ font-weight: 600;
21
22
  font-style: normal;
22
- font-display: swap;
23
- src: url('https://www.stamhoofd.be/assets/fonts/clarity-city/WOFF2/ClarityCity-SemiBold.woff2') format('woff2');
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,