@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/modules/billing/api/accounts/route.js +2 -1
  3. package/dist/modules/billing/api/accounts/route.js.map +2 -2
  4. package/dist/modules/billing/api/invoices/[id]/pdf/route.js +2 -1
  5. package/dist/modules/billing/api/invoices/[id]/pdf/route.js.map +2 -2
  6. package/dist/modules/billing/data/entities.js.map +2 -2
  7. package/dist/modules/billing/i18n/de.json +880 -0
  8. package/dist/modules/billing/i18n/es.json +880 -0
  9. package/dist/modules/billing/i18n/pl.json +1 -1
  10. package/dist/modules/billing/lib/invoiceDelivery.js +4 -2
  11. package/dist/modules/billing/lib/invoiceDelivery.js.map +2 -2
  12. package/dist/modules/billing/lib/invoiceLanguages.js +15 -3
  13. package/dist/modules/billing/lib/invoiceLanguages.js.map +2 -2
  14. package/dist/modules/billing/lib/pdf/invoiceModel.js +2 -2
  15. package/dist/modules/billing/lib/pdf/invoiceModel.js.map +2 -2
  16. package/dist/modules/billing/lib/pdf/invoicePdf.js +3 -3
  17. package/dist/modules/billing/lib/pdf/invoicePdf.js.map +2 -2
  18. package/dist/modules/billing/lib/pdf/invoiceTemplate.js +3 -1
  19. package/dist/modules/billing/lib/pdf/invoiceTemplate.js.map +2 -2
  20. package/package.json +1 -1
  21. package/src/modules/billing/__integration__/TC-BILL-004.spec.ts +2 -1
  22. package/src/modules/billing/__tests__/i18n/dictionaries.test.ts +64 -0
  23. package/src/modules/billing/__tests__/lib/invoiceDelivery.test.ts +17 -0
  24. package/src/modules/billing/__tests__/lib/invoiceLanguages.test.ts +49 -0
  25. package/src/modules/billing/__tests__/lib/pdf/invoicePdf.test.ts +75 -5
  26. package/src/modules/billing/__tests__/validators.test.ts +5 -1
  27. package/src/modules/billing/api/accounts/route.ts +2 -1
  28. package/src/modules/billing/api/invoices/[id]/pdf/route.ts +2 -1
  29. package/src/modules/billing/data/entities.ts +2 -1
  30. package/src/modules/billing/i18n/de.json +880 -0
  31. package/src/modules/billing/i18n/es.json +880 -0
  32. package/src/modules/billing/i18n/pl.json +1 -1
  33. package/src/modules/billing/lib/invoiceDelivery.ts +4 -5
  34. package/src/modules/billing/lib/invoiceLanguages.ts +34 -11
  35. package/src/modules/billing/lib/pdf/invoiceModel.ts +5 -4
  36. package/src/modules/billing/lib/pdf/invoicePdf.ts +8 -4
  37. 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 { renderInvoicePdf, sanitizeForFont } from '../../../lib/pdf/invoiceTemplate'
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
- function model(language: 'en' | 'pl', overrides: Partial<PdfInvoice> = {}, docLines: PdfLine[] = lines) {
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 === 'pl' ? (pl as Record<string, string>) : (en as Record<string, string>)),
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 de = buildInvoiceDocumentModel({ invoice, lines, seller, buyer, language: 'de', translate: translator({}) })
153
- expect(de.meta[1]).toEqual(['Issue date', '14/11/2026'])
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: 'de' }),
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
- '`invoiceLanguage` must be a language supported by the invoice template (`en`, `pl`; default `en`).',
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
- 'Labels follow the account invoice language (en / pl).',
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 (`en`, `pl`, …). Used by mailer / template renderer.
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