@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
@@ -68,7 +68,7 @@
68
68
  "billing.accounts.form.customer_kind": "Typ klienta",
69
69
  "billing.accounts.form.cycle_anchor": "Zakotwiczenie cyklu",
70
70
  "billing.accounts.form.cycle_anchor.month_hint": "1 — 28",
71
- "billing.accounts.form.cycle_anchor.weekly_hint": "pon | wt | … | nd",
71
+ "billing.accounts.form.cycle_anchor.weekly_hint": "mon | tue | … | sun",
72
72
  "billing.accounts.form.error.address_required": "Adres na fakturze (linia 1 + miasto) jest wymagany",
73
73
  "billing.accounts.form.error.country_invalid": "Kraj musi być 2- lub 3-literowym kodem ISO 3166",
74
74
  "billing.accounts.form.error.currency_invalid": "Waluta musi być 3-literowym kodem ISO",
@@ -4,7 +4,7 @@ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import type { SendEmailOptions } from '@open-mercato/shared/lib/email/send'
5
5
  import { BillingAccount, BillingInvoice } from '../data/entities'
6
6
  import { emitBillingEvent } from '../events'
7
- import { DEFAULT_INVOICE_LANGUAGE } from './invoiceLanguages'
7
+ import { resolveInvoiceLanguage } from './invoiceLanguages'
8
8
  import { formatIban } from './iban'
9
9
  import { documentLocale, formatDocumentDate, formatDocumentMoney, type DocumentTranslate } from './pdf/invoiceModel'
10
10
  import { getInvoicePdf, loadDocumentTranslator, type TranslatorLoader } from './pdf/invoicePdf'
@@ -148,10 +148,9 @@ export async function sendInvoice(
148
148
  })
149
149
  }
150
150
 
151
- const language =
152
- (typeof buyer.invoice_language === 'string' && buyer.invoice_language) ||
153
- account?.invoiceLanguage ||
154
- DEFAULT_INVOICE_LANGUAGE
151
+ const language = resolveInvoiceLanguage(
152
+ (typeof buyer.invoice_language === 'string' && buyer.invoice_language) || account?.invoiceLanguage,
153
+ )
155
154
  const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language)
156
155
  const seller = (invoice.sellerSnapshot ?? {}) as Record<string, unknown>
157
156
  const email = buildInvoiceEmail({
@@ -1,24 +1,35 @@
1
+ import type { Locale } from '@open-mercato/shared/lib/i18n/config'
2
+
1
3
  /**
2
4
  * Invoice languages the built-in billing document template can render.
3
5
  *
4
- * Single source of truth for the account form's language picklist and the
5
- * `invoiceLanguage` validator. Adding a language means adding the template
6
- * labels for it AND one entry here — never one without the other.
6
+ * Single source of truth for the account form's language picklist, the
7
+ * `invoiceLanguage` validator, the `Intl` locale used for money / dates on
8
+ * documents and emails, and the dictionary the document translator loads.
9
+ * Adding a language means adding `i18n/<code>.json` AND one entry here — the
10
+ * dictionary-parity test fails if either is missing.
11
+ *
12
+ * Every `code` must be a UI locale the framework ships (`Locale`), because the
13
+ * document translator loads the regular module dictionary for it. The
14
+ * `satisfies` clause below turns a code the framework does not ship into a
15
+ * compile error.
7
16
  *
8
17
  * `label` is the language's own name (endonym), so it reads correctly no
9
18
  * matter which UI locale the operator uses — it is intentionally not routed
10
- * through i18n.
19
+ * through i18n. Order: default first, then product priority.
11
20
  *
12
- * Spec: .ai/specs/2026-11-14-billing-account-workspace.md (D22). Narrowing the
13
- * list per organization (`BillingSettings.enabled_invoice_languages`) arrives
14
- * with the standalone-ledger spec; until then every supported language is
15
- * selectable.
21
+ * Specs: .ai/specs/2026-11-14-billing-account-workspace.md (D22),
22
+ * .ai/specs/2026-11-15-billing-multi-language.md. Narrowing the list per
23
+ * organization (`BillingSettings.enabled_invoice_languages`) arrives with the
24
+ * standalone-ledger spec; until then every supported language is selectable.
16
25
  */
17
26
 
18
27
  export const SUPPORTED_INVOICE_LANGUAGES = [
19
- { code: 'en', label: 'English' },
20
- { code: 'pl', label: 'Polski' },
21
- ] as const
28
+ { code: 'en', label: 'English', intlLocale: 'en-GB' },
29
+ { code: 'es', label: 'Español', intlLocale: 'es-ES' },
30
+ { code: 'de', label: 'Deutsch', intlLocale: 'de-DE' },
31
+ { code: 'pl', label: 'Polski', intlLocale: 'pl-PL' },
32
+ ] as const satisfies ReadonlyArray<{ code: Locale; label: string; intlLocale: string }>
22
33
 
23
34
  export type InvoiceLanguageCode = (typeof SUPPORTED_INVOICE_LANGUAGES)[number]['code']
24
35
 
@@ -32,3 +43,15 @@ export const DEFAULT_INVOICE_LANGUAGE: InvoiceLanguageCode = 'en'
32
43
  export function isSupportedInvoiceLanguage(value: string): value is InvoiceLanguageCode {
33
44
  return (INVOICE_LANGUAGE_CODES as readonly string[]).includes(value)
34
45
  }
46
+
47
+ /** Normalizes a stored / requested language to a supported code (`' PL '` → `pl`), else the default. */
48
+ export function resolveInvoiceLanguage(value: string | null | undefined): InvoiceLanguageCode {
49
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
50
+ return isSupportedInvoiceLanguage(normalized) ? normalized : DEFAULT_INVOICE_LANGUAGE
51
+ }
52
+
53
+ /** The `Intl` locale documents in this language format money and dates with. */
54
+ export function invoiceIntlLocale(language: string | null | undefined): string {
55
+ const code = resolveInvoiceLanguage(language)
56
+ return SUPPORTED_INVOICE_LANGUAGES.find((l) => l.code === code)?.intlLocale ?? 'en-GB'
57
+ }
@@ -12,7 +12,9 @@
12
12
  * "corrects" row is added to the header.
13
13
  */
14
14
 
15
- export type DocumentTranslate = (key: string, fallback: string) => string
15
+ import { invoiceIntlLocale } from '../invoiceLanguages'
16
+
17
+ export type DocumentTranslate =(key: string, fallback: string) => string
16
18
 
17
19
  export type PdfAddress = {
18
20
  line1?: string | null
@@ -91,10 +93,9 @@ export type InvoiceDocumentModel = {
91
93
  pageLabel: (page: number, pages: number) => string
92
94
  }
93
95
 
94
- const LOCALE_BY_LANGUAGE: Record<string, string> = { en: 'en-GB', pl: 'pl-PL' }
95
-
96
+ /** `Intl` locale for a document language — derived from the language registry. */
96
97
  export function documentLocale(language: string): string {
97
- return LOCALE_BY_LANGUAGE[language] ?? 'en-GB'
98
+ return invoiceIntlLocale(language)
98
99
  }
99
100
 
100
101
  function toDate(value: Date | string | null | undefined): Date | null {
@@ -3,7 +3,7 @@ import { BillingAccount, BillingInvoice, BillingInvoiceLine } from '../../data/e
3
3
  import { billingEntityIds } from '../../data/entityIds'
4
4
  import { buildSellerSnapshot, getBillingSettings } from '../billingSettings'
5
5
  import { buildBuyerSnapshot } from '../documentParties'
6
- import { DEFAULT_INVOICE_LANGUAGE } from '../invoiceLanguages'
6
+ import { resolveInvoiceLanguage } from '../invoiceLanguages'
7
7
  import { buildInvoiceDocumentModel, type DocumentTranslate, type PdfAddress } from './invoiceModel'
8
8
  import { renderInvoicePdf, type PdfLogo } from './invoiceTemplate'
9
9
  import { attachmentIdFromUrl, readAttachment, storeAttachment } from './attachmentStore'
@@ -25,11 +25,15 @@ import { attachmentIdFromUrl, readAttachment, storeAttachment } from './attachme
25
25
 
26
26
  export type TranslatorLoader = (language: string) => Promise<DocumentTranslate>
27
27
 
28
- /** Billing i18n for a document language (en / pl, falling back to en). */
28
+ /**
29
+ * Billing i18n for a document language. Any supported invoice language loads
30
+ * its own dictionary (registry in `../invoiceLanguages`); anything else falls
31
+ * back to the default language.
32
+ */
29
33
  export const loadDocumentTranslator: TranslatorLoader = async (language) => {
30
34
  const { loadDictionary } = await import('@open-mercato/shared/lib/i18n/server')
31
35
  const { createFallbackTranslator } = await import('@open-mercato/shared/lib/i18n/translate')
32
- const locale = language === 'pl' ? 'pl' : 'en'
36
+ const locale = resolveInvoiceLanguage(language)
33
37
  const translate = createFallbackTranslator(await loadDictionary(locale))
34
38
  return (key, fallback) => translate(key, fallback)
35
39
  }
@@ -93,7 +97,7 @@ export async function renderBillingDocument(
93
97
  { orderBy: { lineNumber: 'asc' } as never },
94
98
  )
95
99
  const { seller, buyer } = await resolveParties(em, invoice)
96
- const language = str(buyer.invoice_language) ?? DEFAULT_INVOICE_LANGUAGE
100
+ const language = resolveInvoiceLanguage(str(buyer.invoice_language))
97
101
  const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language)
98
102
 
99
103
  let correctsNumber: string | null = null
@@ -27,7 +27,7 @@ const SMALL_SIZE = 8
27
27
  const LINE_GAP = 3
28
28
 
29
29
  // # | description | qty | unit net | VAT % | net | VAT | gross (sums to CONTENT_WIDTH)
30
- const TABLE_COLUMNS = [24, 179, 30, 62, 36, 62, 56, 66.28]
30
+ export const TABLE_COLUMNS = [24, 179, 30, 62, 36, 62, 56, 66.28]
31
31
 
32
32
  const TEXT = rgb(0.12, 0.12, 0.14)
33
33
  const MUTED = rgb(0.42, 0.42, 0.46)
@@ -51,7 +51,7 @@ export function sanitizeForFont(text: string, charset: Set<number>): string {
51
51
  return out
52
52
  }
53
53
 
54
- function wrap(text: string, font: PDFFont, size: number, width: number): string[] {
54
+ export function wrap(text: string, font: PDFFont, size: number, width: number): string[] {
55
55
  const lines: string[] = []
56
56
  for (const paragraph of text.split('\n')) {
57
57
  let current = ''