@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",
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
3
3
  import { BillingAccount, BillingInvoice } from "../data/entities.js";
4
4
  import { emitBillingEvent } from "../events.js";
5
- import { DEFAULT_INVOICE_LANGUAGE } from "./invoiceLanguages.js";
5
+ import { resolveInvoiceLanguage } from "./invoiceLanguages.js";
6
6
  import { formatIban } from "./iban.js";
7
7
  import { documentLocale, formatDocumentDate, formatDocumentMoney } from "./pdf/invoiceModel.js";
8
8
  import { getInvoicePdf, loadDocumentTranslator } from "./pdf/invoicePdf.js";
@@ -80,7 +80,9 @@ async function sendInvoice(em, params, deps = {}) {
80
80
  code: "billing.invoice.email_invalid"
81
81
  });
82
82
  }
83
- const language = typeof buyer.invoice_language === "string" && buyer.invoice_language || account?.invoiceLanguage || DEFAULT_INVOICE_LANGUAGE;
83
+ const language = resolveInvoiceLanguage(
84
+ typeof buyer.invoice_language === "string" && buyer.invoice_language || account?.invoiceLanguage
85
+ );
84
86
  const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language);
85
87
  const seller = invoice.sellerSnapshot ?? {};
86
88
  const email = buildInvoiceEmail({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/lib/invoiceDelivery.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { SendEmailOptions } from '@open-mercato/shared/lib/email/send'\nimport { BillingAccount, BillingInvoice } from '../data/entities'\nimport { emitBillingEvent } from '../events'\nimport { DEFAULT_INVOICE_LANGUAGE } from './invoiceLanguages'\nimport { formatIban } from './iban'\nimport { documentLocale, formatDocumentDate, formatDocumentMoney, type DocumentTranslate } from './pdf/invoiceModel'\nimport { getInvoicePdf, loadDocumentTranslator, type TranslatorLoader } from './pdf/invoicePdf'\n\n/**\n * Emails a posted billing document to the account's invoice address with\n * the frozen PDF attached (Spec B), through the platform's standard\n * `sendEmail()` \u2014 no custom transport.\n *\n * - posted (non-draft, non-test) documents only \u2192 409 `billing.invoice.not_posted`;\n * - the connector's placeholder addresses (`@invalid.local`) and malformed\n * emails are refused \u2192 422 `billing.invoice.email_invalid`, nothing sent;\n * - every attempt appends `{ at, to, status, error?, trigger }` to\n * `metadata.deliveries` (atomic jsonb append \u2014 concurrent sends never\n * lose an entry) and emits `billing.invoice.sent` / `send_failed`;\n * - transport errors (e.g. `EMAIL_TRANSPORT_NOT_CONFIGURED`) are recorded\n * as a `failed` delivery and returned, never thrown \u2014 posting is\n * unaffected and the operator sees the reason.\n */\n\nexport type DeliveryTrigger = 'manual' | 'auto'\n\nexport type InvoiceDelivery = {\n at: string\n to: string\n status: 'sent' | 'failed'\n error?: string | null\n trigger: DeliveryTrigger\n}\n\nexport type SendInvoiceResult = { invoiceId: string; to: string; status: 'sent' | 'failed'; error: string | null }\n\nexport type SendEmailFn = (options: SendEmailOptions) => Promise<void>\n\nexport type SendInvoiceDeps = {\n sendEmail?: SendEmailFn\n loadTranslator?: TranslatorLoader\n now?: () => Date\n}\n\nconst PLACEHOLDER_DOMAIN = '@invalid.local'\nconst emailSchema = z.string().trim().email()\n\nexport function isDeliverableEmail(value: string | null | undefined): value is string {\n if (!value) return false\n if (value.trim().toLowerCase().endsWith(PLACEHOLDER_DOMAIN)) return false\n return emailSchema.safeParse(value).success\n}\n\nexport function hasSuccessfulDelivery(\n invoice: Pick<BillingInvoice, 'metadata'>,\n trigger?: DeliveryTrigger,\n): boolean {\n const deliveries = (invoice.metadata?.deliveries ?? []) as InvoiceDelivery[]\n return Array.isArray(deliveries) && deliveries.some((d) => d.status === 'sent' && (!trigger || d.trigger === trigger))\n}\n\nfunction escapeHtml(value: string): string {\n return value.replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[c] ?? c)\n}\n\nfunction fill(template: string, params: Record<string, string>): string {\n return template.replace(/\\{(\\w+)\\}/g, (match, key: string) => params[key] ?? match)\n}\n\n/** Subject + plain-text and HTML bodies in the document language. Pure. */\nexport function buildInvoiceEmail(params: {\n invoice: Pick<BillingInvoice, 'number' | 'totalGross' | 'currencyCode' | 'dueDate'>\n buyerName: string\n sellerName: string | null\n iban: string | null\n language: string\n translate: DocumentTranslate\n}): { subject: string; text: string; html: string } {\n const { invoice, translate: t } = params\n const locale = documentLocale(params.language)\n const values = {\n number: invoice.number,\n name: params.buyerName,\n amount: formatDocumentMoney(invoice.totalGross, invoice.currencyCode, locale).replace(/[\u00A0\u202F]/g, ' '),\n dueDate: formatDocumentDate(invoice.dueDate ?? null, locale),\n iban: params.iban ? formatIban(params.iban) : '',\n }\n const paragraphs = [\n fill(t('billing.email.invoice.greeting', 'Hello {name},'), values),\n fill(t('billing.email.invoice.body', 'Please find attached invoice {number} for {amount}, due on {dueDate}.'), values),\n params.iban\n ? fill(t('billing.email.invoice.payment', 'Please pay to IBAN {iban} with the transfer title {number}.'), values)\n : '',\n [t('billing.email.invoice.closing', 'Kind regards,'), params.sellerName ?? ''].filter(Boolean).join('\\n'),\n ].filter(Boolean)\n return {\n subject: fill(t('billing.email.invoice.subject', 'Invoice {number}'), values),\n text: paragraphs.join('\\n\\n'),\n html: paragraphs.map((p) => `<p>${escapeHtml(p).replace(/\\n/g, '<br/>')}</p>`).join(''),\n }\n}\n\nasync function defaultSendEmail(options: SendEmailOptions): Promise<void> {\n const { sendEmail } = await import('@open-mercato/shared/lib/email/send')\n await sendEmail(options)\n}\n\nasync function appendDelivery(em: EntityManager, invoice: BillingInvoice, delivery: InvoiceDelivery): Promise<void> {\n await em.execute(\n `UPDATE billing_invoices\n SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{deliveries}',\n COALESCE(metadata->'deliveries', '[]'::jsonb) || ?::jsonb),\n updated_at = now()\n WHERE id = ? AND tenant_id = ? AND organization_id = ?`,\n [JSON.stringify([delivery]), invoice.id, invoice.tenantId, invoice.organizationId],\n )\n const deliveries = Array.isArray(invoice.metadata?.deliveries) ? (invoice.metadata?.deliveries as InvoiceDelivery[]) : []\n invoice.metadata = { ...(invoice.metadata ?? {}), deliveries: [...deliveries, delivery] }\n}\n\nexport async function sendInvoice(\n em: EntityManager,\n params: { invoiceId: string; tenantId: string; organizationId: string; trigger: DeliveryTrigger },\n deps: SendInvoiceDeps = {},\n): Promise<SendInvoiceResult> {\n const scope = { tenantId: params.tenantId, organizationId: params.organizationId }\n const invoice = await em.findOne(BillingInvoice, { id: params.invoiceId, ...scope, deletedAt: null } as never)\n if (!invoice) throw new CrudHttpError(404, { error: 'Invoice not found' })\n if (invoice.status === 'draft' || invoice.testRun) {\n throw new CrudHttpError(409, {\n error: 'Only posted invoices can be sent',\n code: 'billing.invoice.not_posted',\n })\n }\n\n // The live account address wins (an operator may fix a bad address and\n // resend); the frozen buyer snapshot is the fallback for a deleted account.\n const account = await em.findOne(BillingAccount, { id: invoice.billAccountId, ...scope } as never)\n const buyer = (invoice.buyerSnapshot ?? {}) as Record<string, unknown>\n const to = account?.invoiceEmail ?? (typeof buyer.invoice_email === 'string' ? buyer.invoice_email : '')\n if (!isDeliverableEmail(to)) {\n throw new CrudHttpError(422, {\n error: 'The billing account has no deliverable invoice email',\n code: 'billing.invoice.email_invalid',\n })\n }\n\n const language =\n (typeof buyer.invoice_language === 'string' && buyer.invoice_language) ||\n account?.invoiceLanguage ||\n DEFAULT_INVOICE_LANGUAGE\n const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language)\n const seller = (invoice.sellerSnapshot ?? {}) as Record<string, unknown>\n const email = buildInvoiceEmail({\n invoice,\n buyerName: (typeof buyer.name === 'string' && buyer.name) || account?.name || '',\n sellerName: typeof seller.legal_name === 'string' ? seller.legal_name : null,\n iban: typeof seller.iban === 'string' ? seller.iban : null,\n language,\n translate,\n })\n\n const now = deps.now ?? (() => new Date())\n let error: string | null = null\n try {\n const pdf = await getInvoicePdf(em, invoice, { loadTranslator: deps.loadTranslator })\n await (deps.sendEmail ?? defaultSendEmail)({\n to,\n subject: email.subject,\n text: email.text,\n html: email.html,\n attachments: [{ filename: pdf.fileName, content: Buffer.from(pdf.bytes).toString('base64'), contentType: 'application/pdf' }],\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n })\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n console.warn('[billing.invoice.send] delivery failed', invoice.id, error)\n }\n\n const status: InvoiceDelivery['status'] = error ? 'failed' : 'sent'\n await appendDelivery(em, invoice, { at: now().toISOString(), to, status, error, trigger: params.trigger })\n await emitBillingEvent(status === 'sent' ? 'billing.invoice.sent' : 'billing.invoice.send_failed', {\n invoiceId: invoice.id,\n invoiceNumber: invoice.number,\n billAccountId: invoice.billAccountId,\n to,\n trigger: params.trigger,\n error,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n })\n return { invoiceId: invoice.id, to, status, error }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { SendEmailOptions } from '@open-mercato/shared/lib/email/send'\nimport { BillingAccount, BillingInvoice } from '../data/entities'\nimport { emitBillingEvent } from '../events'\nimport { resolveInvoiceLanguage } from './invoiceLanguages'\nimport { formatIban } from './iban'\nimport { documentLocale, formatDocumentDate, formatDocumentMoney, type DocumentTranslate } from './pdf/invoiceModel'\nimport { getInvoicePdf, loadDocumentTranslator, type TranslatorLoader } from './pdf/invoicePdf'\n\n/**\n * Emails a posted billing document to the account's invoice address with\n * the frozen PDF attached (Spec B), through the platform's standard\n * `sendEmail()` \u2014 no custom transport.\n *\n * - posted (non-draft, non-test) documents only \u2192 409 `billing.invoice.not_posted`;\n * - the connector's placeholder addresses (`@invalid.local`) and malformed\n * emails are refused \u2192 422 `billing.invoice.email_invalid`, nothing sent;\n * - every attempt appends `{ at, to, status, error?, trigger }` to\n * `metadata.deliveries` (atomic jsonb append \u2014 concurrent sends never\n * lose an entry) and emits `billing.invoice.sent` / `send_failed`;\n * - transport errors (e.g. `EMAIL_TRANSPORT_NOT_CONFIGURED`) are recorded\n * as a `failed` delivery and returned, never thrown \u2014 posting is\n * unaffected and the operator sees the reason.\n */\n\nexport type DeliveryTrigger = 'manual' | 'auto'\n\nexport type InvoiceDelivery = {\n at: string\n to: string\n status: 'sent' | 'failed'\n error?: string | null\n trigger: DeliveryTrigger\n}\n\nexport type SendInvoiceResult = { invoiceId: string; to: string; status: 'sent' | 'failed'; error: string | null }\n\nexport type SendEmailFn = (options: SendEmailOptions) => Promise<void>\n\nexport type SendInvoiceDeps = {\n sendEmail?: SendEmailFn\n loadTranslator?: TranslatorLoader\n now?: () => Date\n}\n\nconst PLACEHOLDER_DOMAIN = '@invalid.local'\nconst emailSchema = z.string().trim().email()\n\nexport function isDeliverableEmail(value: string | null | undefined): value is string {\n if (!value) return false\n if (value.trim().toLowerCase().endsWith(PLACEHOLDER_DOMAIN)) return false\n return emailSchema.safeParse(value).success\n}\n\nexport function hasSuccessfulDelivery(\n invoice: Pick<BillingInvoice, 'metadata'>,\n trigger?: DeliveryTrigger,\n): boolean {\n const deliveries = (invoice.metadata?.deliveries ?? []) as InvoiceDelivery[]\n return Array.isArray(deliveries) && deliveries.some((d) => d.status === 'sent' && (!trigger || d.trigger === trigger))\n}\n\nfunction escapeHtml(value: string): string {\n return value.replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[c] ?? c)\n}\n\nfunction fill(template: string, params: Record<string, string>): string {\n return template.replace(/\\{(\\w+)\\}/g, (match, key: string) => params[key] ?? match)\n}\n\n/** Subject + plain-text and HTML bodies in the document language. Pure. */\nexport function buildInvoiceEmail(params: {\n invoice: Pick<BillingInvoice, 'number' | 'totalGross' | 'currencyCode' | 'dueDate'>\n buyerName: string\n sellerName: string | null\n iban: string | null\n language: string\n translate: DocumentTranslate\n}): { subject: string; text: string; html: string } {\n const { invoice, translate: t } = params\n const locale = documentLocale(params.language)\n const values = {\n number: invoice.number,\n name: params.buyerName,\n amount: formatDocumentMoney(invoice.totalGross, invoice.currencyCode, locale).replace(/[\u00A0\u202F]/g, ' '),\n dueDate: formatDocumentDate(invoice.dueDate ?? null, locale),\n iban: params.iban ? formatIban(params.iban) : '',\n }\n const paragraphs = [\n fill(t('billing.email.invoice.greeting', 'Hello {name},'), values),\n fill(t('billing.email.invoice.body', 'Please find attached invoice {number} for {amount}, due on {dueDate}.'), values),\n params.iban\n ? fill(t('billing.email.invoice.payment', 'Please pay to IBAN {iban} with the transfer title {number}.'), values)\n : '',\n [t('billing.email.invoice.closing', 'Kind regards,'), params.sellerName ?? ''].filter(Boolean).join('\\n'),\n ].filter(Boolean)\n return {\n subject: fill(t('billing.email.invoice.subject', 'Invoice {number}'), values),\n text: paragraphs.join('\\n\\n'),\n html: paragraphs.map((p) => `<p>${escapeHtml(p).replace(/\\n/g, '<br/>')}</p>`).join(''),\n }\n}\n\nasync function defaultSendEmail(options: SendEmailOptions): Promise<void> {\n const { sendEmail } = await import('@open-mercato/shared/lib/email/send')\n await sendEmail(options)\n}\n\nasync function appendDelivery(em: EntityManager, invoice: BillingInvoice, delivery: InvoiceDelivery): Promise<void> {\n await em.execute(\n `UPDATE billing_invoices\n SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{deliveries}',\n COALESCE(metadata->'deliveries', '[]'::jsonb) || ?::jsonb),\n updated_at = now()\n WHERE id = ? AND tenant_id = ? AND organization_id = ?`,\n [JSON.stringify([delivery]), invoice.id, invoice.tenantId, invoice.organizationId],\n )\n const deliveries = Array.isArray(invoice.metadata?.deliveries) ? (invoice.metadata?.deliveries as InvoiceDelivery[]) : []\n invoice.metadata = { ...(invoice.metadata ?? {}), deliveries: [...deliveries, delivery] }\n}\n\nexport async function sendInvoice(\n em: EntityManager,\n params: { invoiceId: string; tenantId: string; organizationId: string; trigger: DeliveryTrigger },\n deps: SendInvoiceDeps = {},\n): Promise<SendInvoiceResult> {\n const scope = { tenantId: params.tenantId, organizationId: params.organizationId }\n const invoice = await em.findOne(BillingInvoice, { id: params.invoiceId, ...scope, deletedAt: null } as never)\n if (!invoice) throw new CrudHttpError(404, { error: 'Invoice not found' })\n if (invoice.status === 'draft' || invoice.testRun) {\n throw new CrudHttpError(409, {\n error: 'Only posted invoices can be sent',\n code: 'billing.invoice.not_posted',\n })\n }\n\n // The live account address wins (an operator may fix a bad address and\n // resend); the frozen buyer snapshot is the fallback for a deleted account.\n const account = await em.findOne(BillingAccount, { id: invoice.billAccountId, ...scope } as never)\n const buyer = (invoice.buyerSnapshot ?? {}) as Record<string, unknown>\n const to = account?.invoiceEmail ?? (typeof buyer.invoice_email === 'string' ? buyer.invoice_email : '')\n if (!isDeliverableEmail(to)) {\n throw new CrudHttpError(422, {\n error: 'The billing account has no deliverable invoice email',\n code: 'billing.invoice.email_invalid',\n })\n }\n\n const language = resolveInvoiceLanguage(\n (typeof buyer.invoice_language === 'string' && buyer.invoice_language) || account?.invoiceLanguage,\n )\n const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language)\n const seller = (invoice.sellerSnapshot ?? {}) as Record<string, unknown>\n const email = buildInvoiceEmail({\n invoice,\n buyerName: (typeof buyer.name === 'string' && buyer.name) || account?.name || '',\n sellerName: typeof seller.legal_name === 'string' ? seller.legal_name : null,\n iban: typeof seller.iban === 'string' ? seller.iban : null,\n language,\n translate,\n })\n\n const now = deps.now ?? (() => new Date())\n let error: string | null = null\n try {\n const pdf = await getInvoicePdf(em, invoice, { loadTranslator: deps.loadTranslator })\n await (deps.sendEmail ?? defaultSendEmail)({\n to,\n subject: email.subject,\n text: email.text,\n html: email.html,\n attachments: [{ filename: pdf.fileName, content: Buffer.from(pdf.bytes).toString('base64'), contentType: 'application/pdf' }],\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n })\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n console.warn('[billing.invoice.send] delivery failed', invoice.id, error)\n }\n\n const status: InvoiceDelivery['status'] = error ? 'failed' : 'sent'\n await appendDelivery(em, invoice, { at: now().toISOString(), to, status, error, trigger: params.trigger })\n await emitBillingEvent(status === 'sent' ? 'billing.invoice.sent' : 'billing.invoice.send_failed', {\n invoiceId: invoice.id,\n invoiceNumber: invoice.number,\n billAccountId: invoice.billAccountId,\n to,\n trigger: params.trigger,\n error,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n })\n return { invoiceId: invoice.id, to, status, error }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,16 +1,28 @@
1
1
  const SUPPORTED_INVOICE_LANGUAGES = [
2
- { code: "en", label: "English" },
3
- { code: "pl", label: "Polski" }
2
+ { code: "en", label: "English", intlLocale: "en-GB" },
3
+ { code: "es", label: "Espa\xF1ol", intlLocale: "es-ES" },
4
+ { code: "de", label: "Deutsch", intlLocale: "de-DE" },
5
+ { code: "pl", label: "Polski", intlLocale: "pl-PL" }
4
6
  ];
5
7
  const INVOICE_LANGUAGE_CODES = SUPPORTED_INVOICE_LANGUAGES.map((l) => l.code);
6
8
  const DEFAULT_INVOICE_LANGUAGE = "en";
7
9
  function isSupportedInvoiceLanguage(value) {
8
10
  return INVOICE_LANGUAGE_CODES.includes(value);
9
11
  }
12
+ function resolveInvoiceLanguage(value) {
13
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
14
+ return isSupportedInvoiceLanguage(normalized) ? normalized : DEFAULT_INVOICE_LANGUAGE;
15
+ }
16
+ function invoiceIntlLocale(language) {
17
+ const code = resolveInvoiceLanguage(language);
18
+ return SUPPORTED_INVOICE_LANGUAGES.find((l) => l.code === code)?.intlLocale ?? "en-GB";
19
+ }
10
20
  export {
11
21
  DEFAULT_INVOICE_LANGUAGE,
12
22
  INVOICE_LANGUAGE_CODES,
13
23
  SUPPORTED_INVOICE_LANGUAGES,
14
- isSupportedInvoiceLanguage
24
+ invoiceIntlLocale,
25
+ isSupportedInvoiceLanguage,
26
+ resolveInvoiceLanguage
15
27
  };
16
28
  //# sourceMappingURL=invoiceLanguages.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/lib/invoiceLanguages.ts"],
4
- "sourcesContent": ["/**\n * Invoice languages the built-in billing document template can render.\n *\n * Single source of truth for the account form's language picklist and the\n * `invoiceLanguage` validator. Adding a language means adding the template\n * labels for it AND one entry here \u2014 never one without the other.\n *\n * `label` is the language's own name (endonym), so it reads correctly no\n * matter which UI locale the operator uses \u2014 it is intentionally not routed\n * through i18n.\n *\n * Spec: .ai/specs/2026-11-14-billing-account-workspace.md (D22). Narrowing the\n * list per organization (`BillingSettings.enabled_invoice_languages`) arrives\n * with the standalone-ledger spec; until then every supported language is\n * selectable.\n */\n\nexport const SUPPORTED_INVOICE_LANGUAGES = [\n { code: 'en', label: 'English' },\n { code: 'pl', label: 'Polski' },\n] as const\n\nexport type InvoiceLanguageCode = (typeof SUPPORTED_INVOICE_LANGUAGES)[number]['code']\n\nexport const INVOICE_LANGUAGE_CODES = SUPPORTED_INVOICE_LANGUAGES.map((l) => l.code) as [\n InvoiceLanguageCode,\n ...InvoiceLanguageCode[],\n]\n\nexport const DEFAULT_INVOICE_LANGUAGE: InvoiceLanguageCode = 'en'\n\nexport function isSupportedInvoiceLanguage(value: string): value is InvoiceLanguageCode {\n return (INVOICE_LANGUAGE_CODES as readonly string[]).includes(value)\n}\n"],
5
- "mappings": "AAiBO,MAAM,8BAA8B;AAAA,EACzC,EAAE,MAAM,MAAM,OAAO,UAAU;AAAA,EAC/B,EAAE,MAAM,MAAM,OAAO,SAAS;AAChC;AAIO,MAAM,yBAAyB,4BAA4B,IAAI,CAAC,MAAM,EAAE,IAAI;AAK5E,MAAM,2BAAgD;AAEtD,SAAS,2BAA2B,OAA6C;AACtF,SAAQ,uBAA6C,SAAS,KAAK;AACrE;",
4
+ "sourcesContent": ["import type { Locale } from '@open-mercato/shared/lib/i18n/config'\n\n/**\n * Invoice languages the built-in billing document template can render.\n *\n * Single source of truth for the account form's language picklist, the\n * `invoiceLanguage` validator, the `Intl` locale used for money / dates on\n * documents and emails, and the dictionary the document translator loads.\n * Adding a language means adding `i18n/<code>.json` AND one entry here \u2014 the\n * dictionary-parity test fails if either is missing.\n *\n * Every `code` must be a UI locale the framework ships (`Locale`), because the\n * document translator loads the regular module dictionary for it. The\n * `satisfies` clause below turns a code the framework does not ship into a\n * compile error.\n *\n * `label` is the language's own name (endonym), so it reads correctly no\n * matter which UI locale the operator uses \u2014 it is intentionally not routed\n * through i18n. Order: default first, then product priority.\n *\n * Specs: .ai/specs/2026-11-14-billing-account-workspace.md (D22),\n * .ai/specs/2026-11-15-billing-multi-language.md. Narrowing the list per\n * organization (`BillingSettings.enabled_invoice_languages`) arrives with the\n * standalone-ledger spec; until then every supported language is selectable.\n */\n\nexport const SUPPORTED_INVOICE_LANGUAGES = [\n { code: 'en', label: 'English', intlLocale: 'en-GB' },\n { code: 'es', label: 'Espa\u00F1ol', intlLocale: 'es-ES' },\n { code: 'de', label: 'Deutsch', intlLocale: 'de-DE' },\n { code: 'pl', label: 'Polski', intlLocale: 'pl-PL' },\n] as const satisfies ReadonlyArray<{ code: Locale; label: string; intlLocale: string }>\n\nexport type InvoiceLanguageCode = (typeof SUPPORTED_INVOICE_LANGUAGES)[number]['code']\n\nexport const INVOICE_LANGUAGE_CODES = SUPPORTED_INVOICE_LANGUAGES.map((l) => l.code) as [\n InvoiceLanguageCode,\n ...InvoiceLanguageCode[],\n]\n\nexport const DEFAULT_INVOICE_LANGUAGE: InvoiceLanguageCode = 'en'\n\nexport function isSupportedInvoiceLanguage(value: string): value is InvoiceLanguageCode {\n return (INVOICE_LANGUAGE_CODES as readonly string[]).includes(value)\n}\n\n/** Normalizes a stored / requested language to a supported code (`' PL '` \u2192 `pl`), else the default. */\nexport function resolveInvoiceLanguage(value: string | null | undefined): InvoiceLanguageCode {\n const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''\n return isSupportedInvoiceLanguage(normalized) ? normalized : DEFAULT_INVOICE_LANGUAGE\n}\n\n/** The `Intl` locale documents in this language format money and dates with. */\nexport function invoiceIntlLocale(language: string | null | undefined): string {\n const code = resolveInvoiceLanguage(language)\n return SUPPORTED_INVOICE_LANGUAGES.find((l) => l.code === code)?.intlLocale ?? 'en-GB'\n}\n"],
5
+ "mappings": "AA0BO,MAAM,8BAA8B;AAAA,EACzC,EAAE,MAAM,MAAM,OAAO,WAAW,YAAY,QAAQ;AAAA,EACpD,EAAE,MAAM,MAAM,OAAO,cAAW,YAAY,QAAQ;AAAA,EACpD,EAAE,MAAM,MAAM,OAAO,WAAW,YAAY,QAAQ;AAAA,EACpD,EAAE,MAAM,MAAM,OAAO,UAAU,YAAY,QAAQ;AACrD;AAIO,MAAM,yBAAyB,4BAA4B,IAAI,CAAC,MAAM,EAAE,IAAI;AAK5E,MAAM,2BAAgD;AAEtD,SAAS,2BAA2B,OAA6C;AACtF,SAAQ,uBAA6C,SAAS,KAAK;AACrE;AAGO,SAAS,uBAAuB,OAAuD;AAC5F,QAAM,aAAa,OAAO,UAAU,WAAW,MAAM,KAAK,EAAE,YAAY,IAAI;AAC5E,SAAO,2BAA2B,UAAU,IAAI,aAAa;AAC/D;AAGO,SAAS,kBAAkB,UAA6C;AAC7E,QAAM,OAAO,uBAAuB,QAAQ;AAC5C,SAAO,4BAA4B,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI,GAAG,cAAc;AACjF;",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,6 @@
1
- const LOCALE_BY_LANGUAGE = { en: "en-GB", pl: "pl-PL" };
1
+ import { invoiceIntlLocale } from "../invoiceLanguages.js";
2
2
  function documentLocale(language) {
3
- return LOCALE_BY_LANGUAGE[language] ?? "en-GB";
3
+ return invoiceIntlLocale(language);
4
4
  }
5
5
  function toDate(value) {
6
6
  if (!value) return null;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/billing/lib/pdf/invoiceModel.ts"],
4
- "sourcesContent": ["/**\n * Invoice document model (Spec B) \u2014 pure.\n *\n * Turns a billing document + its lines + the frozen seller / buyer blocks\n * into the exact strings the PDF prints, in the document language. The\n * renderer (`invoiceTemplate.ts`) only lays this model out, so everything a\n * reader can check (labels, formatting, VAT summary, payment title) is\n * asserted here without parsing a PDF. Same shape as core forms'\n * \"build model, then lay out with pdf-lib\" snapshot pipeline.\n *\n * Credit / debit memos (Spec D) reuse it: only the title changes and a\n * \"corrects\" row is added to the header.\n */\n\nexport type DocumentTranslate = (key: string, fallback: string) => string\n\nexport type PdfAddress = {\n line1?: string | null\n line2?: string | null\n city?: string | null\n postal_code?: string | null\n country?: string | null\n}\n\nexport type PdfSeller = {\n legalName: string | null\n address: PdfAddress | null\n taxId: string | null\n bankName: string | null\n iban: string | null\n bic: string | null\n footer: string | null\n}\n\nexport type PdfBuyer = {\n name: string\n address: PdfAddress | null\n taxId: string | null\n}\n\nexport type PdfInvoice = {\n number: string\n documentType: 'invoice' | 'credit_memo' | 'debit_memo' | 'receipt'\n status: string\n testRun: boolean\n issueDate: Date | string | null\n dueDate: Date | string | null\n billDate: Date | string | null\n periodStart: Date | string | null\n periodEnd: Date | string | null\n currencyCode: string\n subtotalNet: string\n taxTotal: string\n totalGross: string\n correctsNumber?: string | null\n /** Spec D: issue date of the corrected document and the correction reason. */\n correctsIssueDate?: Date | string | null\n correctionReason?: string | null\n}\n\nexport type PdfLine = {\n description: string\n quantity: string\n unitPriceNet: string\n taxRate: string\n netAmount: string\n taxAmount: string\n grossAmount: string\n servicePeriodStart: Date | string | null\n servicePeriodEnd: Date | string | null\n}\n\nexport type InvoiceDocumentModel = {\n language: string\n title: string\n number: string\n watermark: string | null\n meta: Array<[string, string]>\n seller: { heading: string; lines: string[] }\n buyer: { heading: string; lines: string[] }\n table: {\n headers: string[]\n /** Column alignment: description left, numbers right. */\n align: Array<'left' | 'right'>\n rows: string[][]\n }\n vatSummary: { heading: string; headers: string[]; rows: string[][] }\n totals: Array<[string, string]>\n payment: { heading: string; lines: string[] } | null\n footer: string[]\n pageLabel: (page: number, pages: number) => string\n}\n\nconst LOCALE_BY_LANGUAGE: Record<string, string> = { en: 'en-GB', pl: 'pl-PL' }\n\nexport function documentLocale(language: string): string {\n return LOCALE_BY_LANGUAGE[language] ?? 'en-GB'\n}\n\nfunction toDate(value: Date | string | null | undefined): Date | null {\n if (!value) return null\n const date = typeof value === 'string' ? new Date(value.length === 10 ? `${value}T00:00:00Z` : value) : value\n return Number.isNaN(date.getTime()) ? null : date\n}\n\nexport function formatDocumentDate(value: Date | string | null | undefined, locale: string): string {\n const date = toDate(value)\n if (!date) return '\u2014'\n return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' }).format(\n date,\n )\n}\n\nexport function formatDocumentMoney(value: string | number, currency: string, locale: string): string {\n const n = typeof value === 'number' ? value : Number.parseFloat(value)\n const safe = Number.isFinite(n) ? n : 0\n try {\n return new Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(safe)\n } catch {\n return `${safe.toFixed(2)} ${currency}`\n }\n}\n\nfunction formatNumber(value: string | number, locale: string, maxFraction = 4): string {\n const n = typeof value === 'number' ? value : Number.parseFloat(value)\n return new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: maxFraction }).format(\n Number.isFinite(n) ? n : 0,\n )\n}\n\nfunction formatPercent(rate: string | number, locale: string): string {\n return `${formatNumber(rate, locale, 2)} %`\n}\n\n/** Normalizes Intl's narrow / regular no-break spaces to plain ones (safe for any embedded font). */\nfunction plain(text: string): string {\n return text.replace(/[\u00A0\u202F]/g, ' ')\n}\n\nfunction addressLines(address: PdfAddress | null | undefined): string[] {\n if (!address) return []\n const cityLine = [address.postal_code, address.city].filter(Boolean).join(' ')\n return [address.line1, address.line2, cityLine, address.country].filter(\n (part): part is string => typeof part === 'string' && part.trim() !== '',\n )\n}\n\nfunction titleFor(doc: PdfInvoice, t: DocumentTranslate): string {\n switch (doc.documentType) {\n case 'credit_memo':\n return t('billing.pdf.title.credit_memo', 'Credit memo')\n case 'debit_memo':\n return t('billing.pdf.title.debit_memo', 'Debit memo')\n case 'receipt':\n return t('billing.pdf.title.receipt', 'Receipt')\n default:\n return t('billing.pdf.title.invoice', 'VAT invoice')\n }\n}\n\nexport function buildInvoiceDocumentModel(params: {\n invoice: PdfInvoice\n lines: PdfLine[]\n seller: PdfSeller\n buyer: PdfBuyer\n language: string\n translate: DocumentTranslate\n}): InvoiceDocumentModel {\n const { invoice, lines, seller, buyer, translate: t } = params\n const locale = documentLocale(params.language)\n const money = (value: string | number) => plain(formatDocumentMoney(value, invoice.currencyCode, locale))\n const date = (value: Date | string | null | undefined) => plain(formatDocumentDate(value, locale))\n const isDraft = invoice.status === 'draft'\n\n const meta: Array<[string, string]> = [\n [t('billing.pdf.meta.number', 'Number'), invoice.number],\n [t('billing.pdf.meta.issue_date', 'Issue date'), isDraft ? '\u2014' : date(invoice.issueDate)],\n ]\n if (invoice.documentType !== 'receipt') {\n meta.push([t('billing.pdf.meta.due_date', 'Due date'), isDraft ? '\u2014' : date(invoice.dueDate)])\n }\n if (invoice.periodStart && invoice.periodEnd) {\n meta.push([\n t('billing.pdf.meta.service_period', 'Service period'),\n `${date(invoice.periodStart)} \u2013 ${date(invoice.periodEnd)}`,\n ])\n }\n if (invoice.correctsNumber) {\n meta.push([\n t('billing.pdf.meta.corrects', 'Corrects'),\n invoice.correctsIssueDate\n ? `${invoice.correctsNumber} ${t('billing.pdf.meta.of', 'of')} ${date(invoice.correctsIssueDate)}`\n : invoice.correctsNumber,\n ])\n }\n if (invoice.correctionReason) {\n meta.push([t('billing.pdf.meta.reason', 'Reason'), invoice.correctionReason])\n }\n\n const sellerLines = [\n seller.legalName ?? '',\n ...addressLines(seller.address),\n seller.taxId ? `${t('billing.pdf.tax_id', 'Tax ID')}: ${seller.taxId}` : '',\n ].filter((line) => line !== '')\n const buyerLines = [\n buyer.name,\n ...addressLines(buyer.address),\n buyer.taxId ? `${t('billing.pdf.tax_id', 'Tax ID')}: ${buyer.taxId}` : '',\n ].filter((line) => line !== '')\n\n const rows = lines.map((line, index) => {\n const start = line.servicePeriodStart\n const end = line.servicePeriodEnd\n const description =\n start && end && !line.description.includes(' \u2013 ')\n ? `${line.description} (${date(start)} \u2013 ${date(end)})`\n : line.description\n return [\n String(index + 1),\n description,\n plain(formatNumber(line.quantity, locale)),\n money(line.unitPriceNet),\n plain(formatPercent(line.taxRate, locale)),\n money(line.netAmount),\n money(line.taxAmount),\n money(line.grossAmount),\n ]\n })\n\n // VAT summary: sums of the (already rounded) line amounts per rate, so the\n // summary always adds up to the document totals (B-D2).\n const byRate = new Map<number, { net: number; tax: number; gross: number }>()\n for (const line of lines) {\n const rate = Number.parseFloat(line.taxRate) || 0\n const bucket = byRate.get(rate) ?? { net: 0, tax: 0, gross: 0 }\n bucket.net += Number.parseFloat(line.netAmount) || 0\n bucket.tax += Number.parseFloat(line.taxAmount) || 0\n bucket.gross += Number.parseFloat(line.grossAmount) || 0\n byRate.set(rate, bucket)\n }\n const vatRows = [...byRate.entries()]\n .sort(([a], [b]) => b - a)\n .map(([rate, sums]) => [plain(formatPercent(rate, locale)), money(sums.net), money(sums.tax), money(sums.gross)])\n\n const isCredit = invoice.documentType === 'credit_memo'\n const totals: Array<[string, string]> = [\n [t('billing.pdf.totals.net', 'Total net'), money(invoice.subtotalNet)],\n [t('billing.pdf.totals.tax', 'Total VAT'), money(invoice.taxTotal)],\n isCredit\n ? [t('billing.pdf.totals.credited', 'Total credited'), money(invoice.totalGross)]\n : [t('billing.pdf.totals.gross', 'Total due'), money(invoice.totalGross)],\n ]\n\n const payable = invoice.documentType === 'invoice' || invoice.documentType === 'debit_memo'\n const payment =\n payable && seller.iban\n ? {\n heading: t('billing.pdf.payment.heading', 'Payment'),\n lines: [\n `${t('billing.pdf.payment.amount', 'Amount')}: ${money(invoice.totalGross)}`,\n `${t('billing.pdf.payment.due', 'Due by')}: ${isDraft ? '\u2014' : date(invoice.dueDate)}`,\n seller.bankName ? `${t('billing.pdf.payment.bank', 'Bank')}: ${seller.bankName}` : '',\n `IBAN: ${seller.iban.replace(/(.{4})/g, '$1 ').trim()}`,\n seller.bic ? `BIC: ${seller.bic}` : '',\n // Spec C's matcher finds the invoice number in the transfer title.\n `${t('billing.pdf.payment.title', 'Transfer title')}: ${invoice.number}`,\n ].filter((line) => line !== ''),\n }\n : isCredit\n ? {\n // A credit memo is not paid: it settles the corrected invoice and\n // any remainder stays as credit on the customer's account (D-D10).\n heading: t('billing.pdf.memo.settlement', 'Settlement'),\n lines: [\n `${t('billing.pdf.memo.amount_credited', 'Amount credited')}: ${money(invoice.totalGross)}`,\n invoice.correctsNumber\n ? t('billing.pdf.memo.settled_against', 'Settled against invoice {number}; any remainder is credited to your account.').replace(\n '{number}',\n invoice.correctsNumber,\n )\n : t('billing.pdf.memo.credited_to_account', 'The amount is credited to your account.'),\n ],\n }\n : null\n\n return {\n language: params.language,\n title: titleFor(invoice, t),\n number: invoice.number,\n watermark: invoice.testRun\n ? t('billing.pdf.watermark.test', 'TEST')\n : isDraft\n ? t('billing.pdf.watermark.draft', 'DRAFT')\n : null,\n meta,\n seller: { heading: t('billing.pdf.seller', 'Seller'), lines: sellerLines },\n buyer: { heading: t('billing.pdf.buyer', 'Buyer'), lines: buyerLines },\n table: {\n headers: [\n '#',\n t('billing.pdf.columns.description', 'Description'),\n t('billing.pdf.columns.quantity', 'Qty'),\n t('billing.pdf.columns.unit_net', 'Unit net'),\n t('billing.pdf.columns.tax_rate', 'VAT %'),\n t('billing.pdf.columns.net', 'Net'),\n t('billing.pdf.columns.tax', 'VAT'),\n t('billing.pdf.columns.gross', 'Gross'),\n ],\n align: ['right', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n rows,\n },\n vatSummary: {\n heading: t('billing.pdf.vat_summary', 'VAT summary'),\n headers: [\n t('billing.pdf.columns.tax_rate', 'VAT %'),\n t('billing.pdf.columns.net', 'Net'),\n t('billing.pdf.columns.tax', 'VAT'),\n t('billing.pdf.columns.gross', 'Gross'),\n ],\n rows: vatRows,\n },\n totals,\n payment,\n footer: (seller.footer ?? '').split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean),\n pageLabel: (page, pages) =>\n t('billing.pdf.page', 'Page {page} of {pages}').replace('{page}', String(page)).replace('{pages}', String(pages)),\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Invoice document model (Spec B) \u2014 pure.\n *\n * Turns a billing document + its lines + the frozen seller / buyer blocks\n * into the exact strings the PDF prints, in the document language. The\n * renderer (`invoiceTemplate.ts`) only lays this model out, so everything a\n * reader can check (labels, formatting, VAT summary, payment title) is\n * asserted here without parsing a PDF. Same shape as core forms'\n * \"build model, then lay out with pdf-lib\" snapshot pipeline.\n *\n * Credit / debit memos (Spec D) reuse it: only the title changes and a\n * \"corrects\" row is added to the header.\n */\n\nimport { invoiceIntlLocale } from '../invoiceLanguages'\n\nexport type DocumentTranslate =(key: string, fallback: string) => string\n\nexport type PdfAddress = {\n line1?: string | null\n line2?: string | null\n city?: string | null\n postal_code?: string | null\n country?: string | null\n}\n\nexport type PdfSeller = {\n legalName: string | null\n address: PdfAddress | null\n taxId: string | null\n bankName: string | null\n iban: string | null\n bic: string | null\n footer: string | null\n}\n\nexport type PdfBuyer = {\n name: string\n address: PdfAddress | null\n taxId: string | null\n}\n\nexport type PdfInvoice = {\n number: string\n documentType: 'invoice' | 'credit_memo' | 'debit_memo' | 'receipt'\n status: string\n testRun: boolean\n issueDate: Date | string | null\n dueDate: Date | string | null\n billDate: Date | string | null\n periodStart: Date | string | null\n periodEnd: Date | string | null\n currencyCode: string\n subtotalNet: string\n taxTotal: string\n totalGross: string\n correctsNumber?: string | null\n /** Spec D: issue date of the corrected document and the correction reason. */\n correctsIssueDate?: Date | string | null\n correctionReason?: string | null\n}\n\nexport type PdfLine = {\n description: string\n quantity: string\n unitPriceNet: string\n taxRate: string\n netAmount: string\n taxAmount: string\n grossAmount: string\n servicePeriodStart: Date | string | null\n servicePeriodEnd: Date | string | null\n}\n\nexport type InvoiceDocumentModel = {\n language: string\n title: string\n number: string\n watermark: string | null\n meta: Array<[string, string]>\n seller: { heading: string; lines: string[] }\n buyer: { heading: string; lines: string[] }\n table: {\n headers: string[]\n /** Column alignment: description left, numbers right. */\n align: Array<'left' | 'right'>\n rows: string[][]\n }\n vatSummary: { heading: string; headers: string[]; rows: string[][] }\n totals: Array<[string, string]>\n payment: { heading: string; lines: string[] } | null\n footer: string[]\n pageLabel: (page: number, pages: number) => string\n}\n\n/** `Intl` locale for a document language \u2014 derived from the language registry. */\nexport function documentLocale(language: string): string {\n return invoiceIntlLocale(language)\n}\n\nfunction toDate(value: Date | string | null | undefined): Date | null {\n if (!value) return null\n const date = typeof value === 'string' ? new Date(value.length === 10 ? `${value}T00:00:00Z` : value) : value\n return Number.isNaN(date.getTime()) ? null : date\n}\n\nexport function formatDocumentDate(value: Date | string | null | undefined, locale: string): string {\n const date = toDate(value)\n if (!date) return '\u2014'\n return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' }).format(\n date,\n )\n}\n\nexport function formatDocumentMoney(value: string | number, currency: string, locale: string): string {\n const n = typeof value === 'number' ? value : Number.parseFloat(value)\n const safe = Number.isFinite(n) ? n : 0\n try {\n return new Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(safe)\n } catch {\n return `${safe.toFixed(2)} ${currency}`\n }\n}\n\nfunction formatNumber(value: string | number, locale: string, maxFraction = 4): string {\n const n = typeof value === 'number' ? value : Number.parseFloat(value)\n return new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: maxFraction }).format(\n Number.isFinite(n) ? n : 0,\n )\n}\n\nfunction formatPercent(rate: string | number, locale: string): string {\n return `${formatNumber(rate, locale, 2)} %`\n}\n\n/** Normalizes Intl's narrow / regular no-break spaces to plain ones (safe for any embedded font). */\nfunction plain(text: string): string {\n return text.replace(/[\u00A0\u202F]/g, ' ')\n}\n\nfunction addressLines(address: PdfAddress | null | undefined): string[] {\n if (!address) return []\n const cityLine = [address.postal_code, address.city].filter(Boolean).join(' ')\n return [address.line1, address.line2, cityLine, address.country].filter(\n (part): part is string => typeof part === 'string' && part.trim() !== '',\n )\n}\n\nfunction titleFor(doc: PdfInvoice, t: DocumentTranslate): string {\n switch (doc.documentType) {\n case 'credit_memo':\n return t('billing.pdf.title.credit_memo', 'Credit memo')\n case 'debit_memo':\n return t('billing.pdf.title.debit_memo', 'Debit memo')\n case 'receipt':\n return t('billing.pdf.title.receipt', 'Receipt')\n default:\n return t('billing.pdf.title.invoice', 'VAT invoice')\n }\n}\n\nexport function buildInvoiceDocumentModel(params: {\n invoice: PdfInvoice\n lines: PdfLine[]\n seller: PdfSeller\n buyer: PdfBuyer\n language: string\n translate: DocumentTranslate\n}): InvoiceDocumentModel {\n const { invoice, lines, seller, buyer, translate: t } = params\n const locale = documentLocale(params.language)\n const money = (value: string | number) => plain(formatDocumentMoney(value, invoice.currencyCode, locale))\n const date = (value: Date | string | null | undefined) => plain(formatDocumentDate(value, locale))\n const isDraft = invoice.status === 'draft'\n\n const meta: Array<[string, string]> = [\n [t('billing.pdf.meta.number', 'Number'), invoice.number],\n [t('billing.pdf.meta.issue_date', 'Issue date'), isDraft ? '\u2014' : date(invoice.issueDate)],\n ]\n if (invoice.documentType !== 'receipt') {\n meta.push([t('billing.pdf.meta.due_date', 'Due date'), isDraft ? '\u2014' : date(invoice.dueDate)])\n }\n if (invoice.periodStart && invoice.periodEnd) {\n meta.push([\n t('billing.pdf.meta.service_period', 'Service period'),\n `${date(invoice.periodStart)} \u2013 ${date(invoice.periodEnd)}`,\n ])\n }\n if (invoice.correctsNumber) {\n meta.push([\n t('billing.pdf.meta.corrects', 'Corrects'),\n invoice.correctsIssueDate\n ? `${invoice.correctsNumber} ${t('billing.pdf.meta.of', 'of')} ${date(invoice.correctsIssueDate)}`\n : invoice.correctsNumber,\n ])\n }\n if (invoice.correctionReason) {\n meta.push([t('billing.pdf.meta.reason', 'Reason'), invoice.correctionReason])\n }\n\n const sellerLines = [\n seller.legalName ?? '',\n ...addressLines(seller.address),\n seller.taxId ? `${t('billing.pdf.tax_id', 'Tax ID')}: ${seller.taxId}` : '',\n ].filter((line) => line !== '')\n const buyerLines = [\n buyer.name,\n ...addressLines(buyer.address),\n buyer.taxId ? `${t('billing.pdf.tax_id', 'Tax ID')}: ${buyer.taxId}` : '',\n ].filter((line) => line !== '')\n\n const rows = lines.map((line, index) => {\n const start = line.servicePeriodStart\n const end = line.servicePeriodEnd\n const description =\n start && end && !line.description.includes(' \u2013 ')\n ? `${line.description} (${date(start)} \u2013 ${date(end)})`\n : line.description\n return [\n String(index + 1),\n description,\n plain(formatNumber(line.quantity, locale)),\n money(line.unitPriceNet),\n plain(formatPercent(line.taxRate, locale)),\n money(line.netAmount),\n money(line.taxAmount),\n money(line.grossAmount),\n ]\n })\n\n // VAT summary: sums of the (already rounded) line amounts per rate, so the\n // summary always adds up to the document totals (B-D2).\n const byRate = new Map<number, { net: number; tax: number; gross: number }>()\n for (const line of lines) {\n const rate = Number.parseFloat(line.taxRate) || 0\n const bucket = byRate.get(rate) ?? { net: 0, tax: 0, gross: 0 }\n bucket.net += Number.parseFloat(line.netAmount) || 0\n bucket.tax += Number.parseFloat(line.taxAmount) || 0\n bucket.gross += Number.parseFloat(line.grossAmount) || 0\n byRate.set(rate, bucket)\n }\n const vatRows = [...byRate.entries()]\n .sort(([a], [b]) => b - a)\n .map(([rate, sums]) => [plain(formatPercent(rate, locale)), money(sums.net), money(sums.tax), money(sums.gross)])\n\n const isCredit = invoice.documentType === 'credit_memo'\n const totals: Array<[string, string]> = [\n [t('billing.pdf.totals.net', 'Total net'), money(invoice.subtotalNet)],\n [t('billing.pdf.totals.tax', 'Total VAT'), money(invoice.taxTotal)],\n isCredit\n ? [t('billing.pdf.totals.credited', 'Total credited'), money(invoice.totalGross)]\n : [t('billing.pdf.totals.gross', 'Total due'), money(invoice.totalGross)],\n ]\n\n const payable = invoice.documentType === 'invoice' || invoice.documentType === 'debit_memo'\n const payment =\n payable && seller.iban\n ? {\n heading: t('billing.pdf.payment.heading', 'Payment'),\n lines: [\n `${t('billing.pdf.payment.amount', 'Amount')}: ${money(invoice.totalGross)}`,\n `${t('billing.pdf.payment.due', 'Due by')}: ${isDraft ? '\u2014' : date(invoice.dueDate)}`,\n seller.bankName ? `${t('billing.pdf.payment.bank', 'Bank')}: ${seller.bankName}` : '',\n `IBAN: ${seller.iban.replace(/(.{4})/g, '$1 ').trim()}`,\n seller.bic ? `BIC: ${seller.bic}` : '',\n // Spec C's matcher finds the invoice number in the transfer title.\n `${t('billing.pdf.payment.title', 'Transfer title')}: ${invoice.number}`,\n ].filter((line) => line !== ''),\n }\n : isCredit\n ? {\n // A credit memo is not paid: it settles the corrected invoice and\n // any remainder stays as credit on the customer's account (D-D10).\n heading: t('billing.pdf.memo.settlement', 'Settlement'),\n lines: [\n `${t('billing.pdf.memo.amount_credited', 'Amount credited')}: ${money(invoice.totalGross)}`,\n invoice.correctsNumber\n ? t('billing.pdf.memo.settled_against', 'Settled against invoice {number}; any remainder is credited to your account.').replace(\n '{number}',\n invoice.correctsNumber,\n )\n : t('billing.pdf.memo.credited_to_account', 'The amount is credited to your account.'),\n ],\n }\n : null\n\n return {\n language: params.language,\n title: titleFor(invoice, t),\n number: invoice.number,\n watermark: invoice.testRun\n ? t('billing.pdf.watermark.test', 'TEST')\n : isDraft\n ? t('billing.pdf.watermark.draft', 'DRAFT')\n : null,\n meta,\n seller: { heading: t('billing.pdf.seller', 'Seller'), lines: sellerLines },\n buyer: { heading: t('billing.pdf.buyer', 'Buyer'), lines: buyerLines },\n table: {\n headers: [\n '#',\n t('billing.pdf.columns.description', 'Description'),\n t('billing.pdf.columns.quantity', 'Qty'),\n t('billing.pdf.columns.unit_net', 'Unit net'),\n t('billing.pdf.columns.tax_rate', 'VAT %'),\n t('billing.pdf.columns.net', 'Net'),\n t('billing.pdf.columns.tax', 'VAT'),\n t('billing.pdf.columns.gross', 'Gross'),\n ],\n align: ['right', 'left', 'right', 'right', 'right', 'right', 'right', 'right'],\n rows,\n },\n vatSummary: {\n heading: t('billing.pdf.vat_summary', 'VAT summary'),\n headers: [\n t('billing.pdf.columns.tax_rate', 'VAT %'),\n t('billing.pdf.columns.net', 'Net'),\n t('billing.pdf.columns.tax', 'VAT'),\n t('billing.pdf.columns.gross', 'Gross'),\n ],\n rows: vatRows,\n },\n totals,\n payment,\n footer: (seller.footer ?? '').split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean),\n pageLabel: (page, pages) =>\n t('billing.pdf.page', 'Page {page} of {pages}').replace('{page}', String(page)).replace('{pages}', String(pages)),\n }\n}\n"],
5
+ "mappings": "AAcA,SAAS,yBAAyB;AAkF3B,SAAS,eAAe,UAA0B;AACvD,SAAO,kBAAkB,QAAQ;AACnC;AAEA,SAAS,OAAO,OAAsD;AACpE,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,OAAO,UAAU,WAAW,IAAI,KAAK,MAAM,WAAW,KAAK,GAAG,KAAK,eAAe,KAAK,IAAI;AACxG,SAAO,OAAO,MAAM,KAAK,QAAQ,CAAC,IAAI,OAAO;AAC/C;AAEO,SAAS,mBAAmB,OAAyC,QAAwB;AAClG,QAAM,OAAO,OAAO,KAAK;AACzB,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,IAAI,KAAK,eAAe,QAAQ,EAAE,MAAM,WAAW,OAAO,WAAW,KAAK,WAAW,UAAU,MAAM,CAAC,EAAE;AAAA,IAC7G;AAAA,EACF;AACF;AAEO,SAAS,oBAAoB,OAAwB,UAAkB,QAAwB;AACpG,QAAM,IAAI,OAAO,UAAU,WAAW,QAAQ,OAAO,WAAW,KAAK;AACrE,QAAM,OAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AACtC,MAAI;AACF,WAAO,IAAI,KAAK,aAAa,QAAQ,EAAE,OAAO,YAAY,UAAU,uBAAuB,GAAG,uBAAuB,EAAE,CAAC,EAAE,OAAO,IAAI;AAAA,EACvI,QAAQ;AACN,WAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,IAAI,QAAQ;AAAA,EACvC;AACF;AAEA,SAAS,aAAa,OAAwB,QAAgB,cAAc,GAAW;AACrF,QAAM,IAAI,OAAO,UAAU,WAAW,QAAQ,OAAO,WAAW,KAAK;AACrE,SAAO,IAAI,KAAK,aAAa,QAAQ,EAAE,uBAAuB,GAAG,uBAAuB,YAAY,CAAC,EAAE;AAAA,IACrG,OAAO,SAAS,CAAC,IAAI,IAAI;AAAA,EAC3B;AACF;AAEA,SAAS,cAAc,MAAuB,QAAwB;AACpE,SAAO,GAAG,aAAa,MAAM,QAAQ,CAAC,CAAC;AACzC;AAGA,SAAS,MAAM,MAAsB;AACnC,SAAO,KAAK,QAAQ,SAAS,GAAG;AAClC;AAEA,SAAS,aAAa,SAAkD;AACtE,MAAI,CAAC,QAAS,QAAO,CAAC;AACtB,QAAM,WAAW,CAAC,QAAQ,aAAa,QAAQ,IAAI,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG;AAC7E,SAAO,CAAC,QAAQ,OAAO,QAAQ,OAAO,UAAU,QAAQ,OAAO,EAAE;AAAA,IAC/D,CAAC,SAAyB,OAAO,SAAS,YAAY,KAAK,KAAK,MAAM;AAAA,EACxE;AACF;AAEA,SAAS,SAAS,KAAiB,GAA8B;AAC/D,UAAQ,IAAI,cAAc;AAAA,IACxB,KAAK;AACH,aAAO,EAAE,iCAAiC,aAAa;AAAA,IACzD,KAAK;AACH,aAAO,EAAE,gCAAgC,YAAY;AAAA,IACvD,KAAK;AACH,aAAO,EAAE,6BAA6B,SAAS;AAAA,IACjD;AACE,aAAO,EAAE,6BAA6B,aAAa;AAAA,EACvD;AACF;AAEO,SAAS,0BAA0B,QAOjB;AACvB,QAAM,EAAE,SAAS,OAAO,QAAQ,OAAO,WAAW,EAAE,IAAI;AACxD,QAAM,SAAS,eAAe,OAAO,QAAQ;AAC7C,QAAM,QAAQ,CAAC,UAA2B,MAAM,oBAAoB,OAAO,QAAQ,cAAc,MAAM,CAAC;AACxG,QAAM,OAAO,CAAC,UAA4C,MAAM,mBAAmB,OAAO,MAAM,CAAC;AACjG,QAAM,UAAU,QAAQ,WAAW;AAEnC,QAAM,OAAgC;AAAA,IACpC,CAAC,EAAE,2BAA2B,QAAQ,GAAG,QAAQ,MAAM;AAAA,IACvD,CAAC,EAAE,+BAA+B,YAAY,GAAG,UAAU,WAAM,KAAK,QAAQ,SAAS,CAAC;AAAA,EAC1F;AACA,MAAI,QAAQ,iBAAiB,WAAW;AACtC,SAAK,KAAK,CAAC,EAAE,6BAA6B,UAAU,GAAG,UAAU,WAAM,KAAK,QAAQ,OAAO,CAAC,CAAC;AAAA,EAC/F;AACA,MAAI,QAAQ,eAAe,QAAQ,WAAW;AAC5C,SAAK,KAAK;AAAA,MACR,EAAE,mCAAmC,gBAAgB;AAAA,MACrD,GAAG,KAAK,QAAQ,WAAW,CAAC,WAAM,KAAK,QAAQ,SAAS,CAAC;AAAA,IAC3D,CAAC;AAAA,EACH;AACA,MAAI,QAAQ,gBAAgB;AAC1B,SAAK,KAAK;AAAA,MACR,EAAE,6BAA6B,UAAU;AAAA,MACzC,QAAQ,oBACJ,GAAG,QAAQ,cAAc,IAAI,EAAE,uBAAuB,IAAI,CAAC,IAAI,KAAK,QAAQ,iBAAiB,CAAC,KAC9F,QAAQ;AAAA,IACd,CAAC;AAAA,EACH;AACA,MAAI,QAAQ,kBAAkB;AAC5B,SAAK,KAAK,CAAC,EAAE,2BAA2B,QAAQ,GAAG,QAAQ,gBAAgB,CAAC;AAAA,EAC9E;AAEA,QAAM,cAAc;AAAA,IAClB,OAAO,aAAa;AAAA,IACpB,GAAG,aAAa,OAAO,OAAO;AAAA,IAC9B,OAAO,QAAQ,GAAG,EAAE,sBAAsB,QAAQ,CAAC,KAAK,OAAO,KAAK,KAAK;AAAA,EAC3E,EAAE,OAAO,CAAC,SAAS,SAAS,EAAE;AAC9B,QAAM,aAAa;AAAA,IACjB,MAAM;AAAA,IACN,GAAG,aAAa,MAAM,OAAO;AAAA,IAC7B,MAAM,QAAQ,GAAG,EAAE,sBAAsB,QAAQ,CAAC,KAAK,MAAM,KAAK,KAAK;AAAA,EACzE,EAAE,OAAO,CAAC,SAAS,SAAS,EAAE;AAE9B,QAAM,OAAO,MAAM,IAAI,CAAC,MAAM,UAAU;AACtC,UAAM,QAAQ,KAAK;AACnB,UAAM,MAAM,KAAK;AACjB,UAAM,cACJ,SAAS,OAAO,CAAC,KAAK,YAAY,SAAS,UAAK,IAC5C,GAAG,KAAK,WAAW,KAAK,KAAK,KAAK,CAAC,WAAM,KAAK,GAAG,CAAC,MAClD,KAAK;AACX,WAAO;AAAA,MACL,OAAO,QAAQ,CAAC;AAAA,MAChB;AAAA,MACA,MAAM,aAAa,KAAK,UAAU,MAAM,CAAC;AAAA,MACzC,MAAM,KAAK,YAAY;AAAA,MACvB,MAAM,cAAc,KAAK,SAAS,MAAM,CAAC;AAAA,MACzC,MAAM,KAAK,SAAS;AAAA,MACpB,MAAM,KAAK,SAAS;AAAA,MACpB,MAAM,KAAK,WAAW;AAAA,IACxB;AAAA,EACF,CAAC;AAID,QAAM,SAAS,oBAAI,IAAyD;AAC5E,aAAW,QAAQ,OAAO;AACxB,UAAM,OAAO,OAAO,WAAW,KAAK,OAAO,KAAK;AAChD,UAAM,SAAS,OAAO,IAAI,IAAI,KAAK,EAAE,KAAK,GAAG,KAAK,GAAG,OAAO,EAAE;AAC9D,WAAO,OAAO,OAAO,WAAW,KAAK,SAAS,KAAK;AACnD,WAAO,OAAO,OAAO,WAAW,KAAK,SAAS,KAAK;AACnD,WAAO,SAAS,OAAO,WAAW,KAAK,WAAW,KAAK;AACvD,WAAO,IAAI,MAAM,MAAM;AAAA,EACzB;AACA,QAAM,UAAU,CAAC,GAAG,OAAO,QAAQ,CAAC,EACjC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,EACxB,IAAI,CAAC,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,cAAc,MAAM,MAAM,CAAC,GAAG,MAAM,KAAK,GAAG,GAAG,MAAM,KAAK,GAAG,GAAG,MAAM,KAAK,KAAK,CAAC,CAAC;AAElH,QAAM,WAAW,QAAQ,iBAAiB;AAC1C,QAAM,SAAkC;AAAA,IACtC,CAAC,EAAE,0BAA0B,WAAW,GAAG,MAAM,QAAQ,WAAW,CAAC;AAAA,IACrE,CAAC,EAAE,0BAA0B,WAAW,GAAG,MAAM,QAAQ,QAAQ,CAAC;AAAA,IAClE,WACI,CAAC,EAAE,+BAA+B,gBAAgB,GAAG,MAAM,QAAQ,UAAU,CAAC,IAC9E,CAAC,EAAE,4BAA4B,WAAW,GAAG,MAAM,QAAQ,UAAU,CAAC;AAAA,EAC5E;AAEA,QAAM,UAAU,QAAQ,iBAAiB,aAAa,QAAQ,iBAAiB;AAC/E,QAAM,UACJ,WAAW,OAAO,OACd;AAAA,IACE,SAAS,EAAE,+BAA+B,SAAS;AAAA,IACnD,OAAO;AAAA,MACL,GAAG,EAAE,8BAA8B,QAAQ,CAAC,KAAK,MAAM,QAAQ,UAAU,CAAC;AAAA,MAC1E,GAAG,EAAE,2BAA2B,QAAQ,CAAC,KAAK,UAAU,WAAM,KAAK,QAAQ,OAAO,CAAC;AAAA,MACnF,OAAO,WAAW,GAAG,EAAE,4BAA4B,MAAM,CAAC,KAAK,OAAO,QAAQ,KAAK;AAAA,MACnF,SAAS,OAAO,KAAK,QAAQ,WAAW,KAAK,EAAE,KAAK,CAAC;AAAA,MACrD,OAAO,MAAM,QAAQ,OAAO,GAAG,KAAK;AAAA;AAAA,MAEpC,GAAG,EAAE,6BAA6B,gBAAgB,CAAC,KAAK,QAAQ,MAAM;AAAA,IACxE,EAAE,OAAO,CAAC,SAAS,SAAS,EAAE;AAAA,EAChC,IACA,WACE;AAAA;AAAA;AAAA,IAGE,SAAS,EAAE,+BAA+B,YAAY;AAAA,IACtD,OAAO;AAAA,MACL,GAAG,EAAE,oCAAoC,iBAAiB,CAAC,KAAK,MAAM,QAAQ,UAAU,CAAC;AAAA,MACzF,QAAQ,iBACJ,EAAE,oCAAoC,8EAA8E,EAAE;AAAA,QACpH;AAAA,QACA,QAAQ;AAAA,MACV,IACA,EAAE,wCAAwC,yCAAyC;AAAA,IACzF;AAAA,EACF,IACA;AAER,SAAO;AAAA,IACL,UAAU,OAAO;AAAA,IACjB,OAAO,SAAS,SAAS,CAAC;AAAA,IAC1B,QAAQ,QAAQ;AAAA,IAChB,WAAW,QAAQ,UACf,EAAE,8BAA8B,MAAM,IACtC,UACE,EAAE,+BAA+B,OAAO,IACxC;AAAA,IACN;AAAA,IACA,QAAQ,EAAE,SAAS,EAAE,sBAAsB,QAAQ,GAAG,OAAO,YAAY;AAAA,IACzE,OAAO,EAAE,SAAS,EAAE,qBAAqB,OAAO,GAAG,OAAO,WAAW;AAAA,IACrE,OAAO;AAAA,MACL,SAAS;AAAA,QACP;AAAA,QACA,EAAE,mCAAmC,aAAa;AAAA,QAClD,EAAE,gCAAgC,KAAK;AAAA,QACvC,EAAE,gCAAgC,UAAU;AAAA,QAC5C,EAAE,gCAAgC,OAAO;AAAA,QACzC,EAAE,2BAA2B,KAAK;AAAA,QAClC,EAAE,2BAA2B,KAAK;AAAA,QAClC,EAAE,6BAA6B,OAAO;AAAA,MACxC;AAAA,MACA,OAAO,CAAC,SAAS,QAAQ,SAAS,SAAS,SAAS,SAAS,SAAS,OAAO;AAAA,MAC7E;AAAA,IACF;AAAA,IACA,YAAY;AAAA,MACV,SAAS,EAAE,2BAA2B,aAAa;AAAA,MACnD,SAAS;AAAA,QACP,EAAE,gCAAgC,OAAO;AAAA,QACzC,EAAE,2BAA2B,KAAK;AAAA,QAClC,EAAE,2BAA2B,KAAK;AAAA,QAClC,EAAE,6BAA6B,OAAO;AAAA,MACxC;AAAA,MACA,MAAM;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA,SAAS,OAAO,UAAU,IAAI,MAAM,OAAO,EAAE,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,IACtF,WAAW,CAAC,MAAM,UAChB,EAAE,oBAAoB,wBAAwB,EAAE,QAAQ,UAAU,OAAO,IAAI,CAAC,EAAE,QAAQ,WAAW,OAAO,KAAK,CAAC;AAAA,EACpH;AACF;",
6
6
  "names": []
7
7
  }
@@ -2,14 +2,14 @@ import { BillingAccount, BillingInvoice, BillingInvoiceLine } from "../../data/e
2
2
  import { billingEntityIds } from "../../data/entityIds.js";
3
3
  import { buildSellerSnapshot, getBillingSettings } from "../billingSettings.js";
4
4
  import { buildBuyerSnapshot } from "../documentParties.js";
5
- import { DEFAULT_INVOICE_LANGUAGE } from "../invoiceLanguages.js";
5
+ import { resolveInvoiceLanguage } from "../invoiceLanguages.js";
6
6
  import { buildInvoiceDocumentModel } from "./invoiceModel.js";
7
7
  import { renderInvoicePdf } from "./invoiceTemplate.js";
8
8
  import { attachmentIdFromUrl, readAttachment, storeAttachment } from "./attachmentStore.js";
9
9
  const loadDocumentTranslator = async (language) => {
10
10
  const { loadDictionary } = await import("@open-mercato/shared/lib/i18n/server");
11
11
  const { createFallbackTranslator } = await import("@open-mercato/shared/lib/i18n/translate");
12
- const locale = language === "pl" ? "pl" : "en";
12
+ const locale = resolveInvoiceLanguage(language);
13
13
  const translate = createFallbackTranslator(await loadDictionary(locale));
14
14
  return (key, fallback) => translate(key, fallback);
15
15
  };
@@ -57,7 +57,7 @@ async function renderBillingDocument(em, invoice, deps = {}) {
57
57
  { orderBy: { lineNumber: "asc" } }
58
58
  );
59
59
  const { seller, buyer } = await resolveParties(em, invoice);
60
- const language = str(buyer.invoice_language) ?? DEFAULT_INVOICE_LANGUAGE;
60
+ const language = resolveInvoiceLanguage(str(buyer.invoice_language));
61
61
  const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language);
62
62
  let correctsNumber = null;
63
63
  if (invoice.correctsInvoiceId) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/billing/lib/pdf/invoicePdf.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillingAccount, BillingInvoice, BillingInvoiceLine } from '../../data/entities'\nimport { billingEntityIds } from '../../data/entityIds'\nimport { buildSellerSnapshot, getBillingSettings } from '../billingSettings'\nimport { buildBuyerSnapshot } from '../documentParties'\nimport { DEFAULT_INVOICE_LANGUAGE } from '../invoiceLanguages'\nimport { buildInvoiceDocumentModel, type DocumentTranslate, type PdfAddress } from './invoiceModel'\nimport { renderInvoicePdf, type PdfLogo } from './invoiceTemplate'\nimport { attachmentIdFromUrl, readAttachment, storeAttachment } from './attachmentStore'\n\n/**\n * Billing document PDFs (Spec B, P-D1 / B-D5):\n *\n * - posted documents are rendered once and frozen as an attachment\n * (`pdf_attachment_id`); later downloads stream the stored file, so the\n * document never changes after issue. If freezing failed at post, the\n * first download renders and freezes it (lazy fallback).\n * - drafts and test documents are rendered live with a DRAFT / TEST\n * watermark and never stored.\n *\n * Parties come from the frozen snapshots on posted documents and from the\n * live account / settings on drafts. Labels use the document language\n * (`invoice_language`), never the operator's UI locale.\n */\n\nexport type TranslatorLoader = (language: string) => Promise<DocumentTranslate>\n\n/** Billing i18n for a document language (en / pl, falling back to en). */\nexport const loadDocumentTranslator: TranslatorLoader = async (language) => {\n const { loadDictionary } = await import('@open-mercato/shared/lib/i18n/server')\n const { createFallbackTranslator } = await import('@open-mercato/shared/lib/i18n/translate')\n const locale = language === 'pl' ? 'pl' : 'en'\n const translate = createFallbackTranslator(await loadDictionary(locale))\n return (key, fallback) => translate(key, fallback)\n}\n\ntype Snapshot = Record<string, unknown>\n\nfunction str(value: unknown): string | null {\n return typeof value === 'string' && value.trim() !== '' ? value : null\n}\n\nfunction address(value: unknown): PdfAddress | null {\n return value && typeof value === 'object' ? (value as PdfAddress) : null\n}\n\nexport function pdfFileName(invoice: Pick<BillingInvoice, 'number'>): string {\n return `${invoice.number.replace(/[^A-Za-z0-9._-]+/g, '-')}.pdf`\n}\n\nfunction isFrozenDocument(invoice: Pick<BillingInvoice, 'status' | 'testRun'>): boolean {\n return invoice.status !== 'draft' && !invoice.testRun\n}\n\nasync function resolveParties(em: EntityManager, invoice: BillingInvoice) {\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n let seller: Snapshot | null = isFrozenDocument(invoice) ? (invoice.sellerSnapshot ?? null) : null\n let buyer: Snapshot | null = isFrozenDocument(invoice) ? (invoice.buyerSnapshot ?? null) : null\n if (!seller || !('legal_name' in seller)) seller = buildSellerSnapshot(await getBillingSettings(em, scope))\n if (!buyer) {\n const account = await em.findOne(BillingAccount, { id: invoice.billAccountId, ...scope } as never)\n buyer = account ? buildBuyerSnapshot(account) : { name: '' }\n }\n return { seller, buyer }\n}\n\nasync function resolveLogo(em: EntityManager, invoice: BillingInvoice, seller: Snapshot): Promise<PdfLogo | null> {\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n let attachmentId = str(seller.logo_attachment_id)\n if (!attachmentId) {\n // Fallback: the organization logo, only when it is one of our own\n // attachments (never fetch arbitrary URLs server-side).\n const rows = (await em.execute(`SELECT logo_url FROM organizations WHERE id = ? LIMIT 1`, [\n invoice.organizationId,\n ])) as unknown as Array<{ logo_url: string | null }>\n attachmentId = attachmentIdFromUrl(rows?.[0]?.logo_url)\n }\n if (!attachmentId) return null\n const file = await readAttachment(em, scope, attachmentId)\n return file ? { bytes: file.bytes, mimeType: file.mimeType } : null\n}\n\n/** Renders a billing document as it should print now (no storage). */\nexport async function renderBillingDocument(\n em: EntityManager,\n invoice: BillingInvoice,\n deps: { loadTranslator?: TranslatorLoader } = {},\n): Promise<Uint8Array> {\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n const lines = await em.find(\n BillingInvoiceLine,\n { invoiceId: invoice.id, ...scope, deletedAt: null } as never,\n { orderBy: { lineNumber: 'asc' } as never },\n )\n const { seller, buyer } = await resolveParties(em, invoice)\n const language = str(buyer.invoice_language) ?? DEFAULT_INVOICE_LANGUAGE\n const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language)\n\n let correctsNumber: string | null = null\n if (invoice.correctsInvoiceId) {\n const corrected = await em.findOne(BillingInvoice, { id: invoice.correctsInvoiceId, ...scope } as never)\n correctsNumber = corrected?.number ?? null\n }\n\n const model = buildInvoiceDocumentModel({\n invoice: {\n number: invoice.number,\n documentType: invoice.documentType as 'invoice' | 'credit_memo' | 'debit_memo' | 'receipt',\n status: invoice.status,\n testRun: invoice.testRun,\n issueDate: invoice.issueDate ?? null,\n dueDate: invoice.dueDate ?? null,\n billDate: invoice.billDate ?? null,\n periodStart: invoice.periodStart ?? null,\n periodEnd: invoice.periodEnd ?? null,\n currencyCode: invoice.currencyCode,\n subtotalNet: invoice.subtotalNet,\n taxTotal: invoice.taxTotal,\n totalGross: invoice.totalGross,\n correctsNumber: invoice.correctsInvoiceNumber ?? correctsNumber,\n correctsIssueDate: invoice.correctsIssueDate ?? null,\n correctionReason: invoice.correctionReason ?? null,\n },\n lines: lines.map((line) => ({\n description: line.description,\n quantity: line.quantity,\n unitPriceNet: line.unitPriceNet,\n taxRate: line.taxRate,\n netAmount: line.netAmount,\n taxAmount: line.taxAmount,\n grossAmount: line.grossAmount,\n servicePeriodStart: line.servicePeriodStart ?? null,\n servicePeriodEnd: line.servicePeriodEnd ?? null,\n })),\n seller: {\n legalName: str(seller.legal_name),\n address: address(seller.address),\n taxId: str(seller.tax_id),\n bankName: str(seller.bank_name),\n iban: str(seller.iban),\n bic: str(seller.bic),\n footer: str(seller.invoice_footer),\n },\n buyer: {\n name: str(buyer.name) ?? '',\n address: address(buyer.invoice_address),\n taxId: str(buyer.tax_id),\n },\n language,\n translate,\n })\n return renderInvoicePdf(model, { logo: await resolveLogo(em, invoice, seller) })\n}\n\n/**\n * Renders and stores the PDF of a posted document once. Idempotent: returns\n * the existing attachment id when already frozen. The column is claimed with\n * a conditional update, so concurrent freezes never overwrite each other.\n */\nexport async function freezeInvoicePdf(\n em: EntityManager,\n invoice: BillingInvoice,\n deps: { loadTranslator?: TranslatorLoader } = {},\n): Promise<{ attachmentId: string; bytes: Uint8Array | null }> {\n if (invoice.pdfAttachmentId) return { attachmentId: invoice.pdfAttachmentId, bytes: null }\n if (!isFrozenDocument(invoice)) throw new Error('Only posted, non-test documents are frozen')\n const bytes = await renderBillingDocument(em, invoice, deps)\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n const attachmentId = await storeAttachment(em, scope, {\n entityId: billingEntityIds.invoice,\n recordId: invoice.id,\n fileName: pdfFileName(invoice),\n mimeType: 'application/pdf',\n bytes,\n })\n await em.nativeUpdate(\n BillingInvoice,\n { id: invoice.id, ...scope, pdfAttachmentId: null } as never,\n { pdfAttachmentId: attachmentId } as never,\n )\n invoice.pdfAttachmentId = attachmentId\n return { attachmentId, bytes }\n}\n\n/**\n * The PDF to serve / attach: the frozen file for posted documents (frozen\n * now if it never was), a live watermarked render for drafts and tests.\n */\nexport async function getInvoicePdf(\n em: EntityManager,\n invoice: BillingInvoice,\n deps: { loadTranslator?: TranslatorLoader } = {},\n): Promise<{ bytes: Uint8Array; fileName: string; frozen: boolean }> {\n const fileName = pdfFileName(invoice)\n if (!isFrozenDocument(invoice)) {\n return { bytes: await renderBillingDocument(em, invoice, deps), fileName, frozen: false }\n }\n if (invoice.pdfAttachmentId) {\n const stored = await readAttachment(\n em,\n { tenantId: invoice.tenantId, organizationId: invoice.organizationId },\n invoice.pdfAttachmentId,\n )\n if (stored) return { bytes: stored.bytes, fileName, frozen: true }\n // Stored file lost (storage moved / purged): serve a fresh render from\n // the frozen snapshots rather than failing; the attachment id is kept.\n console.warn('[billing.pdf] frozen PDF unreadable, rendering from snapshots', invoice.id)\n return { bytes: await renderBillingDocument(em, invoice, deps), fileName, frozen: true }\n }\n const frozen = await freezeInvoicePdf(em, invoice, deps)\n return {\n bytes: frozen.bytes ?? (await renderBillingDocument(em, invoice, deps)),\n fileName,\n frozen: true,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillingAccount, BillingInvoice, BillingInvoiceLine } from '../../data/entities'\nimport { billingEntityIds } from '../../data/entityIds'\nimport { buildSellerSnapshot, getBillingSettings } from '../billingSettings'\nimport { buildBuyerSnapshot } from '../documentParties'\nimport { resolveInvoiceLanguage } from '../invoiceLanguages'\nimport { buildInvoiceDocumentModel, type DocumentTranslate, type PdfAddress } from './invoiceModel'\nimport { renderInvoicePdf, type PdfLogo } from './invoiceTemplate'\nimport { attachmentIdFromUrl, readAttachment, storeAttachment } from './attachmentStore'\n\n/**\n * Billing document PDFs (Spec B, P-D1 / B-D5):\n *\n * - posted documents are rendered once and frozen as an attachment\n * (`pdf_attachment_id`); later downloads stream the stored file, so the\n * document never changes after issue. If freezing failed at post, the\n * first download renders and freezes it (lazy fallback).\n * - drafts and test documents are rendered live with a DRAFT / TEST\n * watermark and never stored.\n *\n * Parties come from the frozen snapshots on posted documents and from the\n * live account / settings on drafts. Labels use the document language\n * (`invoice_language`), never the operator's UI locale.\n */\n\nexport type TranslatorLoader = (language: string) => Promise<DocumentTranslate>\n\n/**\n * Billing i18n for a document language. Any supported invoice language loads\n * its own dictionary (registry in `../invoiceLanguages`); anything else falls\n * back to the default language.\n */\nexport const loadDocumentTranslator: TranslatorLoader = async (language) => {\n const { loadDictionary } = await import('@open-mercato/shared/lib/i18n/server')\n const { createFallbackTranslator } = await import('@open-mercato/shared/lib/i18n/translate')\n const locale = resolveInvoiceLanguage(language)\n const translate = createFallbackTranslator(await loadDictionary(locale))\n return (key, fallback) => translate(key, fallback)\n}\n\ntype Snapshot = Record<string, unknown>\n\nfunction str(value: unknown): string | null {\n return typeof value === 'string' && value.trim() !== '' ? value : null\n}\n\nfunction address(value: unknown): PdfAddress | null {\n return value && typeof value === 'object' ? (value as PdfAddress) : null\n}\n\nexport function pdfFileName(invoice: Pick<BillingInvoice, 'number'>): string {\n return `${invoice.number.replace(/[^A-Za-z0-9._-]+/g, '-')}.pdf`\n}\n\nfunction isFrozenDocument(invoice: Pick<BillingInvoice, 'status' | 'testRun'>): boolean {\n return invoice.status !== 'draft' && !invoice.testRun\n}\n\nasync function resolveParties(em: EntityManager, invoice: BillingInvoice) {\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n let seller: Snapshot | null = isFrozenDocument(invoice) ? (invoice.sellerSnapshot ?? null) : null\n let buyer: Snapshot | null = isFrozenDocument(invoice) ? (invoice.buyerSnapshot ?? null) : null\n if (!seller || !('legal_name' in seller)) seller = buildSellerSnapshot(await getBillingSettings(em, scope))\n if (!buyer) {\n const account = await em.findOne(BillingAccount, { id: invoice.billAccountId, ...scope } as never)\n buyer = account ? buildBuyerSnapshot(account) : { name: '' }\n }\n return { seller, buyer }\n}\n\nasync function resolveLogo(em: EntityManager, invoice: BillingInvoice, seller: Snapshot): Promise<PdfLogo | null> {\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n let attachmentId = str(seller.logo_attachment_id)\n if (!attachmentId) {\n // Fallback: the organization logo, only when it is one of our own\n // attachments (never fetch arbitrary URLs server-side).\n const rows = (await em.execute(`SELECT logo_url FROM organizations WHERE id = ? LIMIT 1`, [\n invoice.organizationId,\n ])) as unknown as Array<{ logo_url: string | null }>\n attachmentId = attachmentIdFromUrl(rows?.[0]?.logo_url)\n }\n if (!attachmentId) return null\n const file = await readAttachment(em, scope, attachmentId)\n return file ? { bytes: file.bytes, mimeType: file.mimeType } : null\n}\n\n/** Renders a billing document as it should print now (no storage). */\nexport async function renderBillingDocument(\n em: EntityManager,\n invoice: BillingInvoice,\n deps: { loadTranslator?: TranslatorLoader } = {},\n): Promise<Uint8Array> {\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n const lines = await em.find(\n BillingInvoiceLine,\n { invoiceId: invoice.id, ...scope, deletedAt: null } as never,\n { orderBy: { lineNumber: 'asc' } as never },\n )\n const { seller, buyer } = await resolveParties(em, invoice)\n const language = resolveInvoiceLanguage(str(buyer.invoice_language))\n const translate = await (deps.loadTranslator ?? loadDocumentTranslator)(language)\n\n let correctsNumber: string | null = null\n if (invoice.correctsInvoiceId) {\n const corrected = await em.findOne(BillingInvoice, { id: invoice.correctsInvoiceId, ...scope } as never)\n correctsNumber = corrected?.number ?? null\n }\n\n const model = buildInvoiceDocumentModel({\n invoice: {\n number: invoice.number,\n documentType: invoice.documentType as 'invoice' | 'credit_memo' | 'debit_memo' | 'receipt',\n status: invoice.status,\n testRun: invoice.testRun,\n issueDate: invoice.issueDate ?? null,\n dueDate: invoice.dueDate ?? null,\n billDate: invoice.billDate ?? null,\n periodStart: invoice.periodStart ?? null,\n periodEnd: invoice.periodEnd ?? null,\n currencyCode: invoice.currencyCode,\n subtotalNet: invoice.subtotalNet,\n taxTotal: invoice.taxTotal,\n totalGross: invoice.totalGross,\n correctsNumber: invoice.correctsInvoiceNumber ?? correctsNumber,\n correctsIssueDate: invoice.correctsIssueDate ?? null,\n correctionReason: invoice.correctionReason ?? null,\n },\n lines: lines.map((line) => ({\n description: line.description,\n quantity: line.quantity,\n unitPriceNet: line.unitPriceNet,\n taxRate: line.taxRate,\n netAmount: line.netAmount,\n taxAmount: line.taxAmount,\n grossAmount: line.grossAmount,\n servicePeriodStart: line.servicePeriodStart ?? null,\n servicePeriodEnd: line.servicePeriodEnd ?? null,\n })),\n seller: {\n legalName: str(seller.legal_name),\n address: address(seller.address),\n taxId: str(seller.tax_id),\n bankName: str(seller.bank_name),\n iban: str(seller.iban),\n bic: str(seller.bic),\n footer: str(seller.invoice_footer),\n },\n buyer: {\n name: str(buyer.name) ?? '',\n address: address(buyer.invoice_address),\n taxId: str(buyer.tax_id),\n },\n language,\n translate,\n })\n return renderInvoicePdf(model, { logo: await resolveLogo(em, invoice, seller) })\n}\n\n/**\n * Renders and stores the PDF of a posted document once. Idempotent: returns\n * the existing attachment id when already frozen. The column is claimed with\n * a conditional update, so concurrent freezes never overwrite each other.\n */\nexport async function freezeInvoicePdf(\n em: EntityManager,\n invoice: BillingInvoice,\n deps: { loadTranslator?: TranslatorLoader } = {},\n): Promise<{ attachmentId: string; bytes: Uint8Array | null }> {\n if (invoice.pdfAttachmentId) return { attachmentId: invoice.pdfAttachmentId, bytes: null }\n if (!isFrozenDocument(invoice)) throw new Error('Only posted, non-test documents are frozen')\n const bytes = await renderBillingDocument(em, invoice, deps)\n const scope = { tenantId: invoice.tenantId, organizationId: invoice.organizationId }\n const attachmentId = await storeAttachment(em, scope, {\n entityId: billingEntityIds.invoice,\n recordId: invoice.id,\n fileName: pdfFileName(invoice),\n mimeType: 'application/pdf',\n bytes,\n })\n await em.nativeUpdate(\n BillingInvoice,\n { id: invoice.id, ...scope, pdfAttachmentId: null } as never,\n { pdfAttachmentId: attachmentId } as never,\n )\n invoice.pdfAttachmentId = attachmentId\n return { attachmentId, bytes }\n}\n\n/**\n * The PDF to serve / attach: the frozen file for posted documents (frozen\n * now if it never was), a live watermarked render for drafts and tests.\n */\nexport async function getInvoicePdf(\n em: EntityManager,\n invoice: BillingInvoice,\n deps: { loadTranslator?: TranslatorLoader } = {},\n): Promise<{ bytes: Uint8Array; fileName: string; frozen: boolean }> {\n const fileName = pdfFileName(invoice)\n if (!isFrozenDocument(invoice)) {\n return { bytes: await renderBillingDocument(em, invoice, deps), fileName, frozen: false }\n }\n if (invoice.pdfAttachmentId) {\n const stored = await readAttachment(\n em,\n { tenantId: invoice.tenantId, organizationId: invoice.organizationId },\n invoice.pdfAttachmentId,\n )\n if (stored) return { bytes: stored.bytes, fileName, frozen: true }\n // Stored file lost (storage moved / purged): serve a fresh render from\n // the frozen snapshots rather than failing; the attachment id is kept.\n console.warn('[billing.pdf] frozen PDF unreadable, rendering from snapshots', invoice.id)\n return { bytes: await renderBillingDocument(em, invoice, deps), fileName, frozen: true }\n }\n const frozen = await freezeInvoicePdf(em, invoice, deps)\n return {\n bytes: frozen.bytes ?? (await renderBillingDocument(em, invoice, deps)),\n fileName,\n frozen: true,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -268,7 +268,9 @@ async function renderInvoicePdf(model, opts = {}) {
268
268
  return doc.save();
269
269
  }
270
270
  export {
271
+ TABLE_COLUMNS,
271
272
  renderInvoicePdf,
272
- sanitizeForFont
273
+ sanitizeForFont,
274
+ wrap
273
275
  };
274
276
  //# sourceMappingURL=invoiceTemplate.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/billing/lib/pdf/invoiceTemplate.ts"],
4
- "sourcesContent": ["import { PDFDocument, degrees, rgb, type PDFFont, type PDFImage, type PDFPage } from 'pdf-lib'\nimport fontkit from '@pdf-lib/fontkit'\nimport { NOTO_SANS_BOLD_BASE64, NOTO_SANS_REGULAR_BASE64 } from './fonts/embeddedFonts'\nimport type { InvoiceDocumentModel } from './invoiceModel'\n\n/**\n * Built-in A4 invoice template (Spec B) \u2014 lays out an `InvoiceDocumentModel`\n * with pdf-lib and an embedded Unicode font (Noto Sans subset, so Polish\n * diacritics render \u2014 the standard Helvetica font cannot).\n *\n * Layout: logo + title | seller / buyer blocks | meta rows | lines table\n * (wrapped descriptions, header repeated on every page) | VAT summary |\n * totals | payment instructions | footer | \"Page n of m\" | optional\n * diagonal watermark (DRAFT / TEST). Characters the subset font lacks are\n * replaced with \"?\" so rendering never throws on exotic customer text.\n */\n\nexport type PdfLogo = { bytes: Uint8Array; mimeType: string }\n\nconst PAGE_WIDTH = 595.28\nconst PAGE_HEIGHT = 841.89\nconst MARGIN = 40\nconst CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN\nconst FOOTER_HEIGHT = 48\nconst BODY_SIZE = 9\nconst SMALL_SIZE = 8\nconst LINE_GAP = 3\n\n// # | description | qty | unit net | VAT % | net | VAT | gross (sums to CONTENT_WIDTH)\nconst TABLE_COLUMNS = [24, 179, 30, 62, 36, 62, 56, 66.28]\n\nconst TEXT = rgb(0.12, 0.12, 0.14)\nconst MUTED = rgb(0.42, 0.42, 0.46)\nconst RULE = rgb(0.82, 0.82, 0.85)\nconst HEADER_FILL = rgb(0.95, 0.95, 0.96)\nconst WATERMARK = rgb(0.85, 0.85, 0.87)\n\ntype Fonts = { regular: PDFFont; bold: PDFFont; charset: Set<number> }\n\nfunction base64ToBytes(value: string): Uint8Array {\n return Uint8Array.from(Buffer.from(value, 'base64'))\n}\n\n/** Replaces characters the embedded subset cannot draw. */\nexport function sanitizeForFont(text: string, charset: Set<number>): string {\n let out = ''\n for (const char of text.replace(/\\t/g, ' ')) {\n const code = char.codePointAt(0) ?? 63\n out += code === 10 || charset.has(code) ? char : '?'\n }\n return out\n}\n\nfunction wrap(text: string, font: PDFFont, size: number, width: number): string[] {\n const lines: string[] = []\n for (const paragraph of text.split('\\n')) {\n let current = ''\n for (const word of paragraph.split(/\\s+/).filter(Boolean)) {\n const candidate = current ? `${current} ${word}` : word\n if (font.widthOfTextAtSize(candidate, size) <= width) {\n current = candidate\n continue\n }\n if (current) lines.push(current)\n // A single word wider than the column is split by characters.\n let chunk = ''\n for (const char of word) {\n if (font.widthOfTextAtSize(chunk + char, size) > width && chunk) {\n lines.push(chunk)\n chunk = char\n } else {\n chunk += char\n }\n }\n current = chunk\n }\n lines.push(current)\n }\n return lines\n}\n\nclass Layout {\n readonly pages: PDFPage[] = []\n page!: PDFPage\n y = 0\n\n constructor(\n private readonly doc: PDFDocument,\n private readonly fonts: Fonts,\n ) {\n this.newPage()\n }\n\n get boldFont(): PDFFont {\n return this.fonts.bold\n }\n\n newPage(): void {\n this.page = this.doc.addPage([PAGE_WIDTH, PAGE_HEIGHT])\n this.pages.push(this.page)\n this.y = PAGE_HEIGHT - MARGIN\n }\n\n /** Starts a new page when fewer than `height` points remain above the footer. */\n ensure(height: number): boolean {\n if (this.y - height < MARGIN + FOOTER_HEIGHT) {\n this.newPage()\n return true\n }\n return false\n }\n\n text(value: string, x: number, opts: { size?: number; bold?: boolean; color?: ReturnType<typeof rgb>; y?: number } = {}) {\n const size = opts.size ?? BODY_SIZE\n this.page.drawText(sanitizeForFont(value, this.fonts.charset), {\n x,\n y: opts.y ?? this.y,\n size,\n font: opts.bold ? this.fonts.bold : this.fonts.regular,\n color: opts.color ?? TEXT,\n })\n }\n\n width(value: string, size: number, bold = false): number {\n return (bold ? this.fonts.bold : this.fonts.regular).widthOfTextAtSize(sanitizeForFont(value, this.fonts.charset), size)\n }\n\n rule(y = this.y): void {\n this.page.drawLine({ start: { x: MARGIN, y }, end: { x: PAGE_WIDTH - MARGIN, y }, thickness: 0.5, color: RULE })\n }\n}\n\nfunction drawTableHeader(layout: Layout, headers: string[], align: Array<'left' | 'right'>, columns: number[], x0 = MARGIN) {\n // Headers wrap inside their column (Polish labels are long: \"Cena netto\").\n const wrapped = headers.map((header, i) => wrap(header, layout.boldFont, SMALL_SIZE, columns[i] - 6))\n const lineHeight = SMALL_SIZE + 2\n const height = Math.max(...wrapped.map((lines) => lines.length)) * lineHeight + 8\n const total = columns.reduce((sum, w) => sum + w, 0)\n layout.page.drawRectangle({ x: x0, y: layout.y - height + 4, width: total, height, color: HEADER_FILL })\n let x = x0\n wrapped.forEach((lines, i) => {\n const w = columns[i]\n lines.forEach((line, li) => {\n const tx = align[i] === 'right' ? x + w - 3 - layout.width(line, SMALL_SIZE, true) : x + 3\n layout.text(line, tx, { size: SMALL_SIZE, bold: true, y: layout.y - SMALL_SIZE - 1 - li * lineHeight })\n })\n x += w\n })\n layout.y -= height + 2\n}\n\nfunction drawRows(\n layout: Layout,\n rows: string[][],\n headers: string[],\n align: Array<'left' | 'right'>,\n columns: number[],\n fonts: Fonts,\n x0 = MARGIN,\n) {\n const lineHeight = BODY_SIZE + LINE_GAP\n for (const row of rows) {\n const cells = row.map((cell, i) =>\n wrap(sanitizeForFont(cell, fonts.charset), fonts.regular, BODY_SIZE, columns[i] - 6),\n )\n const rowHeight = Math.max(...cells.map((c) => c.length)) * lineHeight + 4\n if (layout.ensure(rowHeight)) drawTableHeader(layout, headers, align, columns, x0)\n let x = x0\n cells.forEach((cellLines, i) => {\n const w = columns[i]\n cellLines.forEach((line, li) => {\n const tx = align[i] === 'right' ? x + w - 3 - layout.width(line, BODY_SIZE) : x + 3\n layout.text(line, tx, { y: layout.y - BODY_SIZE - li * lineHeight })\n })\n x += w\n })\n layout.y -= rowHeight\n layout.page.drawLine({\n start: { x: x0, y: layout.y + 1 },\n end: { x: x0 + columns.reduce((s, w) => s + w, 0), y: layout.y + 1 },\n thickness: 0.3,\n color: RULE,\n })\n }\n}\n\nfunction drawBlock(layout: Layout, heading: string, lines: string[], x: number, width: number, top: number, fonts: Fonts): number {\n let y = top\n layout.text(heading.toUpperCase(), x, { size: SMALL_SIZE, bold: true, color: MUTED, y })\n y -= SMALL_SIZE + 6\n lines.forEach((line, index) => {\n for (const part of wrap(sanitizeForFont(line, fonts.charset), index === 0 ? fonts.bold : fonts.regular, BODY_SIZE, width)) {\n layout.text(part, x, { bold: index === 0, y })\n y -= BODY_SIZE + LINE_GAP\n }\n })\n return y\n}\n\nasync function embedLogo(doc: PDFDocument, logo: PdfLogo | null | undefined): Promise<PDFImage | null> {\n if (!logo) return null\n try {\n if (logo.mimeType === 'image/png') return await doc.embedPng(logo.bytes)\n if (logo.mimeType === 'image/jpeg' || logo.mimeType === 'image/jpg') return await doc.embedJpg(logo.bytes)\n } catch (err) {\n console.warn('[billing.pdf] logo could not be embedded', err)\n }\n return null\n}\n\nexport async function renderInvoicePdf(model: InvoiceDocumentModel, opts: { logo?: PdfLogo | null } = {}): Promise<Uint8Array> {\n const doc = await PDFDocument.create()\n doc.registerFontkit(fontkit)\n // subset: false \u2014 the fonts are already subset at build time, and pdf-lib's\n // own subsetting drops kerning on some glyph runs.\n const regular = await doc.embedFont(base64ToBytes(NOTO_SANS_REGULAR_BASE64), { subset: false })\n const bold = await doc.embedFont(base64ToBytes(NOTO_SANS_BOLD_BASE64), { subset: false })\n const fonts: Fonts = { regular, bold, charset: new Set(regular.getCharacterSet()) }\n doc.setTitle(sanitizeForFont(`${model.title} ${model.number}`, fonts.charset))\n doc.setLanguage(model.language)\n doc.setProducer('Dainamite Billing')\n\n const layout = new Layout(doc, fonts)\n\n // \u2500\u2500 Header: logo left, title + number right \u2500\u2500\n const logo = await embedLogo(doc, opts.logo)\n if (logo) {\n const scale = Math.min(140 / logo.width, 48 / logo.height, 1)\n layout.page.drawImage(logo, {\n x: MARGIN,\n y: layout.y - logo.height * scale,\n width: logo.width * scale,\n height: logo.height * scale,\n })\n }\n const titleSize = 18\n layout.text(model.title, PAGE_WIDTH - MARGIN - layout.width(model.title, titleSize, true), {\n size: titleSize,\n bold: true,\n y: layout.y - titleSize,\n })\n layout.text(model.number, PAGE_WIDTH - MARGIN - layout.width(model.number, 11), {\n size: 11,\n color: MUTED,\n y: layout.y - titleSize - 16,\n })\n layout.y -= 64\n\n // \u2500\u2500 Meta rows (right-aligned key: value list) \u2500\u2500\n for (const [label, value] of model.meta) {\n const text = `${label}: ${value}`\n layout.text(text, PAGE_WIDTH - MARGIN - layout.width(text, BODY_SIZE), { y: layout.y })\n layout.y -= BODY_SIZE + LINE_GAP\n }\n layout.y -= 10\n\n // \u2500\u2500 Seller | Buyer \u2500\u2500\n const half = CONTENT_WIDTH / 2 - 10\n const top = layout.y\n const sellerBottom = drawBlock(layout, model.seller.heading, model.seller.lines, MARGIN, half, top, fonts)\n const buyerBottom = drawBlock(layout, model.buyer.heading, model.buyer.lines, MARGIN + half + 20, half, top, fonts)\n layout.y = Math.min(sellerBottom, buyerBottom) - 14\n\n // \u2500\u2500 Lines \u2500\u2500\n layout.ensure(40)\n drawTableHeader(layout, model.table.headers, model.table.align, TABLE_COLUMNS)\n drawRows(layout, model.table.rows, model.table.headers, model.table.align, TABLE_COLUMNS, fonts)\n layout.y -= 14\n\n // \u2500\u2500 VAT summary (right half) + totals \u2500\u2500\n const summaryColumns = [60, 75, 70, 85]\n const summaryX = PAGE_WIDTH - MARGIN - summaryColumns.reduce((s, w) => s + w, 0)\n layout.ensure(SMALL_SIZE + 30 + model.vatSummary.rows.length * (BODY_SIZE + 7))\n layout.text(model.vatSummary.heading, summaryX, { size: SMALL_SIZE, bold: true, color: MUTED })\n layout.y -= SMALL_SIZE + 4\n const summaryAlign: Array<'left' | 'right'> = ['right', 'right', 'right', 'right']\n drawTableHeader(layout, model.vatSummary.headers, summaryAlign, summaryColumns, summaryX)\n drawRows(layout, model.vatSummary.rows, model.vatSummary.headers, summaryAlign, summaryColumns, fonts, summaryX)\n layout.y -= 10\n\n layout.ensure(model.totals.length * 16 + 10)\n model.totals.forEach(([label, value], index) => {\n const last = index === model.totals.length - 1\n const size = last ? 12 : BODY_SIZE + 1\n layout.text(value, PAGE_WIDTH - MARGIN - layout.width(value, size, last), { size, bold: last })\n layout.text(label, summaryX, { size, bold: last, color: last ? TEXT : MUTED })\n layout.y -= size + 6\n })\n layout.y -= 10\n\n // \u2500\u2500 Payment instructions \u2500\u2500\n if (model.payment) {\n layout.ensure((model.payment.lines.length + 1) * (BODY_SIZE + LINE_GAP) + 16)\n layout.rule(layout.y + 4)\n layout.y -= 10\n layout.text(model.payment.heading.toUpperCase(), MARGIN, { size: SMALL_SIZE, bold: true, color: MUTED })\n layout.y -= SMALL_SIZE + 6\n for (const line of model.payment.lines) {\n layout.text(line, MARGIN)\n layout.y -= BODY_SIZE + LINE_GAP\n }\n }\n\n // \u2500\u2500 Footer, page numbers, watermark on every page \u2500\u2500\n const pages = layout.pages\n pages.forEach((page, index) => {\n layout.page = page\n let fy = MARGIN + FOOTER_HEIGHT - 14\n for (const line of model.footer.slice(0, 3)) {\n for (const part of wrap(sanitizeForFont(line, fonts.charset), regular, SMALL_SIZE - 1, CONTENT_WIDTH - 90).slice(0, 2)) {\n layout.text(part, MARGIN, { size: SMALL_SIZE - 1, color: MUTED, y: fy })\n fy -= SMALL_SIZE + 1\n }\n }\n const pageText = model.pageLabel(index + 1, pages.length)\n layout.text(pageText, PAGE_WIDTH - MARGIN - layout.width(pageText, SMALL_SIZE), {\n size: SMALL_SIZE,\n color: MUTED,\n y: MARGIN,\n })\n if (model.watermark) {\n const size = 96\n const text = sanitizeForFont(model.watermark, fonts.charset)\n const w = bold.widthOfTextAtSize(text, size)\n page.drawText(text, {\n x: PAGE_WIDTH / 2 - (w / 2) * Math.cos(Math.PI / 4),\n y: PAGE_HEIGHT / 2 - (w / 2) * Math.sin(Math.PI / 4),\n size,\n font: bold,\n color: WATERMARK,\n rotate: degrees(45),\n opacity: 0.5,\n })\n }\n })\n\n return doc.save()\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { PDFDocument, degrees, rgb, type PDFFont, type PDFImage, type PDFPage } from 'pdf-lib'\nimport fontkit from '@pdf-lib/fontkit'\nimport { NOTO_SANS_BOLD_BASE64, NOTO_SANS_REGULAR_BASE64 } from './fonts/embeddedFonts'\nimport type { InvoiceDocumentModel } from './invoiceModel'\n\n/**\n * Built-in A4 invoice template (Spec B) \u2014 lays out an `InvoiceDocumentModel`\n * with pdf-lib and an embedded Unicode font (Noto Sans subset, so Polish\n * diacritics render \u2014 the standard Helvetica font cannot).\n *\n * Layout: logo + title | seller / buyer blocks | meta rows | lines table\n * (wrapped descriptions, header repeated on every page) | VAT summary |\n * totals | payment instructions | footer | \"Page n of m\" | optional\n * diagonal watermark (DRAFT / TEST). Characters the subset font lacks are\n * replaced with \"?\" so rendering never throws on exotic customer text.\n */\n\nexport type PdfLogo = { bytes: Uint8Array; mimeType: string }\n\nconst PAGE_WIDTH = 595.28\nconst PAGE_HEIGHT = 841.89\nconst MARGIN = 40\nconst CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN\nconst FOOTER_HEIGHT = 48\nconst BODY_SIZE = 9\nconst SMALL_SIZE = 8\nconst LINE_GAP = 3\n\n// # | description | qty | unit net | VAT % | net | VAT | gross (sums to CONTENT_WIDTH)\nexport const TABLE_COLUMNS = [24, 179, 30, 62, 36, 62, 56, 66.28]\n\nconst TEXT = rgb(0.12, 0.12, 0.14)\nconst MUTED = rgb(0.42, 0.42, 0.46)\nconst RULE = rgb(0.82, 0.82, 0.85)\nconst HEADER_FILL = rgb(0.95, 0.95, 0.96)\nconst WATERMARK = rgb(0.85, 0.85, 0.87)\n\ntype Fonts = { regular: PDFFont; bold: PDFFont; charset: Set<number> }\n\nfunction base64ToBytes(value: string): Uint8Array {\n return Uint8Array.from(Buffer.from(value, 'base64'))\n}\n\n/** Replaces characters the embedded subset cannot draw. */\nexport function sanitizeForFont(text: string, charset: Set<number>): string {\n let out = ''\n for (const char of text.replace(/\\t/g, ' ')) {\n const code = char.codePointAt(0) ?? 63\n out += code === 10 || charset.has(code) ? char : '?'\n }\n return out\n}\n\nexport function wrap(text: string, font: PDFFont, size: number, width: number): string[] {\n const lines: string[] = []\n for (const paragraph of text.split('\\n')) {\n let current = ''\n for (const word of paragraph.split(/\\s+/).filter(Boolean)) {\n const candidate = current ? `${current} ${word}` : word\n if (font.widthOfTextAtSize(candidate, size) <= width) {\n current = candidate\n continue\n }\n if (current) lines.push(current)\n // A single word wider than the column is split by characters.\n let chunk = ''\n for (const char of word) {\n if (font.widthOfTextAtSize(chunk + char, size) > width && chunk) {\n lines.push(chunk)\n chunk = char\n } else {\n chunk += char\n }\n }\n current = chunk\n }\n lines.push(current)\n }\n return lines\n}\n\nclass Layout {\n readonly pages: PDFPage[] = []\n page!: PDFPage\n y = 0\n\n constructor(\n private readonly doc: PDFDocument,\n private readonly fonts: Fonts,\n ) {\n this.newPage()\n }\n\n get boldFont(): PDFFont {\n return this.fonts.bold\n }\n\n newPage(): void {\n this.page = this.doc.addPage([PAGE_WIDTH, PAGE_HEIGHT])\n this.pages.push(this.page)\n this.y = PAGE_HEIGHT - MARGIN\n }\n\n /** Starts a new page when fewer than `height` points remain above the footer. */\n ensure(height: number): boolean {\n if (this.y - height < MARGIN + FOOTER_HEIGHT) {\n this.newPage()\n return true\n }\n return false\n }\n\n text(value: string, x: number, opts: { size?: number; bold?: boolean; color?: ReturnType<typeof rgb>; y?: number } = {}) {\n const size = opts.size ?? BODY_SIZE\n this.page.drawText(sanitizeForFont(value, this.fonts.charset), {\n x,\n y: opts.y ?? this.y,\n size,\n font: opts.bold ? this.fonts.bold : this.fonts.regular,\n color: opts.color ?? TEXT,\n })\n }\n\n width(value: string, size: number, bold = false): number {\n return (bold ? this.fonts.bold : this.fonts.regular).widthOfTextAtSize(sanitizeForFont(value, this.fonts.charset), size)\n }\n\n rule(y = this.y): void {\n this.page.drawLine({ start: { x: MARGIN, y }, end: { x: PAGE_WIDTH - MARGIN, y }, thickness: 0.5, color: RULE })\n }\n}\n\nfunction drawTableHeader(layout: Layout, headers: string[], align: Array<'left' | 'right'>, columns: number[], x0 = MARGIN) {\n // Headers wrap inside their column (Polish labels are long: \"Cena netto\").\n const wrapped = headers.map((header, i) => wrap(header, layout.boldFont, SMALL_SIZE, columns[i] - 6))\n const lineHeight = SMALL_SIZE + 2\n const height = Math.max(...wrapped.map((lines) => lines.length)) * lineHeight + 8\n const total = columns.reduce((sum, w) => sum + w, 0)\n layout.page.drawRectangle({ x: x0, y: layout.y - height + 4, width: total, height, color: HEADER_FILL })\n let x = x0\n wrapped.forEach((lines, i) => {\n const w = columns[i]\n lines.forEach((line, li) => {\n const tx = align[i] === 'right' ? x + w - 3 - layout.width(line, SMALL_SIZE, true) : x + 3\n layout.text(line, tx, { size: SMALL_SIZE, bold: true, y: layout.y - SMALL_SIZE - 1 - li * lineHeight })\n })\n x += w\n })\n layout.y -= height + 2\n}\n\nfunction drawRows(\n layout: Layout,\n rows: string[][],\n headers: string[],\n align: Array<'left' | 'right'>,\n columns: number[],\n fonts: Fonts,\n x0 = MARGIN,\n) {\n const lineHeight = BODY_SIZE + LINE_GAP\n for (const row of rows) {\n const cells = row.map((cell, i) =>\n wrap(sanitizeForFont(cell, fonts.charset), fonts.regular, BODY_SIZE, columns[i] - 6),\n )\n const rowHeight = Math.max(...cells.map((c) => c.length)) * lineHeight + 4\n if (layout.ensure(rowHeight)) drawTableHeader(layout, headers, align, columns, x0)\n let x = x0\n cells.forEach((cellLines, i) => {\n const w = columns[i]\n cellLines.forEach((line, li) => {\n const tx = align[i] === 'right' ? x + w - 3 - layout.width(line, BODY_SIZE) : x + 3\n layout.text(line, tx, { y: layout.y - BODY_SIZE - li * lineHeight })\n })\n x += w\n })\n layout.y -= rowHeight\n layout.page.drawLine({\n start: { x: x0, y: layout.y + 1 },\n end: { x: x0 + columns.reduce((s, w) => s + w, 0), y: layout.y + 1 },\n thickness: 0.3,\n color: RULE,\n })\n }\n}\n\nfunction drawBlock(layout: Layout, heading: string, lines: string[], x: number, width: number, top: number, fonts: Fonts): number {\n let y = top\n layout.text(heading.toUpperCase(), x, { size: SMALL_SIZE, bold: true, color: MUTED, y })\n y -= SMALL_SIZE + 6\n lines.forEach((line, index) => {\n for (const part of wrap(sanitizeForFont(line, fonts.charset), index === 0 ? fonts.bold : fonts.regular, BODY_SIZE, width)) {\n layout.text(part, x, { bold: index === 0, y })\n y -= BODY_SIZE + LINE_GAP\n }\n })\n return y\n}\n\nasync function embedLogo(doc: PDFDocument, logo: PdfLogo | null | undefined): Promise<PDFImage | null> {\n if (!logo) return null\n try {\n if (logo.mimeType === 'image/png') return await doc.embedPng(logo.bytes)\n if (logo.mimeType === 'image/jpeg' || logo.mimeType === 'image/jpg') return await doc.embedJpg(logo.bytes)\n } catch (err) {\n console.warn('[billing.pdf] logo could not be embedded', err)\n }\n return null\n}\n\nexport async function renderInvoicePdf(model: InvoiceDocumentModel, opts: { logo?: PdfLogo | null } = {}): Promise<Uint8Array> {\n const doc = await PDFDocument.create()\n doc.registerFontkit(fontkit)\n // subset: false \u2014 the fonts are already subset at build time, and pdf-lib's\n // own subsetting drops kerning on some glyph runs.\n const regular = await doc.embedFont(base64ToBytes(NOTO_SANS_REGULAR_BASE64), { subset: false })\n const bold = await doc.embedFont(base64ToBytes(NOTO_SANS_BOLD_BASE64), { subset: false })\n const fonts: Fonts = { regular, bold, charset: new Set(regular.getCharacterSet()) }\n doc.setTitle(sanitizeForFont(`${model.title} ${model.number}`, fonts.charset))\n doc.setLanguage(model.language)\n doc.setProducer('Dainamite Billing')\n\n const layout = new Layout(doc, fonts)\n\n // \u2500\u2500 Header: logo left, title + number right \u2500\u2500\n const logo = await embedLogo(doc, opts.logo)\n if (logo) {\n const scale = Math.min(140 / logo.width, 48 / logo.height, 1)\n layout.page.drawImage(logo, {\n x: MARGIN,\n y: layout.y - logo.height * scale,\n width: logo.width * scale,\n height: logo.height * scale,\n })\n }\n const titleSize = 18\n layout.text(model.title, PAGE_WIDTH - MARGIN - layout.width(model.title, titleSize, true), {\n size: titleSize,\n bold: true,\n y: layout.y - titleSize,\n })\n layout.text(model.number, PAGE_WIDTH - MARGIN - layout.width(model.number, 11), {\n size: 11,\n color: MUTED,\n y: layout.y - titleSize - 16,\n })\n layout.y -= 64\n\n // \u2500\u2500 Meta rows (right-aligned key: value list) \u2500\u2500\n for (const [label, value] of model.meta) {\n const text = `${label}: ${value}`\n layout.text(text, PAGE_WIDTH - MARGIN - layout.width(text, BODY_SIZE), { y: layout.y })\n layout.y -= BODY_SIZE + LINE_GAP\n }\n layout.y -= 10\n\n // \u2500\u2500 Seller | Buyer \u2500\u2500\n const half = CONTENT_WIDTH / 2 - 10\n const top = layout.y\n const sellerBottom = drawBlock(layout, model.seller.heading, model.seller.lines, MARGIN, half, top, fonts)\n const buyerBottom = drawBlock(layout, model.buyer.heading, model.buyer.lines, MARGIN + half + 20, half, top, fonts)\n layout.y = Math.min(sellerBottom, buyerBottom) - 14\n\n // \u2500\u2500 Lines \u2500\u2500\n layout.ensure(40)\n drawTableHeader(layout, model.table.headers, model.table.align, TABLE_COLUMNS)\n drawRows(layout, model.table.rows, model.table.headers, model.table.align, TABLE_COLUMNS, fonts)\n layout.y -= 14\n\n // \u2500\u2500 VAT summary (right half) + totals \u2500\u2500\n const summaryColumns = [60, 75, 70, 85]\n const summaryX = PAGE_WIDTH - MARGIN - summaryColumns.reduce((s, w) => s + w, 0)\n layout.ensure(SMALL_SIZE + 30 + model.vatSummary.rows.length * (BODY_SIZE + 7))\n layout.text(model.vatSummary.heading, summaryX, { size: SMALL_SIZE, bold: true, color: MUTED })\n layout.y -= SMALL_SIZE + 4\n const summaryAlign: Array<'left' | 'right'> = ['right', 'right', 'right', 'right']\n drawTableHeader(layout, model.vatSummary.headers, summaryAlign, summaryColumns, summaryX)\n drawRows(layout, model.vatSummary.rows, model.vatSummary.headers, summaryAlign, summaryColumns, fonts, summaryX)\n layout.y -= 10\n\n layout.ensure(model.totals.length * 16 + 10)\n model.totals.forEach(([label, value], index) => {\n const last = index === model.totals.length - 1\n const size = last ? 12 : BODY_SIZE + 1\n layout.text(value, PAGE_WIDTH - MARGIN - layout.width(value, size, last), { size, bold: last })\n layout.text(label, summaryX, { size, bold: last, color: last ? TEXT : MUTED })\n layout.y -= size + 6\n })\n layout.y -= 10\n\n // \u2500\u2500 Payment instructions \u2500\u2500\n if (model.payment) {\n layout.ensure((model.payment.lines.length + 1) * (BODY_SIZE + LINE_GAP) + 16)\n layout.rule(layout.y + 4)\n layout.y -= 10\n layout.text(model.payment.heading.toUpperCase(), MARGIN, { size: SMALL_SIZE, bold: true, color: MUTED })\n layout.y -= SMALL_SIZE + 6\n for (const line of model.payment.lines) {\n layout.text(line, MARGIN)\n layout.y -= BODY_SIZE + LINE_GAP\n }\n }\n\n // \u2500\u2500 Footer, page numbers, watermark on every page \u2500\u2500\n const pages = layout.pages\n pages.forEach((page, index) => {\n layout.page = page\n let fy = MARGIN + FOOTER_HEIGHT - 14\n for (const line of model.footer.slice(0, 3)) {\n for (const part of wrap(sanitizeForFont(line, fonts.charset), regular, SMALL_SIZE - 1, CONTENT_WIDTH - 90).slice(0, 2)) {\n layout.text(part, MARGIN, { size: SMALL_SIZE - 1, color: MUTED, y: fy })\n fy -= SMALL_SIZE + 1\n }\n }\n const pageText = model.pageLabel(index + 1, pages.length)\n layout.text(pageText, PAGE_WIDTH - MARGIN - layout.width(pageText, SMALL_SIZE), {\n size: SMALL_SIZE,\n color: MUTED,\n y: MARGIN,\n })\n if (model.watermark) {\n const size = 96\n const text = sanitizeForFont(model.watermark, fonts.charset)\n const w = bold.widthOfTextAtSize(text, size)\n page.drawText(text, {\n x: PAGE_WIDTH / 2 - (w / 2) * Math.cos(Math.PI / 4),\n y: PAGE_HEIGHT / 2 - (w / 2) * Math.sin(Math.PI / 4),\n size,\n font: bold,\n color: WATERMARK,\n rotate: degrees(45),\n opacity: 0.5,\n })\n }\n })\n\n return doc.save()\n}\n"],
5
+ "mappings": "AAAA,SAAS,aAAa,SAAS,WAAsD;AACrF,OAAO,aAAa;AACpB,SAAS,uBAAuB,gCAAgC;AAiBhE,MAAM,aAAa;AACnB,MAAM,cAAc;AACpB,MAAM,SAAS;AACf,MAAM,gBAAgB,aAAa,IAAI;AACvC,MAAM,gBAAgB;AACtB,MAAM,YAAY;AAClB,MAAM,aAAa;AACnB,MAAM,WAAW;AAGV,MAAM,gBAAgB,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK;AAEhE,MAAM,OAAO,IAAI,MAAM,MAAM,IAAI;AACjC,MAAM,QAAQ,IAAI,MAAM,MAAM,IAAI;AAClC,MAAM,OAAO,IAAI,MAAM,MAAM,IAAI;AACjC,MAAM,cAAc,IAAI,MAAM,MAAM,IAAI;AACxC,MAAM,YAAY,IAAI,MAAM,MAAM,IAAI;AAItC,SAAS,cAAc,OAA2B;AAChD,SAAO,WAAW,KAAK,OAAO,KAAK,OAAO,QAAQ,CAAC;AACrD;AAGO,SAAS,gBAAgB,MAAc,SAA8B;AAC1E,MAAI,MAAM;AACV,aAAW,QAAQ,KAAK,QAAQ,OAAO,GAAG,GAAG;AAC3C,UAAM,OAAO,KAAK,YAAY,CAAC,KAAK;AACpC,WAAO,SAAS,MAAM,QAAQ,IAAI,IAAI,IAAI,OAAO;AAAA,EACnD;AACA,SAAO;AACT;AAEO,SAAS,KAAK,MAAc,MAAe,MAAc,OAAyB;AACvF,QAAM,QAAkB,CAAC;AACzB,aAAW,aAAa,KAAK,MAAM,IAAI,GAAG;AACxC,QAAI,UAAU;AACd,eAAW,QAAQ,UAAU,MAAM,KAAK,EAAE,OAAO,OAAO,GAAG;AACzD,YAAM,YAAY,UAAU,GAAG,OAAO,IAAI,IAAI,KAAK;AACnD,UAAI,KAAK,kBAAkB,WAAW,IAAI,KAAK,OAAO;AACpD,kBAAU;AACV;AAAA,MACF;AACA,UAAI,QAAS,OAAM,KAAK,OAAO;AAE/B,UAAI,QAAQ;AACZ,iBAAW,QAAQ,MAAM;AACvB,YAAI,KAAK,kBAAkB,QAAQ,MAAM,IAAI,IAAI,SAAS,OAAO;AAC/D,gBAAM,KAAK,KAAK;AAChB,kBAAQ;AAAA,QACV,OAAO;AACL,mBAAS;AAAA,QACX;AAAA,MACF;AACA,gBAAU;AAAA,IACZ;AACA,UAAM,KAAK,OAAO;AAAA,EACpB;AACA,SAAO;AACT;AAEA,MAAM,OAAO;AAAA,EAKX,YACmB,KACA,OACjB;AAFiB;AACA;AANnB,SAAS,QAAmB,CAAC;AAE7B,aAAI;AAMF,SAAK,QAAQ;AAAA,EACf;AAAA,EAEA,IAAI,WAAoB;AACtB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA,EAEA,UAAgB;AACd,SAAK,OAAO,KAAK,IAAI,QAAQ,CAAC,YAAY,WAAW,CAAC;AACtD,SAAK,MAAM,KAAK,KAAK,IAAI;AACzB,SAAK,IAAI,cAAc;AAAA,EACzB;AAAA;AAAA,EAGA,OAAO,QAAyB;AAC9B,QAAI,KAAK,IAAI,SAAS,SAAS,eAAe;AAC5C,WAAK,QAAQ;AACb,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAAA,EAEA,KAAK,OAAe,GAAW,OAAsF,CAAC,GAAG;AACvH,UAAM,OAAO,KAAK,QAAQ;AAC1B,SAAK,KAAK,SAAS,gBAAgB,OAAO,KAAK,MAAM,OAAO,GAAG;AAAA,MAC7D;AAAA,MACA,GAAG,KAAK,KAAK,KAAK;AAAA,MAClB;AAAA,MACA,MAAM,KAAK,OAAO,KAAK,MAAM,OAAO,KAAK,MAAM;AAAA,MAC/C,OAAO,KAAK,SAAS;AAAA,IACvB,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,OAAe,MAAc,OAAO,OAAe;AACvD,YAAQ,OAAO,KAAK,MAAM,OAAO,KAAK,MAAM,SAAS,kBAAkB,gBAAgB,OAAO,KAAK,MAAM,OAAO,GAAG,IAAI;AAAA,EACzH;AAAA,EAEA,KAAK,IAAI,KAAK,GAAS;AACrB,SAAK,KAAK,SAAS,EAAE,OAAO,EAAE,GAAG,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAG,aAAa,QAAQ,EAAE,GAAG,WAAW,KAAK,OAAO,KAAK,CAAC;AAAA,EACjH;AACF;AAEA,SAAS,gBAAgB,QAAgB,SAAmB,OAAgC,SAAmB,KAAK,QAAQ;AAE1H,QAAM,UAAU,QAAQ,IAAI,CAAC,QAAQ,MAAM,KAAK,QAAQ,OAAO,UAAU,YAAY,QAAQ,CAAC,IAAI,CAAC,CAAC;AACpG,QAAM,aAAa,aAAa;AAChC,QAAM,SAAS,KAAK,IAAI,GAAG,QAAQ,IAAI,CAAC,UAAU,MAAM,MAAM,CAAC,IAAI,aAAa;AAChF,QAAM,QAAQ,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AACnD,SAAO,KAAK,cAAc,EAAE,GAAG,IAAI,GAAG,OAAO,IAAI,SAAS,GAAG,OAAO,OAAO,QAAQ,OAAO,YAAY,CAAC;AACvG,MAAI,IAAI;AACR,UAAQ,QAAQ,CAAC,OAAO,MAAM;AAC5B,UAAM,IAAI,QAAQ,CAAC;AACnB,UAAM,QAAQ,CAAC,MAAM,OAAO;AAC1B,YAAM,KAAK,MAAM,CAAC,MAAM,UAAU,IAAI,IAAI,IAAI,OAAO,MAAM,MAAM,YAAY,IAAI,IAAI,IAAI;AACzF,aAAO,KAAK,MAAM,IAAI,EAAE,MAAM,YAAY,MAAM,MAAM,GAAG,OAAO,IAAI,aAAa,IAAI,KAAK,WAAW,CAAC;AAAA,IACxG,CAAC;AACD,SAAK;AAAA,EACP,CAAC;AACD,SAAO,KAAK,SAAS;AACvB;AAEA,SAAS,SACP,QACA,MACA,SACA,OACA,SACA,OACA,KAAK,QACL;AACA,QAAM,aAAa,YAAY;AAC/B,aAAW,OAAO,MAAM;AACtB,UAAM,QAAQ,IAAI;AAAA,MAAI,CAAC,MAAM,MAC3B,KAAK,gBAAgB,MAAM,MAAM,OAAO,GAAG,MAAM,SAAS,WAAW,QAAQ,CAAC,IAAI,CAAC;AAAA,IACrF;AACA,UAAM,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,aAAa;AACzE,QAAI,OAAO,OAAO,SAAS,EAAG,iBAAgB,QAAQ,SAAS,OAAO,SAAS,EAAE;AACjF,QAAI,IAAI;AACR,UAAM,QAAQ,CAAC,WAAW,MAAM;AAC9B,YAAM,IAAI,QAAQ,CAAC;AACnB,gBAAU,QAAQ,CAAC,MAAM,OAAO;AAC9B,cAAM,KAAK,MAAM,CAAC,MAAM,UAAU,IAAI,IAAI,IAAI,OAAO,MAAM,MAAM,SAAS,IAAI,IAAI;AAClF,eAAO,KAAK,MAAM,IAAI,EAAE,GAAG,OAAO,IAAI,YAAY,KAAK,WAAW,CAAC;AAAA,MACrE,CAAC;AACD,WAAK;AAAA,IACP,CAAC;AACD,WAAO,KAAK;AACZ,WAAO,KAAK,SAAS;AAAA,MACnB,OAAO,EAAE,GAAG,IAAI,GAAG,OAAO,IAAI,EAAE;AAAA,MAChC,KAAK,EAAE,GAAG,KAAK,QAAQ,OAAO,CAAC,GAAG,MAAM,IAAI,GAAG,CAAC,GAAG,GAAG,OAAO,IAAI,EAAE;AAAA,MACnE,WAAW;AAAA,MACX,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACF;AAEA,SAAS,UAAU,QAAgB,SAAiB,OAAiB,GAAW,OAAe,KAAa,OAAsB;AAChI,MAAI,IAAI;AACR,SAAO,KAAK,QAAQ,YAAY,GAAG,GAAG,EAAE,MAAM,YAAY,MAAM,MAAM,OAAO,OAAO,EAAE,CAAC;AACvF,OAAK,aAAa;AAClB,QAAM,QAAQ,CAAC,MAAM,UAAU;AAC7B,eAAW,QAAQ,KAAK,gBAAgB,MAAM,MAAM,OAAO,GAAG,UAAU,IAAI,MAAM,OAAO,MAAM,SAAS,WAAW,KAAK,GAAG;AACzH,aAAO,KAAK,MAAM,GAAG,EAAE,MAAM,UAAU,GAAG,EAAE,CAAC;AAC7C,WAAK,YAAY;AAAA,IACnB;AAAA,EACF,CAAC;AACD,SAAO;AACT;AAEA,eAAe,UAAU,KAAkB,MAA4D;AACrG,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI;AACF,QAAI,KAAK,aAAa,YAAa,QAAO,MAAM,IAAI,SAAS,KAAK,KAAK;AACvE,QAAI,KAAK,aAAa,gBAAgB,KAAK,aAAa,YAAa,QAAO,MAAM,IAAI,SAAS,KAAK,KAAK;AAAA,EAC3G,SAAS,KAAK;AACZ,YAAQ,KAAK,4CAA4C,GAAG;AAAA,EAC9D;AACA,SAAO;AACT;AAEA,eAAsB,iBAAiB,OAA6B,OAAkC,CAAC,GAAwB;AAC7H,QAAM,MAAM,MAAM,YAAY,OAAO;AACrC,MAAI,gBAAgB,OAAO;AAG3B,QAAM,UAAU,MAAM,IAAI,UAAU,cAAc,wBAAwB,GAAG,EAAE,QAAQ,MAAM,CAAC;AAC9F,QAAM,OAAO,MAAM,IAAI,UAAU,cAAc,qBAAqB,GAAG,EAAE,QAAQ,MAAM,CAAC;AACxF,QAAM,QAAe,EAAE,SAAS,MAAM,SAAS,IAAI,IAAI,QAAQ,gBAAgB,CAAC,EAAE;AAClF,MAAI,SAAS,gBAAgB,GAAG,MAAM,KAAK,IAAI,MAAM,MAAM,IAAI,MAAM,OAAO,CAAC;AAC7E,MAAI,YAAY,MAAM,QAAQ;AAC9B,MAAI,YAAY,mBAAmB;AAEnC,QAAM,SAAS,IAAI,OAAO,KAAK,KAAK;AAGpC,QAAM,OAAO,MAAM,UAAU,KAAK,KAAK,IAAI;AAC3C,MAAI,MAAM;AACR,UAAM,QAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,KAAK,KAAK,QAAQ,CAAC;AAC5D,WAAO,KAAK,UAAU,MAAM;AAAA,MAC1B,GAAG;AAAA,MACH,GAAG,OAAO,IAAI,KAAK,SAAS;AAAA,MAC5B,OAAO,KAAK,QAAQ;AAAA,MACpB,QAAQ,KAAK,SAAS;AAAA,IACxB,CAAC;AAAA,EACH;AACA,QAAM,YAAY;AAClB,SAAO,KAAK,MAAM,OAAO,aAAa,SAAS,OAAO,MAAM,MAAM,OAAO,WAAW,IAAI,GAAG;AAAA,IACzF,MAAM;AAAA,IACN,MAAM;AAAA,IACN,GAAG,OAAO,IAAI;AAAA,EAChB,CAAC;AACD,SAAO,KAAK,MAAM,QAAQ,aAAa,SAAS,OAAO,MAAM,MAAM,QAAQ,EAAE,GAAG;AAAA,IAC9E,MAAM;AAAA,IACN,OAAO;AAAA,IACP,GAAG,OAAO,IAAI,YAAY;AAAA,EAC5B,CAAC;AACD,SAAO,KAAK;AAGZ,aAAW,CAAC,OAAO,KAAK,KAAK,MAAM,MAAM;AACvC,UAAM,OAAO,GAAG,KAAK,KAAK,KAAK;AAC/B,WAAO,KAAK,MAAM,aAAa,SAAS,OAAO,MAAM,MAAM,SAAS,GAAG,EAAE,GAAG,OAAO,EAAE,CAAC;AACtF,WAAO,KAAK,YAAY;AAAA,EAC1B;AACA,SAAO,KAAK;AAGZ,QAAM,OAAO,gBAAgB,IAAI;AACjC,QAAM,MAAM,OAAO;AACnB,QAAM,eAAe,UAAU,QAAQ,MAAM,OAAO,SAAS,MAAM,OAAO,OAAO,QAAQ,MAAM,KAAK,KAAK;AACzG,QAAM,cAAc,UAAU,QAAQ,MAAM,MAAM,SAAS,MAAM,MAAM,OAAO,SAAS,OAAO,IAAI,MAAM,KAAK,KAAK;AAClH,SAAO,IAAI,KAAK,IAAI,cAAc,WAAW,IAAI;AAGjD,SAAO,OAAO,EAAE;AAChB,kBAAgB,QAAQ,MAAM,MAAM,SAAS,MAAM,MAAM,OAAO,aAAa;AAC7E,WAAS,QAAQ,MAAM,MAAM,MAAM,MAAM,MAAM,SAAS,MAAM,MAAM,OAAO,eAAe,KAAK;AAC/F,SAAO,KAAK;AAGZ,QAAM,iBAAiB,CAAC,IAAI,IAAI,IAAI,EAAE;AACtC,QAAM,WAAW,aAAa,SAAS,eAAe,OAAO,CAAC,GAAG,MAAM,IAAI,GAAG,CAAC;AAC/E,SAAO,OAAO,aAAa,KAAK,MAAM,WAAW,KAAK,UAAU,YAAY,EAAE;AAC9E,SAAO,KAAK,MAAM,WAAW,SAAS,UAAU,EAAE,MAAM,YAAY,MAAM,MAAM,OAAO,MAAM,CAAC;AAC9F,SAAO,KAAK,aAAa;AACzB,QAAM,eAAwC,CAAC,SAAS,SAAS,SAAS,OAAO;AACjF,kBAAgB,QAAQ,MAAM,WAAW,SAAS,cAAc,gBAAgB,QAAQ;AACxF,WAAS,QAAQ,MAAM,WAAW,MAAM,MAAM,WAAW,SAAS,cAAc,gBAAgB,OAAO,QAAQ;AAC/G,SAAO,KAAK;AAEZ,SAAO,OAAO,MAAM,OAAO,SAAS,KAAK,EAAE;AAC3C,QAAM,OAAO,QAAQ,CAAC,CAAC,OAAO,KAAK,GAAG,UAAU;AAC9C,UAAM,OAAO,UAAU,MAAM,OAAO,SAAS;AAC7C,UAAM,OAAO,OAAO,KAAK,YAAY;AACrC,WAAO,KAAK,OAAO,aAAa,SAAS,OAAO,MAAM,OAAO,MAAM,IAAI,GAAG,EAAE,MAAM,MAAM,KAAK,CAAC;AAC9F,WAAO,KAAK,OAAO,UAAU,EAAE,MAAM,MAAM,MAAM,OAAO,OAAO,OAAO,MAAM,CAAC;AAC7E,WAAO,KAAK,OAAO;AAAA,EACrB,CAAC;AACD,SAAO,KAAK;AAGZ,MAAI,MAAM,SAAS;AACjB,WAAO,QAAQ,MAAM,QAAQ,MAAM,SAAS,MAAM,YAAY,YAAY,EAAE;AAC5E,WAAO,KAAK,OAAO,IAAI,CAAC;AACxB,WAAO,KAAK;AACZ,WAAO,KAAK,MAAM,QAAQ,QAAQ,YAAY,GAAG,QAAQ,EAAE,MAAM,YAAY,MAAM,MAAM,OAAO,MAAM,CAAC;AACvG,WAAO,KAAK,aAAa;AACzB,eAAW,QAAQ,MAAM,QAAQ,OAAO;AACtC,aAAO,KAAK,MAAM,MAAM;AACxB,aAAO,KAAK,YAAY;AAAA,IAC1B;AAAA,EACF;AAGA,QAAM,QAAQ,OAAO;AACrB,QAAM,QAAQ,CAAC,MAAM,UAAU;AAC7B,WAAO,OAAO;AACd,QAAI,KAAK,SAAS,gBAAgB;AAClC,eAAW,QAAQ,MAAM,OAAO,MAAM,GAAG,CAAC,GAAG;AAC3C,iBAAW,QAAQ,KAAK,gBAAgB,MAAM,MAAM,OAAO,GAAG,SAAS,aAAa,GAAG,gBAAgB,EAAE,EAAE,MAAM,GAAG,CAAC,GAAG;AACtH,eAAO,KAAK,MAAM,QAAQ,EAAE,MAAM,aAAa,GAAG,OAAO,OAAO,GAAG,GAAG,CAAC;AACvE,cAAM,aAAa;AAAA,MACrB;AAAA,IACF;AACA,UAAM,WAAW,MAAM,UAAU,QAAQ,GAAG,MAAM,MAAM;AACxD,WAAO,KAAK,UAAU,aAAa,SAAS,OAAO,MAAM,UAAU,UAAU,GAAG;AAAA,MAC9E,MAAM;AAAA,MACN,OAAO;AAAA,MACP,GAAG;AAAA,IACL,CAAC;AACD,QAAI,MAAM,WAAW;AACnB,YAAM,OAAO;AACb,YAAM,OAAO,gBAAgB,MAAM,WAAW,MAAM,OAAO;AAC3D,YAAM,IAAI,KAAK,kBAAkB,MAAM,IAAI;AAC3C,WAAK,SAAS,MAAM;AAAA,QAClB,GAAG,aAAa,IAAK,IAAI,IAAK,KAAK,IAAI,KAAK,KAAK,CAAC;AAAA,QAClD,GAAG,cAAc,IAAK,IAAI,IAAK,KAAK,IAAI,KAAK,KAAK,CAAC;AAAA,QACnD;AAAA,QACA,MAAM;AAAA,QACN,OAAO;AAAA,QACP,QAAQ,QAAQ,EAAE;AAAA,QAClB,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AAED,SAAO,IAAI,KAAK;AAClB;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainamite/billing",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Dainamite Billing — lightweight recurring-billing engine for Open Mercato (Bill Runs, one-time / recurring / usage items, draft invoices in core/sales with human approval).",
5
5
  "type": "module",
6
6
  "main": "./dist/modules/billing/index.js",
@@ -132,7 +132,8 @@ test.describe('TC-BILL-004: B2C accounts + navigation', () => {
132
132
  test('rejects an invoice language the template does not support', async ({ request }) => {
133
133
  const res = await apiRequest(request, 'POST', '/api/billing/accounts', {
134
134
  token,
135
- data: accountPayload({ invoiceLanguage: 'de' }),
135
+ // `fr` has no document template (spec 2026-11-15-billing-multi-language).
136
+ data: accountPayload({ invoiceLanguage: 'fr' }),
136
137
  })
137
138
  expect(res.status()).toBe(400)
138
139
  })
@@ -0,0 +1,64 @@
1
+ import { readdirSync, readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { INVOICE_LANGUAGE_CODES } from '../../lib/invoiceLanguages'
4
+
5
+ /**
6
+ * Guardrails for billing dictionaries (spec 2026-11-15-billing-multi-language):
7
+ * every `i18n/<locale>.json` mirrors `en.json` key for key and keeps every
8
+ * `{placeholder}` intact — a translated placeholder would print as a raw token
9
+ * on a customer's invoice or email. Picks up new dictionaries automatically.
10
+ */
11
+
12
+ const I18N_DIR = join(__dirname, '..', '..', 'i18n')
13
+
14
+ function load(locale: string): Record<string, string> {
15
+ return JSON.parse(readFileSync(join(I18N_DIR, `${locale}.json`), 'utf8')) as Record<string, string>
16
+ }
17
+
18
+ function placeholders(value: string): string[] {
19
+ return [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort()
20
+ }
21
+
22
+ const locales = readdirSync(I18N_DIR)
23
+ .filter((file) => file.endsWith('.json'))
24
+ .map((file) => file.replace(/\.json$/, ''))
25
+ .sort()
26
+ const en = load('en')
27
+ const enKeys = Object.keys(en)
28
+ const translated = locales.filter((locale) => locale !== 'en')
29
+
30
+ describe('billing i18n dictionaries', () => {
31
+ it('ships an English base dictionary and at least one translation', () => {
32
+ expect(locales).toContain('en')
33
+ expect(translated.length).toBeGreaterThan(0)
34
+ })
35
+
36
+ describe.each(translated)('%s.json', (locale) => {
37
+ const dict = load(locale)
38
+
39
+ it('has exactly the keys of en.json, in the same order', () => {
40
+ expect(Object.keys(dict)).toEqual(enKeys)
41
+ })
42
+
43
+ it('keeps every {placeholder} of the English value', () => {
44
+ const mismatches = enKeys
45
+ .filter((key) => typeof dict[key] === 'string')
46
+ .filter((key) => placeholders(dict[key]).join(',') !== placeholders(en[key]).join(','))
47
+ expect(mismatches).toEqual([])
48
+ })
49
+
50
+ it('has no empty values', () => {
51
+ expect(enKeys.filter((key) => typeof dict[key] !== 'string' || dict[key].trim() === '')).toEqual([])
52
+ })
53
+ })
54
+
55
+ it('has a dictionary for every supported invoice language, covering every document and email key', () => {
56
+ const documentKeys = enKeys.filter((key) => key.startsWith('billing.pdf.') || key.startsWith('billing.email.'))
57
+ expect(documentKeys.length).toBeGreaterThan(0)
58
+ for (const code of INVOICE_LANGUAGE_CODES) {
59
+ expect(locales).toContain(code)
60
+ const dict = load(code)
61
+ expect(documentKeys.filter((key) => !dict[key])).toEqual([])
62
+ }
63
+ })
64
+ })