ztechno_core 0.0.105 → 0.0.108

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 (63) hide show
  1. package/lib/core/crypto_service.d.ts +23 -0
  2. package/lib/core/crypto_service.js +100 -0
  3. package/lib/core/engine_base.d.ts +11 -0
  4. package/lib/core/engine_base.js +9 -0
  5. package/lib/core/index.d.ts +14 -0
  6. package/lib/core/index.js +101 -0
  7. package/lib/core/mail_service.d.ts +85 -0
  8. package/lib/core/mail_service.js +156 -0
  9. package/lib/core/orm/mail_blacklist_orm.d.ts +21 -0
  10. package/lib/core/orm/mail_blacklist_orm.js +79 -0
  11. package/lib/core/orm/orm.d.ts +27 -0
  12. package/lib/core/orm/orm.js +67 -0
  13. package/lib/core/sql_service.d.ts +195 -0
  14. package/lib/core/sql_service.js +333 -0
  15. package/lib/core/translate_service.d.ts +48 -0
  16. package/lib/core/translate_service.js +913 -0
  17. package/lib/core/types/crypto_types.d.ts +4 -0
  18. package/lib/core/types/crypto_types.js +2 -0
  19. package/lib/core/types/mail_types.d.ts +95 -0
  20. package/lib/core/types/mail_types.js +2 -0
  21. package/lib/core/types/site_config.d.ts +46 -0
  22. package/lib/core/types/site_config.js +2 -0
  23. package/lib/core/types/translate_types.d.ts +69 -0
  24. package/lib/core/types/translate_types.js +46 -0
  25. package/lib/core/types/user_types.d.ts +41 -0
  26. package/lib/core/types/user_types.js +2 -0
  27. package/lib/core/user_service.d.ts +87 -0
  28. package/lib/core/user_service.js +216 -0
  29. package/lib/express/index.d.ts +1 -0
  30. package/lib/express/index.js +18 -0
  31. package/lib/index.d.ts +17 -8
  32. package/lib/index.js +60 -16
  33. package/lib/mollie/index.d.ts +5 -0
  34. package/lib/mollie/index.js +62 -0
  35. package/lib/mollie/orm/customers_orm.d.ts +16 -0
  36. package/lib/mollie/orm/customers_orm.js +115 -0
  37. package/lib/mollie/orm/invoice_items_orm.d.ts +9 -0
  38. package/lib/mollie/orm/invoice_items_orm.js +71 -0
  39. package/lib/mollie/orm/invoice_payments_orm.d.ts +10 -0
  40. package/lib/mollie/orm/invoice_payments_orm.js +70 -0
  41. package/lib/mollie/orm/invoices_orm.d.ts +40 -0
  42. package/lib/mollie/orm/invoices_orm.js +172 -0
  43. package/lib/mollie/orm/subscription_items_orm.d.ts +9 -0
  44. package/lib/mollie/orm/subscription_items_orm.js +45 -0
  45. package/lib/mollie/orm/subscriptions_orm.d.ts +17 -0
  46. package/lib/mollie/orm/subscriptions_orm.js +122 -0
  47. package/lib/mollie/services/customer_service.d.ts +14 -0
  48. package/lib/mollie/services/customer_service.js +53 -0
  49. package/lib/mollie/services/invoice_service.d.ts +102 -0
  50. package/lib/mollie/services/invoice_service.js +866 -0
  51. package/lib/mollie/services/mollie_service.d.ts +42 -0
  52. package/lib/mollie/services/mollie_service.js +370 -0
  53. package/lib/mollie/services/subscription_service.d.ts +32 -0
  54. package/lib/mollie/services/subscription_service.js +134 -0
  55. package/lib/mollie/types/internal_types.d.ts +19 -0
  56. package/lib/mollie/types/internal_types.js +2 -0
  57. package/lib/mollie/types/mollie_types.d.ts +187 -0
  58. package/lib/mollie/types/mollie_types.js +3 -0
  59. package/lib/mollie/util/subscription_utils.d.ts +8 -0
  60. package/lib/mollie/util/subscription_utils.js +37 -0
  61. package/lib/schema/MySQLSchemaExtractor.d.ts +1 -1
  62. package/lib/schema/MySQLSchemaImporter.d.ts +1 -1
  63. package/package.json +3 -1
@@ -0,0 +1,866 @@
1
+ 'use strict';
2
+ var __importDefault =
3
+ (this && this.__importDefault) ||
4
+ function (mod) {
5
+ return mod && mod.__esModule ? mod : { default: mod };
6
+ };
7
+ Object.defineProperty(exports, '__esModule', { value: true });
8
+ exports.InvoiceService = void 0;
9
+ const pdfkit_1 = __importDefault(require('pdfkit'));
10
+ const path_1 = __importDefault(require('path'));
11
+ const fs_1 = __importDefault(require('fs'));
12
+ const crypto_1 = __importDefault(require('crypto'));
13
+ const invoice_items_orm_1 = require('../orm/invoice_items_orm');
14
+ const invoice_payments_orm_1 = require('../orm/invoice_payments_orm');
15
+ const invoices_orm_1 = require('../orm/invoices_orm');
16
+ const subscription_items_orm_1 = require('../orm/subscription_items_orm');
17
+ const subscriptions_orm_1 = require('../orm/subscriptions_orm');
18
+ const subscription_utils_1 = require('../util/subscription_utils');
19
+ const orm_1 = require('../../core/orm/orm');
20
+ class InvoiceService {
21
+ get config() {
22
+ return this.opt.siteConfig;
23
+ }
24
+ get baseUrl() {
25
+ return this.opt.siteConfig.company.website;
26
+ }
27
+ constructor(opt) {
28
+ this.opt = opt;
29
+ this.payTokenSecret = this.opt.payTokenSecret || '';
30
+ this.payTokenLifetimeMs = 60 * 24 * 60 * 60 * 1000;
31
+ this.invoicesOrm = new invoices_orm_1.InvoicesOrm({ sqlService: opt.sqlService });
32
+ this.itemsOrm = new invoice_items_orm_1.InvoiceItemsOrm({ sqlService: opt.sqlService });
33
+ this.paymentsOrm = new invoice_payments_orm_1.InvoicePaymentsOrm({ sqlService: opt.sqlService });
34
+ this.subscriptionsOrm = new subscriptions_orm_1.SubscriptionsOrm({ sqlService: opt.sqlService });
35
+ this.subscriptionItemsOrm = new subscription_items_orm_1.SubscriptionItemsOrm({ sqlService: opt.sqlService });
36
+ this.mailService = opt.mailService;
37
+ }
38
+ async autoInit() {
39
+ await this.invoicesOrm.ensureTableExists();
40
+ await this.itemsOrm.ensureTableExists();
41
+ await this.paymentsOrm.ensureTableExists();
42
+ await this.ensurePayTokenSchema();
43
+ await this.ensureSubscriptionInvoiceSchema();
44
+ await this.ensureInvoicePaymentSchema();
45
+ await this.ensureSubsidyItemTypeSchema();
46
+ }
47
+ async ensurePayTokenSchema() {
48
+ const table = this.invoicesOrm.alias;
49
+ const ensureColumn = async (column, sqlType) => {
50
+ const rows = await this.opt.sqlService.exec({
51
+ query: `SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND COLUMN_NAME=:columnName LIMIT 1`,
52
+ params: { schema: this.opt.sqlService.database, tableName: table, columnName: column },
53
+ });
54
+ if (!rows?.[0]) {
55
+ await this.opt.sqlService.query(`ALTER TABLE \`${table}\` ADD COLUMN ${column} ${sqlType}`);
56
+ }
57
+ };
58
+ await ensureColumn('pay_token_hash', 'CHAR(64) NULL');
59
+ await ensureColumn('pay_token_expires_at', 'DATETIME NULL');
60
+ await ensureColumn('pay_token_finalized_at', 'DATETIME NULL');
61
+ const indexRows = await this.opt.sqlService.exec({
62
+ query: `SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND INDEX_NAME='uq_invoices_pay_token_hash' LIMIT 1`,
63
+ params: { schema: this.opt.sqlService.database, tableName: table },
64
+ });
65
+ if (!indexRows?.[0]) {
66
+ await this.opt.sqlService.query(
67
+ `ALTER TABLE \`${table}\` ADD UNIQUE KEY uq_invoices_pay_token_hash (pay_token_hash)`,
68
+ );
69
+ }
70
+ }
71
+ async ensureSubscriptionInvoiceSchema() {
72
+ const table = this.invoicesOrm.alias;
73
+ const ensureColumn = async (column, sqlType) => {
74
+ const rows = await this.opt.sqlService.exec({
75
+ query: `SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND COLUMN_NAME=:columnName LIMIT 1`,
76
+ params: { schema: this.opt.sqlService.database, tableName: table, columnName: column },
77
+ });
78
+ if (!rows?.[0]) {
79
+ await this.opt.sqlService.query(`ALTER TABLE \`${table}\` ADD COLUMN ${column} ${sqlType}`);
80
+ }
81
+ };
82
+ await ensureColumn('subscription_id', 'BIGINT UNSIGNED NULL');
83
+ await ensureColumn('subscription_period_start', 'DATETIME NULL');
84
+ await ensureColumn('subscription_period_end', 'DATETIME NULL');
85
+ const indexRows = await this.opt.sqlService.exec({
86
+ query: `SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND INDEX_NAME='idx_invoices_subscription' LIMIT 1`,
87
+ params: { schema: this.opt.sqlService.database, tableName: table },
88
+ });
89
+ if (!indexRows?.[0]) {
90
+ await this.opt.sqlService.query(`ALTER TABLE \`${table}\` ADD KEY idx_invoices_subscription (subscription_id)`);
91
+ }
92
+ }
93
+ async ensureSubsidyItemTypeSchema() {
94
+ const table = this.itemsOrm.alias;
95
+ const rows = await this.opt.sqlService.exec({
96
+ query: `SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND COLUMN_NAME='item_type' LIMIT 1`,
97
+ params: { schema: this.opt.sqlService.database, tableName: table },
98
+ });
99
+ if (!rows?.[0]) {
100
+ await this.opt.sqlService.query(
101
+ `ALTER TABLE \`${table}\` ADD COLUMN item_type ENUM('service','subsidy') NOT NULL DEFAULT 'service' AFTER invoice_id`,
102
+ );
103
+ }
104
+ }
105
+ async ensureInvoicePaymentSchema() {
106
+ const table = this.paymentsOrm.alias;
107
+ const ensureColumn = async (column, sqlType) => {
108
+ const rows = await this.opt.sqlService.exec({
109
+ query: `SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND COLUMN_NAME=:columnName LIMIT 1`,
110
+ params: { schema: this.opt.sqlService.database, tableName: table, columnName: column },
111
+ });
112
+ if (!rows?.[0]) {
113
+ await this.opt.sqlService.query(`ALTER TABLE \`${table}\` ADD COLUMN ${column} ${sqlType}`);
114
+ }
115
+ };
116
+ await ensureColumn('sequence_type', "ENUM('oneoff','first','recurring') NULL");
117
+ await ensureColumn('mollie_subscription_id', 'VARCHAR(64) NULL');
118
+ const indexRows = await this.opt.sqlService.exec({
119
+ query: `SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=:schema AND TABLE_NAME=:tableName AND INDEX_NAME='idx_payments_subscription' LIMIT 1`,
120
+ params: { schema: this.opt.sqlService.database, tableName: table },
121
+ });
122
+ if (!indexRows?.[0]) {
123
+ await this.opt.sqlService.query(
124
+ `ALTER TABLE \`${table}\` ADD KEY idx_payments_subscription (mollie_subscription_id)`,
125
+ );
126
+ }
127
+ }
128
+ async generateInvoiceNumber() {
129
+ const now = new Date();
130
+ const year = now.getFullYear();
131
+ // Get the highest invoice number for this year
132
+ const lastInvoice = await this.invoicesOrm.findLastByYear(year);
133
+ let sequence = 1;
134
+ if (lastInvoice?.invoice_number) {
135
+ // Extract sequence from format "INV-YYYY-NNNNNN"
136
+ const match = lastInvoice.invoice_number.match(/INV-\d{4}-(\d+)/);
137
+ if (match) {
138
+ sequence = parseInt(match[1], 10) + 1;
139
+ }
140
+ }
141
+ return `INV-${year}-${sequence.toString().padStart(6, '0')}`;
142
+ }
143
+ getWebhookUrl() {
144
+ return this.opt.mollieService.webhookUrl;
145
+ }
146
+ signPayTokenRaw(raw) {
147
+ if (!this.payTokenSecret) {
148
+ throw new Error('PAY_TOKEN_SECRET or Mollie key is required for pay token signing');
149
+ }
150
+ return crypto_1.default.createHmac('sha256', this.payTokenSecret).update(raw).digest('hex');
151
+ }
152
+ buildPayToken(raw) {
153
+ const signature = this.signPayTokenRaw(raw);
154
+ return `${raw}.${signature}`;
155
+ }
156
+ parseSignedPayToken(token) {
157
+ const separator = token.lastIndexOf('.');
158
+ if (separator <= 0 || separator >= token.length - 1) {
159
+ return null;
160
+ }
161
+ const raw = token.substring(0, separator);
162
+ const signature = token.substring(separator + 1);
163
+ return { raw, signature };
164
+ }
165
+ verifySignedPayToken(token) {
166
+ const parsed = this.parseSignedPayToken(token);
167
+ if (!parsed) {
168
+ return null;
169
+ }
170
+ const expected = this.signPayTokenRaw(parsed.raw);
171
+ const expectedBuf = Buffer.from(expected);
172
+ const signatureBuf = Buffer.from(parsed.signature);
173
+ if (expectedBuf.length !== signatureBuf.length) {
174
+ return null;
175
+ }
176
+ const valid = crypto_1.default.timingSafeEqual(new Uint8Array(expectedBuf), new Uint8Array(signatureBuf));
177
+ if (!valid) {
178
+ return null;
179
+ }
180
+ return parsed.raw;
181
+ }
182
+ hashPayTokenRaw(raw) {
183
+ return crypto_1.default.createHash('sha256').update(raw).digest('hex');
184
+ }
185
+ async getInvoiceBundle(invoiceId) {
186
+ const invoice = await this.invoicesOrm.findById(invoiceId);
187
+ if (!invoice) {
188
+ throw new Error(`Invoice ${invoiceId} not found`);
189
+ }
190
+ const items = await this.itemsOrm.findByInvoice(invoiceId);
191
+ const customer = await this.opt.customerService.findById(invoice.customer_id);
192
+ if (!customer) {
193
+ throw new Error(`Customer ${invoice.customer_id} not found`);
194
+ }
195
+ return { invoice, items, customer };
196
+ }
197
+ formatMoney(value, currency) {
198
+ const symbol = currency === 'EUR' ? '€' : currency;
199
+ return `${symbol} ${value.toFixed(2)}`;
200
+ }
201
+ calcTotals(items) {
202
+ let amount_due = 0;
203
+ const mapped = items.map((item, idx) => {
204
+ const itemType = item.item_type ?? 'service';
205
+ const total_ex_vat = Number(item.quantity) * Number(item.unit_price);
206
+ // Subsidy items are VAT-exempt gross deductions (vat_rate forced to 0)
207
+ const effectiveVatRate = itemType === 'subsidy' ? 0 : Number(item.vat_rate || 0);
208
+ const total_inc_vat = total_ex_vat * (1 + effectiveVatRate / 100);
209
+ amount_due += total_inc_vat;
210
+ return {
211
+ invoice_id: 0,
212
+ item_type: itemType,
213
+ description: item.description,
214
+ quantity: Number(item.quantity),
215
+ unit_price: Number(item.unit_price),
216
+ vat_rate: effectiveVatRate,
217
+ total_ex_vat,
218
+ total_inc_vat,
219
+ sort_order: item.sort_order ?? idx,
220
+ };
221
+ });
222
+ return { amount_due: Number(amount_due.toFixed(2)), items: mapped };
223
+ }
224
+ createPayTokenRaw(invoiceId) {
225
+ const nonce = crypto_1.default.randomBytes(16).toString('hex');
226
+ return `inv_${invoiceId}_${nonce}`;
227
+ }
228
+ isInvoicePaid(invoice) {
229
+ return invoice?.status === 'paid';
230
+ }
231
+ isInvoiceFinalizedForPay(invoice) {
232
+ return this.isInvoicePaid(invoice) || !!invoice?.pay_token_finalized_at;
233
+ }
234
+ isTokenExpired(invoice) {
235
+ if (!invoice.pay_token_expires_at) {
236
+ return true;
237
+ }
238
+ return new Date(invoice.pay_token_expires_at).getTime() <= Date.now();
239
+ }
240
+ async createMolliePaymentForInvoice(invoice, opt) {
241
+ const customer = await this.opt.customerService.findById(invoice.customer_id);
242
+ if (!customer) {
243
+ throw new Error(`Customer ${invoice.customer_id} not found`);
244
+ }
245
+ if (!customer.mollie_customer_id) {
246
+ throw new Error(`Customer ${customer.id} missing mollie_customer_id`);
247
+ }
248
+ const metadata = {
249
+ ...(invoice.metadata ?? {}),
250
+ ...(opt?.metadata ?? {}),
251
+ };
252
+ metadata.invoice_number = invoice.invoice_number;
253
+ metadata.customer_id = customer.id;
254
+ metadata.invoice_id = invoice.id;
255
+ if (invoice.subscription_id) {
256
+ metadata.subscription_id = invoice.subscription_id;
257
+ }
258
+ if (invoice.payment_terms) {
259
+ metadata.payment_terms = invoice.payment_terms;
260
+ }
261
+ if (invoice.due_date) {
262
+ metadata.due_date = invoice.due_date;
263
+ }
264
+ // Store line items for recovery
265
+ const invoiceItems = await this.itemsOrm.findByInvoice(invoice.id);
266
+ if (invoiceItems.length > 0) {
267
+ metadata.items = invoiceItems.map((it) => ({
268
+ d: it.description,
269
+ q: it.quantity,
270
+ u: it.unit_price,
271
+ v: it.vat_rate,
272
+ t: it.item_type ?? 'service',
273
+ }));
274
+ }
275
+ const payment = await this.opt.mollieService.createPayment({
276
+ amount: { currency: invoice.currency || 'EUR', value: Number(invoice.amount_due).toFixed(2) },
277
+ description: invoice.description || `Invoice ${invoice.invoice_number}`,
278
+ redirectUrl: `${this.baseUrl}/pay/success?invoice=${encodeURIComponent(invoice.invoice_number)}`,
279
+ webhookUrl: this.getWebhookUrl(),
280
+ customerId: customer.mollie_customer_id,
281
+ metadata,
282
+ sequenceType: opt?.sequenceType ?? 'oneoff',
283
+ mandateId: opt?.mandateId ?? undefined,
284
+ });
285
+ await this.paymentsOrm.upsert({
286
+ invoice_id: invoice.id,
287
+ mollie_payment_id: payment.id,
288
+ status: payment.status,
289
+ sequence_type: payment.sequenceType ?? opt?.sequenceType ?? 'oneoff',
290
+ mollie_subscription_id: payment.subscriptionId ?? null,
291
+ method: payment.method ?? null,
292
+ amount: Number(payment.amount.value),
293
+ currency: payment.amount.currency,
294
+ checkout_url: payment?._links?.checkout?.href ?? null,
295
+ paid_at: (0, orm_1.toDatetime)(payment.paidAt ?? null),
296
+ expires_at: (0, orm_1.toDatetime)(payment.expiresAt),
297
+ mandate_id: payment.mandateId ?? opt?.mandateId ?? null,
298
+ });
299
+ await this.invoicesOrm.updatePaymentRef(invoice.id, {
300
+ mollie_payment_id: payment.id,
301
+ checkout_url: payment?.getCheckoutUrl() ?? payment?._links?.checkout?.href ?? null,
302
+ });
303
+ return payment;
304
+ }
305
+ async issueOrRotatePayToken(invoice) {
306
+ if (this.isInvoiceFinalizedForPay(invoice)) {
307
+ throw new Error(`Invoice ${invoice.invoice_number} is already finalized`);
308
+ }
309
+ const raw = this.createPayTokenRaw(invoice.id);
310
+ const token = this.buildPayToken(raw);
311
+ const expiresAtDate = new Date(Date.now() + this.payTokenLifetimeMs);
312
+ const expiresAt = (0, orm_1.formatDatetime)(expiresAtDate);
313
+ const tokenHash = this.hashPayTokenRaw(raw);
314
+ await this.invoicesOrm.setPayToken(invoice.id, {
315
+ pay_token_hash: tokenHash,
316
+ pay_token_expires_at: expiresAt,
317
+ });
318
+ return {
319
+ token,
320
+ expiresAt,
321
+ payUrl: `${this.baseUrl}/pay?token=${encodeURIComponent(token)}`,
322
+ };
323
+ }
324
+ async ensurePayLink(invoiceId) {
325
+ const invoice = await this.invoicesOrm.findById(invoiceId);
326
+ if (!invoice) {
327
+ throw new Error(`Invoice ${invoiceId} not found`);
328
+ }
329
+ if (this.isInvoiceFinalizedForPay(invoice)) {
330
+ return {
331
+ token: '',
332
+ expiresAt: invoice.pay_token_expires_at ?? (0, orm_1.formatDatetime)(new Date()),
333
+ payUrl: `${this.baseUrl}/pay/success?invoice=${encodeURIComponent(invoice.invoice_number)}`,
334
+ };
335
+ }
336
+ return await this.issueOrRotatePayToken(invoice);
337
+ }
338
+ async resolvePayToken(token) {
339
+ const raw = this.verifySignedPayToken(token);
340
+ if (!raw) {
341
+ throw new Error('Invalid pay token');
342
+ }
343
+ const tokenHash = this.hashPayTokenRaw(raw);
344
+ const invoice = await this.invoicesOrm.findByPayTokenHash(tokenHash);
345
+ if (!invoice) {
346
+ throw new Error('Pay token not found');
347
+ }
348
+ if (this.isInvoicePaid(invoice)) {
349
+ await this.invoicesOrm.finalizePayToken(invoice.id);
350
+ return { action: 'paid', invoice };
351
+ }
352
+ if (this.isTokenExpired(invoice)) {
353
+ throw new Error('Pay token expired');
354
+ }
355
+ if (invoice.mollie_payment_id) {
356
+ const existingPayment = await this.opt.mollieService.getPayment(invoice.mollie_payment_id);
357
+ const mapped = this.opt.mollieService.mapPaymentStatus(existingPayment.status);
358
+ if (mapped === 'paid') {
359
+ await this.syncPayment(existingPayment.id);
360
+ const paidInvoice = await this.invoicesOrm.findById(invoice.id);
361
+ if (paidInvoice) {
362
+ await this.invoicesOrm.finalizePayToken(paidInvoice.id);
363
+ return { action: 'paid', invoice: paidInvoice };
364
+ }
365
+ }
366
+ const checkoutUrl = existingPayment?.getCheckoutUrl() || existingPayment?._links?.checkout?.href;
367
+ if (existingPayment.status === 'open' && checkoutUrl) {
368
+ return { action: 'redirect', checkoutUrl, invoice };
369
+ }
370
+ }
371
+ const payment = await this.createMolliePaymentForInvoice(invoice, {
372
+ sequenceType: invoice.subscription_id ? 'first' : 'oneoff',
373
+ });
374
+ const checkoutUrl = payment?.getCheckoutUrl() || payment?._links?.checkout?.href;
375
+ if (!checkoutUrl) {
376
+ throw new Error('Failed to create checkout URL');
377
+ }
378
+ const updatedInvoice = await this.invoicesOrm.findById(invoice.id);
379
+ return { action: 'redirect', checkoutUrl, invoice: updatedInvoice || invoice };
380
+ }
381
+ async listInvoices() {
382
+ return await this.invoicesOrm.findAll();
383
+ }
384
+ async getInvoiceById(invoiceId) {
385
+ return await this.invoicesOrm.findById(invoiceId);
386
+ }
387
+ async listPayments(invoice_id) {
388
+ return await this.paymentsOrm.findByInvoice(invoice_id);
389
+ }
390
+ async createInvoiceWithPayment(input) {
391
+ const draft = await this.createInvoiceDraft(input);
392
+ const payment = await this.createMolliePaymentForInvoice(draft.invoice);
393
+ const updated = await this.invoicesOrm.findById(draft.invoice.id);
394
+ return {
395
+ invoice: updated || draft.invoice,
396
+ checkoutUrl: payment?.getCheckoutUrl() || payment?._links?.checkout?.href || null,
397
+ payment,
398
+ payUrl: draft.pay.payUrl,
399
+ };
400
+ }
401
+ async createInvoiceFromItems(input, overrides) {
402
+ const customer = await this.opt.customerService.findById(input.customer_id);
403
+ if (!customer) {
404
+ throw new Error(`Customer ${input.customer_id} not found`);
405
+ }
406
+ if (!customer.mollie_customer_id) {
407
+ throw new Error(`Customer ${customer.id} missing mollie_customer_id`);
408
+ }
409
+ const { items, amount_due } = this.calcTotals(input.items);
410
+ const invoice_number = await this.generateInvoiceNumber();
411
+ const status = overrides?.status ?? 'pending';
412
+ const issuedAt = (0, orm_1.formatDatetime)(new Date());
413
+ const paidAt = overrides?.paid_at ?? (status === 'paid' ? issuedAt : null);
414
+ const amount_paid = overrides?.amount_paid ?? (status === 'paid' ? amount_due : 0);
415
+ const invoicePayload = {
416
+ invoice_number,
417
+ customer_id: customer.id,
418
+ subscription_id: overrides?.subscription_id ?? null,
419
+ subscription_period_start: overrides?.subscription_period_start ?? null,
420
+ subscription_period_end: overrides?.subscription_period_end ?? null,
421
+ mollie_customer_id: customer.mollie_customer_id,
422
+ mollie_payment_id: overrides?.mollie_payment_id ?? null,
423
+ pay_token_hash: null,
424
+ pay_token_expires_at: null,
425
+ pay_token_finalized_at: null,
426
+ status,
427
+ amount_due,
428
+ amount_paid,
429
+ currency: input.currency || 'EUR',
430
+ description: input.description,
431
+ payment_terms: input.payment_terms ?? 'Betaling binnen 14 dagen na factuurdatum',
432
+ due_date: input.due_date ?? null,
433
+ issued_at: issuedAt,
434
+ paid_at: paidAt,
435
+ checkout_url: overrides?.checkout_url ?? null,
436
+ metadata: input.metadata ?? null,
437
+ };
438
+ await this.invoicesOrm.create(invoicePayload);
439
+ const savedInvoice = await this.invoicesOrm.findByInvoiceNumber(invoice_number);
440
+ if (!savedInvoice) {
441
+ throw new Error('Failed to persist invoice');
442
+ }
443
+ const itemsWithInvoice = items.map((it) => ({ ...it, invoice_id: savedInvoice.id }));
444
+ await this.itemsOrm.bulkInsert(itemsWithInvoice);
445
+ const pay = overrides?.issuePayToken === false ? null : await this.ensurePayLink(savedInvoice.id);
446
+ return { invoice: savedInvoice, pay };
447
+ }
448
+ async createInvoiceDraft(input) {
449
+ const result = await this.createInvoiceFromItems(input, { issuePayToken: true });
450
+ if (!result.pay) {
451
+ throw new Error('Failed to issue pay token');
452
+ }
453
+ return { invoice: result.invoice, pay: result.pay };
454
+ }
455
+ async syncPayment(paymentId) {
456
+ const payment = await this.opt.mollieService.getPayment(paymentId);
457
+ const metadata = payment.metadata ?? {};
458
+ const localPayment = await this.paymentsOrm.findByPaymentId(paymentId);
459
+ let invoice = localPayment ? await this.invoicesOrm.findById(localPayment.invoice_id) : undefined;
460
+ const metadataInvoiceId = metadata?.invoice_id ? Number(metadata.invoice_id) : undefined;
461
+ if (!invoice && metadataInvoiceId) {
462
+ invoice = await this.invoicesOrm.findById(metadataInvoiceId);
463
+ }
464
+ if (!invoice && metadata?.invoice_number) {
465
+ invoice = await this.invoicesOrm.findByInvoiceNumber(metadata.invoice_number);
466
+ }
467
+ const subscriptionIdFromPayment = payment.subscriptionId ?? null;
468
+ const metadataSubscriptionId = metadata?.subscription_id ? Number(metadata.subscription_id) : undefined;
469
+ let subscription;
470
+ if (subscriptionIdFromPayment) {
471
+ subscription = await this.subscriptionsOrm.findByMollieSubscriptionId(subscriptionIdFromPayment);
472
+ }
473
+ if (!subscription && metadataSubscriptionId) {
474
+ subscription = await this.subscriptionsOrm.findById(metadataSubscriptionId);
475
+ }
476
+ if (!subscription && invoice?.subscription_id) {
477
+ subscription = await this.subscriptionsOrm.findById(invoice.subscription_id);
478
+ }
479
+ const hadInvoice = !!invoice;
480
+ const mappedStatus = this.opt.mollieService.mapPaymentStatus(payment.status);
481
+ const paidAmount = mappedStatus === 'paid' ? Number(payment.amount.value) : undefined;
482
+ const paidAt = mappedStatus === 'paid' && payment.paidAt ? (0, orm_1.toDatetime)(payment.paidAt) : null;
483
+ if (!invoice && subscription) {
484
+ const subscriptionItems = await this.subscriptionItemsOrm.findBySubscription(subscription.id);
485
+ if (subscriptionItems.length === 0) {
486
+ throw new Error(`Subscription ${subscription.id} has no items`);
487
+ }
488
+ const periodBase = payment.paidAt
489
+ ? new Date(payment.paidAt)
490
+ : payment.createdAt
491
+ ? new Date(payment.createdAt)
492
+ : new Date();
493
+ const interval = (0, subscription_utils_1.parseSubscriptionInterval)(subscription.interval);
494
+ const periodEnd = (0, subscription_utils_1.addSubscriptionInterval)(periodBase, interval);
495
+ const created = await this.createInvoiceFromItems(
496
+ {
497
+ customer_id: subscription.customer_id,
498
+ description: subscription.description ?? `Subscription ${subscription.id}`,
499
+ currency: subscription.currency || 'EUR',
500
+ items: subscriptionItems.map((it) => ({
501
+ description: it.description,
502
+ quantity: it.quantity,
503
+ unit_price: it.unit_price,
504
+ vat_rate: it.vat_rate,
505
+ sort_order: it.sort_order,
506
+ })),
507
+ metadata: subscription.metadata ?? null,
508
+ },
509
+ {
510
+ status: mappedStatus,
511
+ paid_at: paidAt,
512
+ amount_paid: mappedStatus === 'paid' ? Number(payment.amount.value) : 0,
513
+ subscription_id: subscription.id,
514
+ subscription_period_start: (0, orm_1.formatDatetime)(periodBase),
515
+ subscription_period_end: (0, orm_1.formatDatetime)(periodEnd),
516
+ issuePayToken: false,
517
+ mollie_payment_id: payment.id,
518
+ checkout_url: payment?._links?.checkout?.href ?? null,
519
+ },
520
+ );
521
+ invoice = created.invoice;
522
+ }
523
+ if (!invoice) {
524
+ throw new Error(`Invoice for payment ${paymentId} not found`);
525
+ }
526
+ await this.paymentsOrm.upsert({
527
+ invoice_id: invoice.id,
528
+ mollie_payment_id: payment.id,
529
+ status: payment.status,
530
+ sequence_type: payment.sequenceType ?? null,
531
+ mollie_subscription_id: payment.subscriptionId ?? null,
532
+ method: payment.method ?? null,
533
+ amount: Number(payment.amount.value),
534
+ currency: payment.amount.currency,
535
+ checkout_url: payment?._links?.checkout?.href ?? null,
536
+ paid_at: (0, orm_1.toDatetime)(payment.paidAt ?? null),
537
+ expires_at: (0, orm_1.toDatetime)(payment.expiresAt),
538
+ mandate_id: payment.mandateId ?? null,
539
+ });
540
+ await this.invoicesOrm.updatePaymentRef(invoice.id, {
541
+ mollie_payment_id: payment.id,
542
+ checkout_url: payment?._links?.checkout?.href ?? null,
543
+ });
544
+ const previousStatus = invoice.status;
545
+ await this.invoicesOrm.updateStatus(invoice.id, mappedStatus, paidAmount, paidAt);
546
+ if (mappedStatus === 'paid') {
547
+ await this.invoicesOrm.finalizePayToken(invoice.id);
548
+ }
549
+ if (subscription && !invoice.subscription_period_start) {
550
+ const periodBase = payment.paidAt
551
+ ? new Date(payment.paidAt)
552
+ : payment.createdAt
553
+ ? new Date(payment.createdAt)
554
+ : new Date();
555
+ const interval = (0, subscription_utils_1.parseSubscriptionInterval)(subscription.interval);
556
+ const periodEnd = (0, subscription_utils_1.addSubscriptionInterval)(periodBase, interval);
557
+ await this.invoicesOrm.updateSubscriptionPeriod(invoice.id, {
558
+ subscription_period_start: (0, orm_1.formatDatetime)(periodBase),
559
+ subscription_period_end: (0, orm_1.formatDatetime)(periodEnd),
560
+ });
561
+ }
562
+ if (subscription && mappedStatus === 'paid' && (!hadInvoice || previousStatus !== 'paid')) {
563
+ await this.sendInvoiceEmail(invoice.id, undefined, { mode: 'receipt' });
564
+ }
565
+ if (
566
+ subscription &&
567
+ mappedStatus === 'paid' &&
568
+ payment.sequenceType === 'first' &&
569
+ !subscription.mollie_subscription_id
570
+ ) {
571
+ if (!subscription.mollie_customer_id) {
572
+ throw new Error(`Subscription ${subscription.id} missing mollie_customer_id`);
573
+ }
574
+ const paidDate = payment.paidAt ? new Date(payment.paidAt) : new Date();
575
+ const interval = (0, subscription_utils_1.parseSubscriptionInterval)(subscription.interval);
576
+ const startDate = (0, subscription_utils_1.formatDateOnly)(
577
+ (0, subscription_utils_1.addSubscriptionInterval)(paidDate, interval),
578
+ );
579
+ const subscriptionItems = await this.subscriptionItemsOrm.findBySubscription(subscription.id);
580
+ const remote = await this.opt.mollieService.createSubscription({
581
+ customerId: subscription.mollie_customer_id,
582
+ amount: {
583
+ currency: subscription.currency || 'EUR',
584
+ value: Number(subscription.amount).toFixed(2),
585
+ },
586
+ interval: subscription.interval,
587
+ description: subscription.description ?? `Subscription ${subscription.id}`,
588
+ startDate,
589
+ webhookUrl: this.getWebhookUrl(),
590
+ metadata: {
591
+ subscription_id: subscription.id,
592
+ items: subscriptionItems.map((it) => ({
593
+ d: it.description,
594
+ q: it.quantity,
595
+ u: it.unit_price,
596
+ v: it.vat_rate,
597
+ })),
598
+ },
599
+ mandateId: payment.mandateId ?? undefined,
600
+ });
601
+ await this.subscriptionsOrm.update(subscription.id, {
602
+ mollie_subscription_id: remote.id,
603
+ status: remote.status,
604
+ mandate_id: remote.mandateId ?? payment.mandateId ?? null,
605
+ next_payment_date: remote.nextPaymentDate ? (0, orm_1.toDatetimeFromDateOnly)(remote.nextPaymentDate) : null,
606
+ canceled_at: remote.canceledAt ? (0, orm_1.toDatetime)(remote.canceledAt) : null,
607
+ });
608
+ }
609
+ if (
610
+ subscription &&
611
+ payment.sequenceType === 'recurring' &&
612
+ subscription.mollie_subscription_id &&
613
+ subscription.mollie_customer_id
614
+ ) {
615
+ const remote = await this.opt.mollieService.getSubscription(
616
+ subscription.mollie_customer_id,
617
+ subscription.mollie_subscription_id,
618
+ );
619
+ await this.subscriptionsOrm.update(subscription.id, {
620
+ status: remote.status,
621
+ mandate_id: remote.mandateId ?? subscription.mandate_id ?? null,
622
+ next_payment_date: remote.nextPaymentDate ? (0, orm_1.toDatetimeFromDateOnly)(remote.nextPaymentDate) : null,
623
+ canceled_at: remote.canceledAt ? (0, orm_1.toDatetime)(remote.canceledAt) : null,
624
+ });
625
+ }
626
+ return { invoiceId: invoice.id, status: mappedStatus };
627
+ }
628
+ async generateInvoicePdfBuffer(invoiceId) {
629
+ const { invoice, items, customer } = await this.getInvoiceBundle(invoiceId);
630
+ const doc = new pdfkit_1.default({ size: 'A4', margin: 50 });
631
+ const buffers = [];
632
+ doc.on('data', (chunk) => buffers.push(chunk));
633
+ const done = new Promise((resolve, reject) => {
634
+ doc.on('end', () => resolve(Buffer.concat(buffers)));
635
+ doc.on('error', reject);
636
+ });
637
+ // === COMPANY LOGO (left-aligned) ===
638
+ const logoPath = path_1.default.join(process.cwd(), '../zwebsite/public/img/invoice/invoice-logo.png');
639
+ if (fs_1.default.existsSync(logoPath)) {
640
+ doc.image(logoPath, 50, 50, { width: 200 }); // Left side of A4 page
641
+ }
642
+ const cfg = this.opt.siteConfig;
643
+ // === SUPPLIER INFORMATION (right-aligned, Dutch law: name, address, BTW, KVK) ===
644
+ const rightCol = 320;
645
+ doc.fontSize(18).text(cfg.company.company, rightCol, 50, { width: 230, align: 'right' });
646
+ doc.fontSize(10).text(cfg.address.street, rightCol, doc.y, { width: 230, align: 'right' });
647
+ doc.text(`${cfg.address.zipcode} ${cfg.address.city}`, rightCol, doc.y, { width: 230, align: 'right' });
648
+ doc.text(cfg.address.country, rightCol, doc.y, { width: 230, align: 'right' });
649
+ // doc.text(`Tel: ${cfg.contact.phone}`, rightCol, doc.y, { width: 230, align: 'right' })
650
+ doc.moveDown(0.5);
651
+ doc.text(`KVK ${cfg.company.kvk}`, rightCol, doc.y, { width: 230, align: 'right' });
652
+ doc.text(`${cfg.company.btwNr}`, rightCol, doc.y, { width: 230, align: 'right' });
653
+ doc.moveDown();
654
+ // === CUSTOMER INFORMATION (left-aligned, Dutch law: name, address; B2B: BTW-nummer) ===
655
+ doc.fontSize(18).text(customer.company || '', 50, doc.y);
656
+ doc.fontSize(10).text(customer.name, 50);
657
+ if (customer.address_line1) doc.text(customer.address_line1, 50);
658
+ if (customer.address_line2) doc.text(customer.address_line2, 50);
659
+ if (customer.postal_code || customer.city) {
660
+ doc.text(`${customer.postal_code || ''} ${customer.city || ''}`.trim(), 50);
661
+ }
662
+ if (customer.country) doc.text(customer.country === 'NL' ? 'Nederland' : customer.country, 50);
663
+ // doc.text(`Email: ${customer.email}`, 50)
664
+ // if (customer.phone) doc.text(`Tel: ${customer.phone}`, 50)
665
+ if (customer.btw_nummer) doc.text(`${customer.btw_nummer}`, 50);
666
+ doc.moveDown();
667
+ // === INVOICE HEADER (two-column layout like reference) ===
668
+ const headerY = doc.y + 10;
669
+ // Left side: Invoice number
670
+ doc.fontSize(14).font('Helvetica-Bold').text(`Factuur ${invoice.invoice_number}`, 50, headerY);
671
+ // Right side: Date and due date
672
+ const issuedDate = invoice.issued_at
673
+ ? new Date(invoice.issued_at).toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' })
674
+ : '-';
675
+ const dueDate = invoice.due_date
676
+ ? new Date(invoice.due_date).toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' })
677
+ : null;
678
+ doc.fontSize(12).font('Helvetica-Bold').text(issuedDate, rightCol, headerY, { width: 230, align: 'right' });
679
+ if (dueDate) {
680
+ doc
681
+ .fontSize(10)
682
+ .font('Helvetica')
683
+ .text(`Vervaldatum: ${dueDate}`, rightCol, headerY + 16, { width: 230, align: 'right' });
684
+ }
685
+ doc.font('Helvetica');
686
+ doc.y = headerY + 30;
687
+ doc.moveDown();
688
+ // === LINE ITEMS TABLE (Dutch law: description, quantity, unit price excl. VAT, VAT rate, total) ===
689
+ const tableTop = doc.y;
690
+ const col1 = 50; // Description
691
+ const col2 = 280; // Qty
692
+ const col3 = 330; // VAT %
693
+ const col4 = 390; // Unit price (excl. VAT)
694
+ const col5 = 480; // Total Ex. VAT
695
+ doc.fontSize(9).font('Helvetica-Bold');
696
+ doc.text('Omschrijving', col1, tableTop);
697
+ doc.text('Aantal', col2, tableTop);
698
+ doc.text('BTW %', col3, tableTop);
699
+ doc.text('Prijs excl.', col4, tableTop);
700
+ doc.text('Totaal excl.', col5, tableTop);
701
+ doc.font('Helvetica');
702
+ doc
703
+ .moveTo(50, tableTop + 14)
704
+ .lineTo(560, tableTop + 14)
705
+ .stroke();
706
+ // Split items: service items go in the main table, subsidy items in a separate section
707
+ const serviceItems = items.filter((it) => (it.item_type ?? 'service') === 'service');
708
+ const subsidyItems = items.filter((it) => it.item_type === 'subsidy');
709
+ // Pen helper: tracks vertical cursor, writes text and advances by gap, draws rules
710
+ const P = {
711
+ y: tableTop + 20,
712
+ skip(n) {
713
+ this.y += n;
714
+ return this;
715
+ },
716
+ text(text, x, opts, gap = 12) {
717
+ doc.text(text, x, this.y, opts ?? {});
718
+ this.y += gap;
719
+ return this;
720
+ },
721
+ row(label, labelX, value, valueX, gap = 14) {
722
+ doc.text(label, labelX, this.y);
723
+ doc.text(value, valueX, this.y);
724
+ this.y += gap;
725
+ return this;
726
+ },
727
+ rule(x1 = 50, x2 = 560) {
728
+ doc.moveTo(x1, this.y).lineTo(x2, this.y).stroke();
729
+ return this;
730
+ },
731
+ dashedRule(x1, x2) {
732
+ doc.moveTo(x1, this.y).lineTo(x2, this.y).lineWidth(0.5).dash(3, { space: 3 }).stroke();
733
+ doc.undash().lineWidth(1);
734
+ return this;
735
+ },
736
+ bold() {
737
+ doc.font('Helvetica-Bold');
738
+ return this;
739
+ },
740
+ normal() {
741
+ doc.font('Helvetica');
742
+ return this;
743
+ },
744
+ size(n) {
745
+ doc.fontSize(n);
746
+ return this;
747
+ },
748
+ };
749
+ let subtotal = 0;
750
+ const vatByRate = {};
751
+ for (const item of serviceItems) {
752
+ const lineSubtotal = Number(item.total_ex_vat ?? 0);
753
+ const lineTotal = Number(item.total_inc_vat ?? 0);
754
+ const vatRate = Number(item.vat_rate);
755
+ subtotal += lineSubtotal;
756
+ if (!vatByRate[vatRate]) vatByRate[vatRate] = { base: 0, vat: 0 };
757
+ vatByRate[vatRate].base += lineSubtotal;
758
+ vatByRate[vatRate].vat += lineTotal - lineSubtotal;
759
+ P.size(9);
760
+ doc.text(item.description, col1, P.y, { width: 225 });
761
+ doc.text(String(item.quantity), col2, P.y);
762
+ doc.text(`${vatRate.toFixed(0)}%`, col3, P.y);
763
+ doc.text(this.formatMoney(Number(item.unit_price), invoice.currency), col4, P.y);
764
+ doc.text(this.formatMoney(lineSubtotal, invoice.currency), col5, P.y);
765
+ P.skip(16);
766
+ }
767
+ P.rule().skip(10);
768
+ // === TOTALS (Dutch law: subtotal, VAT breakdown per rate, subtotal incl. VAT) ===
769
+ P.size(10);
770
+ P.row('Subtotaal excl. BTW:', 370, this.formatMoney(subtotal, invoice.currency), col5);
771
+ let totalVat = 0;
772
+ for (const [rate, { vat }] of Object.entries(vatByRate)) {
773
+ totalVat += vat;
774
+ P.row(`BTW ${rate}%`, 370, this.formatMoney(vat, invoice.currency), col5);
775
+ }
776
+ const serviceTotalIncVat = subtotal + totalVat;
777
+ // === SUBSIDY SECTION (shown before grand total when subsidies are present) ===
778
+ let subsidyTotal = 0;
779
+ if (subsidyItems.length > 0) {
780
+ P.normal().size(10);
781
+ P.row('Subtotaal incl. BTW:', 370, this.formatMoney(serviceTotalIncVat, invoice.currency), col5)
782
+ .skip(4)
783
+ .dashedRule(370, 560)
784
+ .skip(8);
785
+ P.bold().size(9);
786
+ P.text('Subsidie', 50, {}, 12);
787
+ P.normal().size(9);
788
+ for (const item of subsidyItems) {
789
+ const subsidyAmount = Number(item.total_inc_vat ?? 0); // negative
790
+ subsidyTotal += subsidyAmount;
791
+ doc.text(item.description, col1, P.y, { width: 400 });
792
+ doc.text(`− ${this.formatMoney(Math.abs(subsidyAmount), invoice.currency)}`, col5, P.y);
793
+ P.skip(14);
794
+ }
795
+ P.rule(370, 560).skip(8);
796
+ }
797
+ const grandTotal = serviceTotalIncVat + subsidyTotal; // subsidyTotal is negative
798
+ P.bold();
799
+ P.row('Totaal incl. BTW:', 370, this.formatMoney(grandTotal, invoice.currency), col5, 20);
800
+ P.normal();
801
+ // === DESCRIPTION ===
802
+ if (invoice.description) {
803
+ P.size(10).bold();
804
+ P.skip(10).text('Omschrijving:', 50, {}, 12);
805
+ P.normal();
806
+ P.text(invoice.description, 50, {}, 12);
807
+ }
808
+ // === PAYMENT TERMS (Dutch law recommended) ===
809
+ if (invoice.payment_terms) {
810
+ P.size(10).bold();
811
+ P.skip(10).text('Betalingsvoorwaarden:', 50, {}, 12);
812
+ P.normal();
813
+ P.text(invoice.payment_terms, 50, {}, 12);
814
+ }
815
+ // === BANK DETAILS FOR PAYMENT ===
816
+ P.size(10).bold();
817
+ P.skip(10).text('Betalingsgegevens:', 50, {}, 14);
818
+ P.normal();
819
+ P.text(`IBAN: ${cfg.company.iban}`, 50);
820
+ if (cfg.company.bankName) P.text(`Bank: ${cfg.company.bankName}`, 50);
821
+ P.text(`T.n.v.: ${cfg.company.company}`, 50);
822
+ P.text(`O.v.v.: ${invoice.invoice_number}`, 50);
823
+ doc.end();
824
+ return await done;
825
+ }
826
+ async sendInvoiceEmail(invoiceId, recipient, opt) {
827
+ const { invoice, customer } = await this.getInvoiceBundle(invoiceId);
828
+ const mode = opt?.mode ?? 'invoice';
829
+ const isReceipt = mode === 'receipt';
830
+ const pay = isReceipt ? null : await this.ensurePayLink(invoiceId);
831
+ const pdfBuffer = await this.generateInvoicePdfBuffer(invoiceId);
832
+ const to = recipient ?? customer.email;
833
+ const cfg = this.opt.siteConfig;
834
+ const subject = isReceipt ? `Betalingsbevestiging ${invoice.invoice_number}` : `Factuur ${invoice.invoice_number}`;
835
+ const title = isReceipt ? `Betaling ontvangen ${invoice.invoice_number}` : `Factuur ${invoice.invoice_number}`;
836
+ const content = isReceipt
837
+ ? `<br>Beste ${customer.name},<br><br>We hebben uw betaling ontvangen. De factuur vindt u in de bijlage.<br><br>Dank u wel, ${cfg.company.website}<br>`
838
+ : `<br>Beste ${customer.name},<br><br>U vindt uw factuur in de bijlage.<br><br><a href="${pay?.payUrl}" style="display:inline-block;padding:10px 16px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:6px;">Betaal nu</a><br><br>Dank u wel, ${cfg.company.website}<br>`;
839
+ await this.mailService.sendAdvanced({
840
+ from: `${cfg.company.website} <${cfg.contact.contact}>`,
841
+ recipient: to,
842
+ subject,
843
+ template: 'template.html',
844
+ inject: {
845
+ title,
846
+ content,
847
+ logoSrc: `${cfg.company.website}/img/logo-small.png`,
848
+ baseUrl: cfg.company.website,
849
+ },
850
+ attachments: [
851
+ {
852
+ filename: `factuur-${invoice.invoice_number}.pdf`,
853
+ content: pdfBuffer,
854
+ contentType: 'application/pdf',
855
+ },
856
+ ],
857
+ });
858
+ return {
859
+ invoice_number: invoice.invoice_number,
860
+ recipient: to,
861
+ payUrl: pay?.payUrl ?? null,
862
+ payTokenExpiresAt: pay?.expiresAt ?? null,
863
+ };
864
+ }
865
+ }
866
+ exports.InvoiceService = InvoiceService;