@dainamite/billing 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/modules/billing/api/accounts/route.js +2 -1
- package/dist/modules/billing/api/accounts/route.js.map +2 -2
- package/dist/modules/billing/api/invoices/[id]/pdf/route.js +2 -1
- package/dist/modules/billing/api/invoices/[id]/pdf/route.js.map +2 -2
- package/dist/modules/billing/data/entities.js.map +2 -2
- package/dist/modules/billing/i18n/de.json +880 -0
- package/dist/modules/billing/i18n/es.json +880 -0
- package/dist/modules/billing/i18n/pl.json +1 -1
- package/dist/modules/billing/lib/invoiceDelivery.js +4 -2
- package/dist/modules/billing/lib/invoiceDelivery.js.map +2 -2
- package/dist/modules/billing/lib/invoiceLanguages.js +15 -3
- package/dist/modules/billing/lib/invoiceLanguages.js.map +2 -2
- package/dist/modules/billing/lib/pdf/invoiceModel.js +2 -2
- package/dist/modules/billing/lib/pdf/invoiceModel.js.map +2 -2
- package/dist/modules/billing/lib/pdf/invoicePdf.js +3 -3
- package/dist/modules/billing/lib/pdf/invoicePdf.js.map +2 -2
- package/dist/modules/billing/lib/pdf/invoiceTemplate.js +3 -1
- package/dist/modules/billing/lib/pdf/invoiceTemplate.js.map +2 -2
- package/package.json +1 -1
- package/src/modules/billing/__integration__/TC-BILL-004.spec.ts +2 -1
- package/src/modules/billing/__tests__/i18n/dictionaries.test.ts +64 -0
- package/src/modules/billing/__tests__/lib/invoiceDelivery.test.ts +17 -0
- package/src/modules/billing/__tests__/lib/invoiceLanguages.test.ts +49 -0
- package/src/modules/billing/__tests__/lib/pdf/invoicePdf.test.ts +75 -5
- package/src/modules/billing/__tests__/validators.test.ts +5 -1
- package/src/modules/billing/api/accounts/route.ts +2 -1
- package/src/modules/billing/api/invoices/[id]/pdf/route.ts +2 -1
- package/src/modules/billing/data/entities.ts +2 -1
- package/src/modules/billing/i18n/de.json +880 -0
- package/src/modules/billing/i18n/es.json +880 -0
- package/src/modules/billing/i18n/pl.json +1 -1
- package/src/modules/billing/lib/invoiceDelivery.ts +4 -5
- package/src/modules/billing/lib/invoiceLanguages.ts +34 -11
- package/src/modules/billing/lib/pdf/invoiceModel.ts +5 -4
- package/src/modules/billing/lib/pdf/invoicePdf.ts +8 -4
- package/src/modules/billing/lib/pdf/invoiceTemplate.ts +2 -2
|
@@ -97,6 +97,23 @@ describe('sendInvoice', () => {
|
|
|
97
97
|
spy.mockRestore()
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
it('loads the translator for the resolved document language (snapshot first, then account, else default)', async () => {
|
|
101
|
+
const spyLoader = jest.fn(async (_language: string) => (_key: string, fallback: string) => fallback)
|
|
102
|
+
const sendEmail = jest.fn(async () => undefined)
|
|
103
|
+
const run = async (snapshotLanguage: unknown, accountLanguage: string) => {
|
|
104
|
+
spyLoader.mockClear()
|
|
105
|
+
const { em } = makeEm(
|
|
106
|
+
invoiceRow({ buyerSnapshot: { name: 'Jan', invoice_language: snapshotLanguage } }),
|
|
107
|
+
{ invoiceLanguage: accountLanguage },
|
|
108
|
+
)
|
|
109
|
+
await sendInvoice(em as never, { invoiceId: 'inv-1', ...scope, trigger: 'manual' }, { sendEmail, loadTranslator: spyLoader, now })
|
|
110
|
+
return spyLoader.mock.calls[0]?.[0]
|
|
111
|
+
}
|
|
112
|
+
expect(await run(' PL ', 'en')).toBe('pl')
|
|
113
|
+
expect(await run(undefined, 'pl')).toBe('pl')
|
|
114
|
+
expect(await run('xx', 'pl')).toBe('en')
|
|
115
|
+
})
|
|
116
|
+
|
|
100
117
|
it('refuses drafts and test documents with 409 not_posted', async () => {
|
|
101
118
|
for (const overrides of [{ status: 'draft' }, { testRun: true }]) {
|
|
102
119
|
const { em } = makeEm(invoiceRow(overrides))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_INVOICE_LANGUAGE,
|
|
3
|
+
INVOICE_LANGUAGE_CODES,
|
|
4
|
+
SUPPORTED_INVOICE_LANGUAGES,
|
|
5
|
+
invoiceIntlLocale,
|
|
6
|
+
isSupportedInvoiceLanguage,
|
|
7
|
+
resolveInvoiceLanguage,
|
|
8
|
+
} from '../../lib/invoiceLanguages'
|
|
9
|
+
import { documentLocale } from '../../lib/pdf/invoiceModel'
|
|
10
|
+
|
|
11
|
+
describe('invoice language registry', () => {
|
|
12
|
+
it('lists the default first and derives the code list from the registry', () => {
|
|
13
|
+
expect(SUPPORTED_INVOICE_LANGUAGES[0].code).toBe(DEFAULT_INVOICE_LANGUAGE)
|
|
14
|
+
expect(INVOICE_LANGUAGE_CODES).toEqual(SUPPORTED_INVOICE_LANGUAGES.map((l) => l.code))
|
|
15
|
+
expect(new Set(INVOICE_LANGUAGE_CODES).size).toBe(INVOICE_LANGUAGE_CODES.length)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('gives every language an endonym label and a valid Intl locale', () => {
|
|
19
|
+
for (const language of SUPPORTED_INVOICE_LANGUAGES) {
|
|
20
|
+
expect(language.label.trim()).not.toBe('')
|
|
21
|
+
expect(Intl.DateTimeFormat.supportedLocalesOf([language.intlLocale])).toEqual([language.intlLocale])
|
|
22
|
+
expect(language.intlLocale.toLowerCase().startsWith(`${language.code}-`)).toBe(true)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('resolves stored values: normalizes case / whitespace, falls back to the default', () => {
|
|
27
|
+
expect(resolveInvoiceLanguage(' PL ')).toBe('pl')
|
|
28
|
+
expect(resolveInvoiceLanguage('en')).toBe('en')
|
|
29
|
+
expect(resolveInvoiceLanguage('xx')).toBe(DEFAULT_INVOICE_LANGUAGE)
|
|
30
|
+
expect(resolveInvoiceLanguage('')).toBe(DEFAULT_INVOICE_LANGUAGE)
|
|
31
|
+
expect(resolveInvoiceLanguage(null)).toBe(DEFAULT_INVOICE_LANGUAGE)
|
|
32
|
+
expect(resolveInvoiceLanguage(undefined)).toBe(DEFAULT_INVOICE_LANGUAGE)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('checks support strictly (no normalization)', () => {
|
|
36
|
+
expect(isSupportedInvoiceLanguage('pl')).toBe(true)
|
|
37
|
+
expect(isSupportedInvoiceLanguage('PL')).toBe(false)
|
|
38
|
+
expect(isSupportedInvoiceLanguage('xx')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('maps languages to their Intl locale; documentLocale delegates to the registry', () => {
|
|
42
|
+
for (const language of SUPPORTED_INVOICE_LANGUAGES) {
|
|
43
|
+
expect(invoiceIntlLocale(language.code)).toBe(language.intlLocale)
|
|
44
|
+
expect(documentLocale(language.code)).toBe(language.intlLocale)
|
|
45
|
+
}
|
|
46
|
+
expect(invoiceIntlLocale('xx')).toBe('en-GB')
|
|
47
|
+
expect(documentLocale('xx')).toBe('en-GB')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -5,9 +5,14 @@ import {
|
|
|
5
5
|
type PdfInvoice,
|
|
6
6
|
type PdfLine,
|
|
7
7
|
} from '../../../lib/pdf/invoiceModel'
|
|
8
|
-
import
|
|
8
|
+
import fontkit from '@pdf-lib/fontkit'
|
|
9
|
+
import { TABLE_COLUMNS, renderInvoicePdf, sanitizeForFont, wrap } from '../../../lib/pdf/invoiceTemplate'
|
|
10
|
+
import { NOTO_SANS_BOLD_BASE64 } from '../../../lib/pdf/fonts/embeddedFonts'
|
|
9
11
|
import en from '../../../i18n/en.json'
|
|
10
12
|
import pl from '../../../i18n/pl.json'
|
|
13
|
+
import es from '../../../i18n/es.json'
|
|
14
|
+
import de from '../../../i18n/de.json'
|
|
15
|
+
import type { InvoiceLanguageCode } from '../../../lib/invoiceLanguages'
|
|
11
16
|
|
|
12
17
|
const translator = (dict: Record<string, string>): DocumentTranslate => (key, fallback) => dict[key] ?? fallback
|
|
13
18
|
|
|
@@ -58,14 +63,16 @@ const seller = {
|
|
|
58
63
|
|
|
59
64
|
const buyer = { name: 'Jan Kowalski', address: { line1: 'ul. Testowa 1', city: 'Warszawa', postal_code: '00-001', country: 'PL' }, taxId: null }
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
const DICTIONARIES: Record<InvoiceLanguageCode, Record<string, string>> = { en, pl, es, de }
|
|
67
|
+
|
|
68
|
+
function model(language: InvoiceLanguageCode, overrides: Partial<PdfInvoice> = {}, docLines: PdfLine[] = lines) {
|
|
62
69
|
return buildInvoiceDocumentModel({
|
|
63
70
|
invoice: { ...invoice, ...overrides },
|
|
64
71
|
lines: docLines,
|
|
65
72
|
seller,
|
|
66
73
|
buyer,
|
|
67
74
|
language,
|
|
68
|
-
translate: translator(language
|
|
75
|
+
translate: translator(DICTIONARIES[language]),
|
|
69
76
|
})
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -96,6 +103,43 @@ describe('buildInvoiceDocumentModel', () => {
|
|
|
96
103
|
expect(m.seller.heading).toBe('Sprzedawca')
|
|
97
104
|
})
|
|
98
105
|
|
|
106
|
+
it('builds Spanish labels and es-ES money / dates (invoice and credit memo)', () => {
|
|
107
|
+
const m = model('es', { currencyCode: 'EUR', totalGross: '1234.5600' })
|
|
108
|
+
expect(m.title).toBe('Factura')
|
|
109
|
+
expect(m.meta).toEqual(expect.arrayContaining([['Fecha de emisión', '14/11/2026']]))
|
|
110
|
+
expect(m.table.headers).toEqual(expect.arrayContaining(['Cant.', 'Precio unit.', '% IVA', 'IVA']))
|
|
111
|
+
expect(m.totals[0][0]).toBe('Base imponible')
|
|
112
|
+
expect(m.totals[2]).toEqual(['Total a pagar', '1234,56 €'])
|
|
113
|
+
// The payment matcher looks for the invoice number in the transfer title.
|
|
114
|
+
expect(m.payment?.lines).toContain('Concepto: INV/2026/11/0007')
|
|
115
|
+
expect(m.seller.heading).toBe('Emisor')
|
|
116
|
+
expect(m.buyer.heading).toBe('Cliente')
|
|
117
|
+
expect(m.vatSummary.heading).toBe('Resumen de IVA')
|
|
118
|
+
expect(m.pageLabel(1, 2)).not.toContain('{')
|
|
119
|
+
const memo = model('es', { documentType: 'credit_memo', correctsNumber: 'INV/2026/10/0003', correctsIssueDate: '2026-10-05' })
|
|
120
|
+
expect(memo.title).toBe('Factura rectificativa')
|
|
121
|
+
expect(memo.meta).toEqual(expect.arrayContaining([['Rectifica', 'INV/2026/10/0003 de 05/10/2026']]))
|
|
122
|
+
expect(memo.totals[2][0]).toBe('Total abonado')
|
|
123
|
+
expect(memo.payment?.lines.join(' ')).toContain('INV/2026/10/0003')
|
|
124
|
+
expect(model('es', { status: 'draft' }).watermark).toBe('BORRADOR')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('builds German labels and de-DE money / dates (invoice and credit memo)', () => {
|
|
128
|
+
const m = model('de', { currencyCode: 'EUR', totalGross: '1234.5600' })
|
|
129
|
+
expect(m.title).toBe('Rechnung')
|
|
130
|
+
expect(m.meta).toEqual(expect.arrayContaining([['Leistungszeitraum', '01.11.2026 – 30.11.2026']]))
|
|
131
|
+
expect(m.table.headers).toEqual(expect.arrayContaining(['Anz.', 'Einzelpreis netto', 'USt. %', 'USt.']))
|
|
132
|
+
expect(m.totals[2]).toEqual(['Zahlbetrag', '1.234,56 €'])
|
|
133
|
+
expect(m.payment?.lines).toContain('Verwendungszweck: INV/2026/11/0007')
|
|
134
|
+
expect(m.seller.heading).toBe('Rechnungssteller')
|
|
135
|
+
expect(m.buyer.heading).toBe('Rechnungsempfänger')
|
|
136
|
+
const memo = model('de', { documentType: 'credit_memo', correctsNumber: 'INV/2026/10/0003', correctsIssueDate: '2026-10-05' })
|
|
137
|
+
expect(memo.title).toBe('Rechnungskorrektur')
|
|
138
|
+
expect(memo.meta).toEqual(expect.arrayContaining([['Korrigiert', 'INV/2026/10/0003 vom 05.10.2026']]))
|
|
139
|
+
expect(memo.totals[2][0]).toBe('Gutgeschriebener Betrag')
|
|
140
|
+
expect(model('de', { status: 'draft' }).watermark).toBe('ENTWURF')
|
|
141
|
+
})
|
|
142
|
+
|
|
99
143
|
it('groups the VAT summary by rate (highest first) and it adds up to the totals', () => {
|
|
100
144
|
const m = model('en')
|
|
101
145
|
expect(m.vatSummary.rows.map((r) => r[0])).toEqual(['23 %', '8 %', '0 %'])
|
|
@@ -149,8 +193,8 @@ describe('buildInvoiceDocumentModel', () => {
|
|
|
149
193
|
|
|
150
194
|
it('splits the footer into lines and falls back to English for unknown languages', () => {
|
|
151
195
|
expect(model('en').footer).toEqual(['KRS 0000000000', 'Kapitał zakładowy 5 000 zł'])
|
|
152
|
-
const
|
|
153
|
-
expect(
|
|
196
|
+
const unknown = buildInvoiceDocumentModel({ invoice, lines, seller, buyer, language: 'xx', translate: translator({}) })
|
|
197
|
+
expect(unknown.meta[1]).toEqual(['Issue date', '14/11/2026'])
|
|
154
198
|
})
|
|
155
199
|
})
|
|
156
200
|
|
|
@@ -178,6 +222,32 @@ describe('renderInvoicePdf', () => {
|
|
|
178
222
|
expect(await pages(80)).toBe(3)
|
|
179
223
|
}, 30_000)
|
|
180
224
|
|
|
225
|
+
it.each(['es', 'de'] as const)('renders a one-page %s document with its own title and diacritics', async (language) => {
|
|
226
|
+
const m = model(language, { currencyCode: 'EUR' })
|
|
227
|
+
const doc = await PDFDocument.load(await renderInvoicePdf(m))
|
|
228
|
+
expect(doc.getPageCount()).toBe(1)
|
|
229
|
+
expect(doc.getTitle()).toBe(`${m.title} INV/2026/11/0007`)
|
|
230
|
+
}, 30_000)
|
|
231
|
+
|
|
232
|
+
it.each(['en', 'es', 'de', 'pl'] as const)(
|
|
233
|
+
'fits %s column headers in their columns with the real font (no mid-word split, at most 3 lines)',
|
|
234
|
+
async (language) => {
|
|
235
|
+
const doc = await PDFDocument.create()
|
|
236
|
+
doc.registerFontkit(fontkit)
|
|
237
|
+
const bold = await doc.embedFont(Buffer.from(NOTO_SANS_BOLD_BASE64, 'base64'), { subset: true })
|
|
238
|
+
// Same size / padding the template uses for table headers (SMALL_SIZE = 8, 3pt each side).
|
|
239
|
+
model(language).table.headers.forEach((header, i) => {
|
|
240
|
+
const width = TABLE_COLUMNS[i] - 6
|
|
241
|
+
const lines = wrap(header, bold, 8, width)
|
|
242
|
+
expect({ header, lines: lines.length }).toEqual({ header, lines: expect.any(Number) })
|
|
243
|
+
expect(lines.length).toBeLessThanOrEqual(3)
|
|
244
|
+
// Every wrapped line is a whole word (or words): no word was cut by characters.
|
|
245
|
+
const words = new Set(header.split(/\s+/))
|
|
246
|
+
for (const part of lines) for (const w of part.split(' ')) expect({ header, word: w, whole: words.has(w) }).toEqual({ header, word: w, whole: true })
|
|
247
|
+
})
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
|
|
181
251
|
it('wraps long descriptions instead of overflowing the column', async () => {
|
|
182
252
|
const long = line({ description: 'Bardzo długi opis usługi '.repeat(12) })
|
|
183
253
|
const bytes = await renderInvoicePdf(model('en', {}, [long]))
|
|
@@ -398,10 +398,14 @@ describe('billingAccountCreateSchema', () => {
|
|
|
398
398
|
|
|
399
399
|
it('rejects an invoice language the template does not support', () => {
|
|
400
400
|
expect(() =>
|
|
401
|
-
billingAccountCreateSchema.parse({ ...validAccountCreate, invoiceLanguage: '
|
|
401
|
+
billingAccountCreateSchema.parse({ ...validAccountCreate, invoiceLanguage: 'fr' }),
|
|
402
402
|
).toThrow()
|
|
403
403
|
})
|
|
404
404
|
|
|
405
|
+
it.each(['en', 'es', 'de', 'pl'])('accepts the supported invoice language %s (case-insensitive)', (code) => {
|
|
406
|
+
expect(billingAccountCreateSchema.parse({ ...validAccountCreate, invoiceLanguage: ` ${code.toUpperCase()} ` }).invoiceLanguage).toBe(code)
|
|
407
|
+
})
|
|
408
|
+
|
|
405
409
|
it('defaults invoiceLanguage when omitted', () => {
|
|
406
410
|
const { invoiceLanguage, ...withoutLanguage } = validAccountCreate
|
|
407
411
|
void invoiceLanguage
|
|
@@ -10,6 +10,7 @@ import { fetchAccountFinancials, fetchOverdueAccountIds, financialsOrZero } from
|
|
|
10
10
|
/** A uuid no row has — narrows an `$in` filter to nothing. */
|
|
11
11
|
const NO_MATCH_ID = '00000000-0000-4000-8000-000000000000'
|
|
12
12
|
import { billingEntityIds } from '../../data/entityIds'
|
|
13
|
+
import { DEFAULT_INVOICE_LANGUAGE, INVOICE_LANGUAGE_CODES } from '../../lib/invoiceLanguages'
|
|
13
14
|
import {
|
|
14
15
|
billingAccountCreateSchema,
|
|
15
16
|
billingAccountListQuerySchema,
|
|
@@ -243,7 +244,7 @@ export const openApi = createBillingCrudOpenApi({
|
|
|
243
244
|
responseSchema: defaultCreateResponseSchema,
|
|
244
245
|
description:
|
|
245
246
|
'Creates a new billing account for a customer — a company or a person (`customerKind`, default `company`). ' +
|
|
246
|
-
|
|
247
|
+
`\`invoiceLanguage\` must be a language supported by the invoice template (${INVOICE_LANGUAGE_CODES.map((c) => `\`${c}\``).join(', ')}; default \`${DEFAULT_INVOICE_LANGUAGE}\`).`,
|
|
247
248
|
},
|
|
248
249
|
update: {
|
|
249
250
|
schema: billingAccountUpdateSchema,
|
|
@@ -4,6 +4,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { BillingInvoice } from '../../../../data/entities'
|
|
6
6
|
import { getInvoicePdf } from '../../../../lib/pdf/invoicePdf'
|
|
7
|
+
import { INVOICE_LANGUAGE_CODES } from '../../../../lib/invoiceLanguages'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `GET /api/billing/invoices/[id]/pdf` — the document PDF (Spec B).
|
|
@@ -71,7 +72,7 @@ export const openApi = {
|
|
|
71
72
|
description:
|
|
72
73
|
'`application/pdf`. Posted documents return the PDF frozen at post (an attachment); drafts ' +
|
|
73
74
|
'and test documents are rendered live with a DRAFT / TEST watermark and never stored. ' +
|
|
74
|
-
|
|
75
|
+
`Labels follow the account invoice language (${INVOICE_LANGUAGE_CODES.join(' / ')}).`,
|
|
75
76
|
responses: [{ status: 200, description: 'PDF document (`application/pdf`)' }],
|
|
76
77
|
errors: [{ status: 404, description: 'Invoice not found in this organization' }],
|
|
77
78
|
},
|
|
@@ -83,7 +83,8 @@ export class BillingAccount {
|
|
|
83
83
|
@Property({ name: 'invoice_email', type: 'text' })
|
|
84
84
|
invoiceEmail!: string
|
|
85
85
|
|
|
86
|
-
// ISO 639-1
|
|
86
|
+
// ISO 639-1; allowed values come from `lib/invoiceLanguages.ts` (Zod-validated,
|
|
87
|
+
// no DB constraint). Used by mailer / template renderer.
|
|
87
88
|
@Property({ name: 'invoice_language', type: 'text' })
|
|
88
89
|
invoiceLanguage!: string
|
|
89
90
|
|