@stamhoofd/backend 2.120.5 → 2.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +12 -12
  2. package/src/audit-logs/RegistrationInvitationLogger.ts +46 -0
  3. package/src/audit-logs/init.ts +2 -0
  4. package/src/crons/index.ts +2 -0
  5. package/src/crons/invoices.ts +166 -0
  6. package/src/crons/mollie-chargebacks.ts +87 -0
  7. package/src/crons.ts +47 -10
  8. package/src/email-recipient-loaders/payments.ts +84 -41
  9. package/src/endpoints/global/groups/GetGroupsCountEndpoint.ts +51 -0
  10. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +22 -3
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -0
  12. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsCountEndpoint.ts +45 -0
  13. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +495 -0
  14. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.ts +216 -0
  15. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +405 -0
  16. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +168 -0
  17. package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +15 -0
  18. package/src/endpoints/{global → organization/dashboard}/billing/DeactivatePackageEndpoint.ts +3 -4
  19. package/src/endpoints/organization/dashboard/billing/DeleteOrganizationMandateEndpoint.ts +62 -0
  20. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceCollectionEndpoint.ts +56 -0
  21. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceEndpoint.ts +42 -19
  22. package/src/endpoints/organization/dashboard/billing/GetOrganizationMandatesEndpoint.ts +64 -0
  23. package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.ts +11 -3
  24. package/src/endpoints/organization/dashboard/billing/OrganizationCheckoutEndpoint.ts +308 -0
  25. package/src/endpoints/organization/dashboard/billing/PatchOrganizationMandatesEndpoint.ts +94 -0
  26. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +7 -0
  27. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +5 -4
  28. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +7 -2
  29. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +17 -8
  30. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +3 -3
  31. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +127 -0
  32. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +13 -4
  33. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +7 -1
  34. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  35. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +13 -11
  36. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +14 -19
  37. package/src/helpers/AdminPermissionChecker.ts +11 -3
  38. package/src/helpers/AuthenticatedStructures.ts +94 -6
  39. package/src/helpers/FinancialSupportHelper.ts +21 -0
  40. package/src/helpers/RecordAnswerHelper.test.ts +746 -0
  41. package/src/helpers/RecordAnswerHelper.ts +116 -0
  42. package/src/helpers/StripeHelper.ts +2 -3
  43. package/src/helpers/ViesHelper.ts +7 -3
  44. package/src/seeds/1750090030-records-configuration.ts +68 -3
  45. package/src/seeds/1752848561-groups-registration-periods.ts +26 -2
  46. package/src/seeds/1779121239-default-invoice-email-template.sql +3 -0
  47. package/src/services/BalanceItemService.ts +12 -16
  48. package/src/services/InvoiceService.ts +372 -72
  49. package/src/services/MollieService.ts +537 -0
  50. package/src/services/PaymentMandateService.ts +214 -0
  51. package/src/services/PaymentService.ts +578 -222
  52. package/src/services/PlatformMembershipService.ts +1 -1
  53. package/src/services/RegistrationService.ts +66 -5
  54. package/src/services/STPackageService.ts +0 -7
  55. package/src/services/data/invoice.hbs.html +686 -0
  56. package/src/sql-filters/groups.ts +11 -1
  57. package/src/sql-filters/payments.ts +5 -0
  58. package/src/sql-filters/registration-invitations.ts +90 -0
  59. package/src/sql-sorters/registration-invitations.ts +36 -0
  60. package/vitest.config.js +1 -0
  61. package/src/endpoints/global/billing/ActivatePackagesEndpoint.ts +0 -216
@@ -0,0 +1,116 @@
1
+ import type { AutoEncoderPatchType, Encodeable, Identifiable, Patchable, PatchableArray } from '@simonbackx/simple-encoding';
2
+ import { isPatchableArray } from '@simonbackx/simple-encoding';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import type { RecordCategory, RecordSettings, RecordType } from '@stamhoofd/structures';
5
+ import { RecordAnswerDecoder } from '@stamhoofd/structures';
6
+
7
+ export class RecordAnswerHelper {
8
+ static throwIfPatchOrPutIsInvalid(original: RecordCategory[], patchOrPut: RecordCategory[] | PatchableArray<string, RecordCategory, AutoEncoderPatchType<RecordCategory>>) {
9
+ if (original.length === 0) {
10
+ return;
11
+ }
12
+
13
+ const updatedRecordSettings = this.getAllRecords(patchOrPut)
14
+ // only check if the types changed
15
+ .filter(p => p.type !== undefined || p.fileType !== undefined);
16
+
17
+ if (updatedRecordSettings.length === 0) {
18
+ return;
19
+ }
20
+
21
+ const updatRecordSettingsMap = new Map<string, RecordSettings | AutoEncoderPatchType<RecordSettings>>(updatedRecordSettings.map(p => [p.id, p]));
22
+
23
+ for (const category of original) {
24
+ for (const originalRecord of category.getAllRecords()) {
25
+ const updatedSettings = updatRecordSettingsMap.get(originalRecord.id);
26
+ if (updatedSettings) {
27
+ this.throwIfInvalidRecordSettingsUpdate(originalRecord, updatedSettings);
28
+
29
+ // remove from map
30
+ updatRecordSettingsMap.delete(originalRecord.id);
31
+
32
+ // stop looping if all updated records are checked
33
+ if (updatRecordSettingsMap.size === 0) {
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ static throwIfPatchOrPutsAreInvalid<
42
+ Key extends string & keyof Put,
43
+ Id extends string | number,
44
+ Put extends (Identifiable<Id> & Encodeable & Patchable<Patch> & Record<Key, RecordCategory[]>),
45
+ Patch extends (Identifiable<Id> & Encodeable) | Put
46
+ >(originals: Put[], patchOrPuts: Put[] | Patch[] | PatchableArray<Id, Put, Patch>, key: string) {
47
+ if (isPatchableArray(patchOrPuts)) {
48
+ const puts = patchOrPuts.getPuts().map(p => p.put);
49
+ this.throwIfPatchOrPutsAreInvalid<Key, Id, Put, Patch>(originals, puts, key);
50
+ const patches = patchOrPuts.getPatches();
51
+ this.throwIfPatchOrPutsAreInvalid<Key, Id, Put, Patch>(originals, patches, key);
52
+ } else {
53
+ for (const patchOrPut of patchOrPuts) {
54
+ const original = originals.find(o => o.id === patchOrPut.id);
55
+ if (original) {
56
+ const originalRecordCategories: RecordCategory[] = original[key];
57
+ const newRecordCategories: RecordCategory[] | PatchableArray<string, RecordCategory, AutoEncoderPatchType<RecordCategory>> = patchOrPut[key];
58
+ if (originalRecordCategories && newRecordCategories) {
59
+ this.throwIfPatchOrPutIsInvalid(originalRecordCategories, newRecordCategories);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ private static throwIfInvalidRecordSettingsUpdate(original: RecordSettings, updated: RecordSettings | AutoEncoderPatchType<RecordSettings>) {
67
+ if (updated.type !== undefined
68
+ && original.type !== updated.type
69
+ // changing between types with the same class should be allowed
70
+ && !this.haveTypesSameClass(original.type, updated.type)) {
71
+ throw new SimpleError({
72
+ code: 'invalid_record_type',
73
+ message: `Cannot change record type from ${original.type} to ${updated.type}`,
74
+ human: $t(`%1Qn`, {name: original.name}),
75
+ })
76
+ }
77
+
78
+ if (updated.fileType !== undefined && ((original.fileType ?? null) !== (updated.fileType ?? null))) {
79
+ throw new SimpleError({
80
+ code: 'invalid_record_type',
81
+ message: `Cannot change record file type from ${original.fileType} to ${updated.fileType}`,
82
+ human: $t(`%1SP`, {name: original.name}),
83
+ })
84
+ }
85
+ }
86
+
87
+ static haveTypesSameClass(a: RecordType, b: RecordType) {
88
+ return RecordAnswerDecoder.getClassForType(a) === RecordAnswerDecoder.getClassForType(b);
89
+ }
90
+
91
+ private static getAllRecords(patchOrPut: RecordCategory[] | PatchableArray<string, RecordCategory, AutoEncoderPatchType<RecordCategory>>): (RecordSettings | AutoEncoderPatchType<RecordSettings>)[] {
92
+ if (isPatchableArray(patchOrPut)) {
93
+ const patches = patchOrPut.getPatches().flatMap(p => this.getAllRecordsFromRecordCategoryPatch(p));
94
+ const puts = patchOrPut.getPuts().flatMap(p => p.put.getAllRecords());
95
+ return [...patches, ...puts];
96
+ }
97
+ return patchOrPut.flatMap(c => c.getAllRecords());
98
+ }
99
+
100
+ private static getAllRecordsFromRecordCategoryPatch(category: AutoEncoderPatchType<RecordCategory>) {
101
+ const results: (RecordSettings | AutoEncoderPatchType<RecordSettings>)[] = [
102
+ ...category.records.getPatches(),
103
+ ...category.records.getPuts().map(p => p.put)
104
+ ];
105
+
106
+ if (category.childCategories) {
107
+ const childCategoryPatches = category.childCategories.getPatches().flatMap(p => this.getAllRecordsFromRecordCategoryPatch(p));
108
+ results.push(...childCategoryPatches);
109
+
110
+ const childCategoryPuts = category.childCategories.getPuts().flatMap(p => p.put.getAllRecords());
111
+ results.push(...childCategoryPuts);
112
+ }
113
+
114
+ return results;
115
+ }
116
+ }
@@ -207,7 +207,7 @@ export class StripeHelper {
207
207
  }
208
208
 
209
209
  static async createPayment(
210
- { payment, stripeAccount, redirectUrl, cancelUrl, customer, statementDescriptor, i18n, metadata, organization, lineItems }: {
210
+ { payment, stripeAccount, redirectUrl, cancelUrl, customer, statementDescriptor, i18n, metadata, organization }: {
211
211
  payment: Payment;
212
212
  stripeAccount: StripeAccount | null;
213
213
  redirectUrl: string;
@@ -218,9 +218,8 @@ export class StripeHelper {
218
218
  };
219
219
  statementDescriptor: string;
220
220
  i18n: I18n;
221
- metadata: { [key: string]: string };
221
+ metadata: { [key: string]: string | undefined};
222
222
  organization: Organization;
223
- lineItems: (BalanceItemPayment & { balanceItem: BalanceItem })[];
224
223
  },
225
224
  ): Promise<{ paymentUrl: string }> {
226
225
  if (!stripeAccount) {
@@ -128,13 +128,17 @@ export class ViesHelperStatic {
128
128
  if (!result.isValid) {
129
129
  throw new SimpleError({
130
130
  code: 'invalid_field',
131
- message: 'Ongeldig BTW-nummer: ' + vatNumber,
131
+ message: $t('%1To', {'number': vatNumber}),
132
132
  field: 'VATNumber',
133
133
  });
134
134
  }
135
135
 
136
136
  const formatted = result.value ?? vatNumber;
137
137
 
138
+ if (STAMHOOFD.environment === 'development' && formatted === 'NL301828519B01') {
139
+ return formatted
140
+ }
141
+
138
142
  try {
139
143
  const cleaned = formatted.substring(2).replace(/(?:\.-\s)+/g, '');
140
144
  const response = await this.request('POST', 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number', {
@@ -150,7 +154,7 @@ export class ViesHelperStatic {
150
154
  if (!response.valid) {
151
155
  throw new SimpleError({
152
156
  code: 'invalid_field',
153
- message: 'Het opgegeven BTW-nummer is ongeldig of niet BTW-plichtig: ' + formatted,
157
+ message: $t('%1TG', {'vat-number': formatted}),
154
158
  field: 'VATNumber',
155
159
  });
156
160
  }
@@ -164,7 +168,7 @@ export class ViesHelperStatic {
164
168
 
165
169
  throw new SimpleError({
166
170
  code: 'service_unavailable',
167
- message: 'De BTW-nummer validatie service (VIES) is tijdelijk niet beschikbaar. Probeer het later opnieuw.',
171
+ message: $t('%1Sf'),
168
172
  field: 'VATNumber',
169
173
  });
170
174
  }
@@ -1,11 +1,76 @@
1
- import { Migration } from '@simonbackx/simple-database';
1
+ import { column, Migration } from '@simonbackx/simple-database';
2
+ import { AutoEncoder, field } from '@simonbackx/simple-encoding';
2
3
  import { Organization, Webshop } from '@stamhoofd/models';
4
+ import { QueryableModel, SQL } from '@stamhoofd/sql';
5
+ import { DataPermissionsSettings, FinancialSupportSettings, Version } from '@stamhoofd/structures';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+
8
+ class OldOrganizationRecordsConfiguration extends AutoEncoder {
9
+ /**
10
+ * If the organizations provides support for families in financial difficulties
11
+ */
12
+ @field({ decoder: FinancialSupportSettings, nullable: true, version: 117 })
13
+ financialSupport: FinancialSupportSettings | null = null;
14
+
15
+ /**
16
+ * Ask permissions to collect data
17
+ */
18
+ @field({ decoder: DataPermissionsSettings, nullable: true, version: 117 })
19
+ dataPermission: DataPermissionsSettings | null = null;
20
+ }
21
+
22
+ class OldOrganizationMetaData extends AutoEncoder {
23
+ @field({
24
+ decoder: OldOrganizationRecordsConfiguration,
25
+ version: 53,
26
+ defaultValue: () => OldOrganizationRecordsConfiguration.create({})
27
+ })
28
+ recordsConfiguration: OldOrganizationRecordsConfiguration = OldOrganizationRecordsConfiguration.create({});
29
+ }
30
+
31
+ // get data like it was in V1
32
+ export class OldOrganization extends QueryableModel {
33
+ static table = 'organizations';
34
+
35
+ @column({
36
+ primary: true, type: 'string', beforeSave(value) {
37
+ return value ?? uuidv4();
38
+ }
39
+ })
40
+ id!: string;
41
+
42
+ /**
43
+ * Public meta data
44
+ */
45
+ @column({ type: 'json', decoder: OldOrganizationMetaData })
46
+ meta: OldOrganizationMetaData = OldOrganizationMetaData.create({});
47
+ }
3
48
 
4
49
  export async function startRecordsConfigurationMigration() {
5
- for await (const organization of Organization.select().all()) {
6
- await organization.save();
50
+
51
+ // migrate recordsConfiguration of organizations
52
+ for await (const oldOrganization of OldOrganization.select()
53
+ // prevent migrating same organizations twice if something goes wrong
54
+ .where(SQL.jsonValue(SQL.column('meta'), '$.version'), '<', Version)
55
+ .all()) {
56
+ const oldFinancialSupport = oldOrganization.meta.recordsConfiguration.financialSupport;
57
+ const oldDataPermission = oldOrganization.meta.recordsConfiguration.dataPermission;
58
+
59
+ if (oldFinancialSupport || oldDataPermission) {
60
+ const newOrganization = await Organization.getByID(oldOrganization.id);
61
+
62
+ if (!newOrganization) {
63
+ throw new Error('Organization with id ' + oldOrganization.id + ' not found');
64
+ }
65
+
66
+ newOrganization.meta.financialSupport = oldFinancialSupport;
67
+ newOrganization.meta.dataPermission = oldDataPermission;
68
+
69
+ await newOrganization.save();
70
+ }
7
71
  }
8
72
 
73
+ // migrate recordsConfiguration of webshops
9
74
  for await (const webshop of Webshop.select().all()) {
10
75
  await webshop.save();
11
76
  }
@@ -1,6 +1,6 @@
1
1
  import { Migration } from '@simonbackx/simple-database';
2
- import { Group, Organization, OrganizationRegistrationPeriod, Registration, RegistrationPeriod } from '@stamhoofd/models';
3
- import type { CycleInformation} from '@stamhoofd/structures';
2
+ import { Group, Organization, OrganizationRegistrationPeriod, Registration, RegistrationInvitation, RegistrationPeriod } from '@stamhoofd/models';
3
+ import type { CycleInformation } from '@stamhoofd/structures';
4
4
  import { GroupCategory, GroupCategorySettings, GroupPrivateSettings, GroupSettings, GroupStatus, GroupType, RegistrationPeriodSettings, TranslatedString } from '@stamhoofd/structures';
5
5
 
6
6
  export default new Migration(async () => {
@@ -419,6 +419,8 @@ async function migrateRegistrations({ organization, period, originalGroup, newGr
419
419
  .fetch();
420
420
 
421
421
  for (const registration of registrations) {
422
+ let invitation: RegistrationInvitation | null = null;
423
+
422
424
  if (registration.waitingList) {
423
425
  const waitingList = await getOrCreateWaitingList();
424
426
  if (newGroup.waitingListId !== waitingList.id) {
@@ -430,6 +432,18 @@ async function migrateRegistrations({ organization, period, originalGroup, newGr
430
432
  }
431
433
 
432
434
  registration.groupId = waitingList.id;
435
+
436
+ if (registration.canRegister) {
437
+ // we should create an invitation
438
+ invitation = new RegistrationInvitation();
439
+ invitation.groupId = newGroup.id;
440
+ invitation.memberId = registration.memberId;
441
+ invitation.organizationId = organization.id;
442
+ invitation.createdAt = registration.createdAt;
443
+
444
+ // deprecated -> set to false
445
+ registration.canRegister = false;
446
+ }
433
447
  }
434
448
  else {
435
449
  registration.groupId = newGroup.id;
@@ -440,6 +454,16 @@ async function migrateRegistrations({ organization, period, originalGroup, newGr
440
454
 
441
455
  if (!dryRun) {
442
456
  await registration.save();
457
+ if (invitation) {
458
+ try {
459
+ await invitation.save();
460
+ } catch (e) {
461
+ // do not throw if duplicate
462
+ if (e.code !== 'ER_DUP_ENTRY') {
463
+ throw e;
464
+ }
465
+ }
466
+ }
443
467
  }
444
468
  }
445
469
  }
@@ -0,0 +1,3 @@
1
+ INSERT INTO `email_templates` (`id`, `subject`, `groupId`, `webshopId`, `organizationId`, `type`, `text`, `html`, `json`, `updatedAt`, `createdAt`) VALUES
2
+ ('5d97e1d3-3f5c-415b-813a-0669b724f716', 'Fout bij het automatisch aanmaken van facturen', NULL, NULL, NULL, 'InvoiceGenerationErrors', '{{greeting}}\n\nBij het automatisch aanmaken van facturen in naam van {{organizationName}} ging er iets mis. Hieronder kan je een lijst terugvinden van alle foutmeldingen. Kijk ze na en maak indien nodig de juiste correcties in het systeem.\n\n{{errors}}', '<!DOCTYPE html>\n<html>\n\n<head>\n<meta charset=\"utf-8\" />\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\" />\n<title>Fout bij het automatisch aanmaken van facturen</title>\n<style type=\"text/css\">body {\n color: #000716;\n color: var(--color-dark, #000716);\n font-family: -apple-system-body, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n font-size: 12pt;\n line-height: 1.4;\n}\n\np {\n margin: 0;\n padding: 0;\n line-height: 1.4;\n}\n\np.description {\n color: var(--color-gray-4, #5e5e5e);\n}\np.description a, p.description a:link, p.description a:visited, p.description a:active, p.description a:hover {\n text-decoration: underline;\n color: var(--color-gray-4, #5e5e5e);\n}\n\nstrong {\n font-weight: bold;\n}\n\nem {\n font-style: italic;\n}\n\nh1 {\n font-size: 30px;\n font-weight: bold;\n line-height: 1.2;\n margin: 0;\n padding: 0;\n}\n@media (max-width: 350px) {\n h1 {\n font-size: 24px;\n }\n}\n\nh2 {\n font-size: 20px;\n line-height: 1.2;\n font-weight: bold;\n margin: 0;\n padding: 0;\n}\n\nh3 {\n font-size: 16px;\n line-height: 1.2;\n font-weight: bold;\n margin: 0;\n padding: 0;\n}\n\nh4 {\n line-height: 1.2;\n font-weight: 500;\n margin: 0;\n padding: 0;\n}\n\nol, ul {\n list-style-position: outside;\n padding-left: 30px;\n}\n\nhr {\n height: 1px;\n background: var(--color-border, var(--color-gray-2, #dcdcdc));\n border-radius: 1px;\n padding: 0;\n margin: 20px 0;\n outline: none;\n border: 0;\n}\n\n.button {\n touch-action: inherit;\n user-select: auto;\n cursor: pointer;\n display: inline-block !important;\n line-height: 42px;\n font-size: 16px;\n font-weight: bold;\n text-box-trim: none;\n}\n.button:active {\n transform: none;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n}\n\na, a:link, a:visited, a:active, a:hover {\n text-decoration: underline;\n color: blue;\n}\n\n.email-data-table {\n width: 100%;\n border-collapse: collapse;\n}\n.email-data-table th, .email-data-table td {\n text-align: left;\n padding: 10px 10px 10px 0;\n border-bottom: 1px solid var(--color-border, var(--color-gray-2, #dcdcdc));\n vertical-align: middle;\n}\n.email-data-table th:last-child, .email-data-table td:last-child {\n text-align: right;\n padding-right: 0;\n}\n.email-data-table td.price {\n white-space: nowrap;\n}\n.email-data-table thead {\n font-weight: bold;\n}\n.email-data-table thead th {\n font-size: 10pt;\n}\n.email-data-table h4 ~ p {\n padding-top: 3px;\n opacity: 0.8;\n font-size: 11pt;\n}\n\n.email-style-inline-code {\n font-family: monospace;\n white-space: pre-wrap;\n display: inline-block;\n}\n\n.email-style-description-small {\n font-size: 14px;\n line-height: 1.5;\n font-weight: normal;\n color: var(--color-gray-4, #5e5e5e);\n font-variation-settings: \"opsz\" 19;\n}\n\n.email-style-title-list {\n font-size: 16px;\n line-height: 1.3;\n font-weight: 500;\n}\n.email-style-title-list + p {\n padding-top: 3px;\n}\n\n.email-style-title-prefix-list {\n font-size: 11px;\n line-height: 1.5;\n font-weight: bold;\n color: {{primaryColor}};\n text-transform: uppercase;\n margin-bottom: 3px;\n}\n.email-style-title-prefix-list.error {\n color: #f0153d;\n}\n\n.email-style-price-base, .email-style-discount-price, .email-style-discount-old-price, .email-style-price {\n font-size: 15px;\n line-height: 1.4;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n}\n.email-style-price-base.disabled, .disabled.email-style-discount-price, .disabled.email-style-discount-old-price, .disabled.email-style-price {\n opacity: 0.6;\n}\n.email-style-price-base.negative, .negative.email-style-discount-price, .negative.email-style-discount-old-price, .negative.email-style-price {\n color: #f0153d;\n}\n\n.email-style-price {\n font-weight: bold;\n color: {{primaryColor}};\n}\n\n.email-style-discount-old-price {\n text-decoration: line-through;\n color: var(--color-gray-4, #5e5e5e);\n}\n\n.email-style-discount-price {\n font-weight: bold;\n color: #ff4747;\n margin-left: 5px;\n}\n\n.pre-wrap {\n white-space: pre-wrap;\n} hr {height: 2px;background: #e7e7e7; border-radius: 1px; padding: 0; margin: 20px 0; outline: none; border: 0;} .button.primary { margin: 0; text-decoration: none; font-size: 16px; font-weight: bold; color: {{primaryColorContrast}}; padding: 0 27px; line-height: 42px; background: {{primaryColor}}; text-align: center; border-radius: 7px; touch-action: manipulation; display: inline-block; transition: 0.2s transform, 0.2s opacity; } .button.primary:active { transform: scale(0.95, 0.95); } .inline-link, .inline-link:link, .inline-link:visited, .inline-link:active, .inline-link:hover { margin: 0; text-decoration: underline; font-size: inherit; font-weight: inherit; color: inherit; touch-action: manipulation; } .inline-link:active { opacity: 0.5; } .description { color: #5e5e5e; } </style>\n</head>\n\n<body>\n<p style=\"margin: 0; padding: 0; line-height: 1.4;\">{{greeting}}</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">Bij het automatisch aanmaken van facturen in naam van {{organizationName}} ging er iets mis. Hieronder kan je een lijst terugvinden van alle foutmeldingen. Kijk ze na en maak indien nodig de juiste correcties in het systeem.</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">{{errors}}</p>\n</body>\n\n</html>', '{\"value\": {\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"smartVariable\", \"attrs\": {\"id\": \"greeting\"}}]}, {\"type\": \"paragraph\"}, {\"type\": \"paragraph\", \"content\": [{\"text\": \"Bij het automatisch aanmaken van facturen in naam van \", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"organizationName\"}}, {\"text\": \" ging er iets mis. Hieronder kan je een lijst terugvinden van alle foutmeldingen. Kijk ze na en maak indien nodig de juiste correcties in het systeem.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\"}, {\"type\": \"paragraph\", \"content\": [{\"type\": \"smartVariable\", \"attrs\": {\"id\": \"errors\"}}]}]}, \"version\": 397}', '2026-05-18 14:52:07', '2026-05-18 14:52:07')
3
+ ON DUPLICATE KEY UPDATE id=id;
@@ -1,7 +1,8 @@
1
1
  import { Model } from '@simonbackx/simple-database';
2
- import type { Organization, Payment} from '@stamhoofd/models';
2
+ import type { Organization, Payment } from '@stamhoofd/models';
3
3
  import { BalanceItem, CachedBalance, Document, MemberUser, Order, Webshop } from '@stamhoofd/models';
4
4
  import { AuditLogSource, BalanceItemStatus, BalanceItemType, OrderStatus, PaymentStatus, ReceivableBalanceType } from '@stamhoofd/structures';
5
+ import { Formatter } from '@stamhoofd/utility';
5
6
  import { GroupedThrottledQueue } from '../helpers/GroupedThrottledQueue.js';
6
7
  import { ThrottledQueue } from '../helpers/ThrottledQueue.js';
7
8
  import { AuditLogService } from './AuditLogService.js';
@@ -108,10 +109,19 @@ export const BalanceItemService = {
108
109
  */
109
110
  async updatePaidAndPending(items: BalanceItem[]) {
110
111
  console.log('updatePaidAndPending for', items.length, 'items');
111
- await BalanceItem.updatePricePaid(items.map(i => i.id));
112
+ await BalanceItem.updatePricePaid(Formatter.uniqueArray(items.map(i => i.id)));
112
113
  await this.scheduleUpdates(items);
113
114
  },
114
115
 
116
+ /**
117
+ * Call this when a payment or payment balance items have changed.
118
+ * It will also call updateOutstanding automatically, so no need to call that separately again
119
+ */
120
+ async updateInvoiced(items: (BalanceItem | string)[]) {
121
+ console.log('updateInvoiced for', items.length, 'items');
122
+ await BalanceItem.updateInvoiced(Formatter.uniqueArray(items.map(i => typeof i === 'string' ? i : i.id)));
123
+ },
124
+
115
125
  /**
116
126
  * In some situations we need immediate updates
117
127
  */
@@ -275,13 +285,6 @@ export const BalanceItemService = {
275
285
  await order.undoPaid(payment, organization);
276
286
  }
277
287
  }
278
-
279
- // If a rounded payment was canceled, make sure the balance item is hidden again (will become visible again when marking paid)
280
- if (this.type === BalanceItemType.Rounding && balanceItem.status !== BalanceItemStatus.Hidden) {
281
- // Mark undue
282
- balanceItem.status = BalanceItemStatus.Hidden;
283
- await balanceItem.save();
284
- }
285
288
  },
286
289
 
287
290
  async markFailed(balanceItem: BalanceItem, payment: Payment, organization: Organization) {
@@ -297,13 +300,6 @@ export const BalanceItemService = {
297
300
  }
298
301
  }
299
302
  }
300
-
301
- // If a rounded payment was canceled, make sure the balance item is hidden again (will become visible again when marking paid)
302
- if (this.type === BalanceItemType.Rounding && balanceItem.status !== BalanceItemStatus.Hidden) {
303
- // Mark undue
304
- balanceItem.status = BalanceItemStatus.Hidden;
305
- await balanceItem.save();
306
- }
307
303
  },
308
304
 
309
305
  async undoFailed(balanceItem: BalanceItem, payment: Payment, organization: Organization) {