@hartl-services/medusa-base 1.0.0 → 2.0.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 (79) hide show
  1. package/.medusa/server/src/admin/index.js +117 -1
  2. package/.medusa/server/src/admin/index.mjs +118 -2
  3. package/.medusa/server/src/documents/plugin-features.js +15 -1
  4. package/.medusa/server/src/documents/templates/financial-document.js +69 -51
  5. package/.medusa/server/src/documents/templates/format.js +44 -2
  6. package/.medusa/server/src/documents/templates/html-factory.js +7 -5
  7. package/.medusa/server/src/documents/templates/html-utils.js +12 -2
  8. package/.medusa/server/src/documents/templates/layout.js +115 -70
  9. package/.medusa/server/src/documents/templates/packing-list.js +10 -8
  10. package/.medusa/server/src/documents/utils/document-merchant.js +5 -5
  11. package/.medusa/server/src/documents/utils/document-render-settings.js +59 -0
  12. package/.medusa/server/src/email/application/handle-invite-event.js +8 -2
  13. package/.medusa/server/src/email/application/handle-password-reset-event.js +16 -4
  14. package/.medusa/server/src/email/domain/order-summary.js +103 -0
  15. package/.medusa/server/src/email/domain/refund-summary.js +43 -0
  16. package/.medusa/server/src/email/domain/return-summary.js +118 -0
  17. package/.medusa/server/src/email/domain/shipment-summary.js +74 -0
  18. package/.medusa/server/src/email/plugin-features.js +9 -1
  19. package/.medusa/server/src/email/rendering/default-templates.js +693 -65
  20. package/.medusa/server/src/email/rendering/load-email-shop-details.js +36 -0
  21. package/.medusa/server/src/email/rendering/render-email.js +16 -3
  22. package/.medusa/server/src/email/rendering/resolve-shop-configuration.js +20 -1
  23. package/.medusa/server/src/email/types/template-data.js +155 -2
  24. package/.medusa/server/src/modules/documents/domain/merchant.js +2 -33
  25. package/.medusa/server/src/modules/documents/domain/options.js +1 -7
  26. package/.medusa/server/src/modules/plugin-features/domain/plugin-settings.js +4 -3
  27. package/.medusa/server/src/public/descriptor.js +3 -1
  28. package/.medusa/server/src/public/shop-master-data.js +7 -7
  29. package/.medusa/server/src/shop-config/build-food-shop-config.js +6 -6
  30. package/.medusa/server/src/shop-config/options.js +5 -15
  31. package/.medusa/server/src/workflows/documents/issue-cancellation-after-refund.js +2 -3
  32. package/.medusa/server/src/workflows/documents/issue-document.js +6 -5
  33. package/.medusa/server/src/workflows/documents/issue-packing-list.js +9 -3
  34. package/.medusa/server/src/workflows/email/dispatch-email-message.js +4 -2
  35. package/.medusa/server/src/workflows/email/prepare-order-canceled-email.js +42 -4
  36. package/.medusa/server/src/workflows/email/prepare-order-received-email.js +31 -5
  37. package/.medusa/server/src/workflows/email/prepare-refund-email.js +25 -1
  38. package/.medusa/server/src/workflows/email/prepare-return-email.js +64 -3
  39. package/.medusa/server/src/workflows/email/prepare-shipment-email.js +36 -3
  40. package/README.md +180 -43
  41. package/dist/admin-components/features/plugin-features/components/ImageSettingInput.d.ts +15 -0
  42. package/dist/admin-components/features/plugin-features/components/ImageSettingInput.js +42 -0
  43. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.js +4 -2
  44. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.d.ts +1 -1
  45. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.d.ts +4 -4
  46. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.d.ts +1 -1
  47. package/dist/admin-components/features/plugin-features/lib/image-upload.d.ts +6 -0
  48. package/dist/admin-components/features/plugin-features/lib/image-upload.js +17 -0
  49. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.d.ts +4 -4
  50. package/dist/admin-components/i18n/texts.d.ts +6 -0
  51. package/dist/admin-components/i18n/texts.js +6 -0
  52. package/dist/documents/templates/financial-document.d.ts +3 -3
  53. package/dist/documents/templates/financial-document.js +68 -50
  54. package/dist/documents/templates/format.d.ts +14 -0
  55. package/dist/documents/templates/format.js +43 -1
  56. package/dist/documents/templates/html-factory.d.ts +6 -2
  57. package/dist/documents/templates/html-factory.js +6 -4
  58. package/dist/documents/templates/html-utils.d.ts +6 -0
  59. package/dist/documents/templates/html-utils.js +11 -1
  60. package/dist/documents/templates/layout.d.ts +21 -3
  61. package/dist/documents/templates/layout.js +114 -69
  62. package/dist/documents/templates/packing-list.js +9 -7
  63. package/dist/documents/types/documents.d.ts +11 -16
  64. package/dist/documents/types/html-factory.d.ts +1 -0
  65. package/dist/email/types/shop-configuration.d.ts +25 -0
  66. package/dist/email/types/template-data.d.ts +509 -0
  67. package/dist/email/types/template-data.js +154 -1
  68. package/dist/modules/documents/domain/options.d.ts +1 -9
  69. package/dist/modules/documents/domain/options.js +0 -6
  70. package/dist/public/descriptor.js +2 -0
  71. package/dist/public/shop-master-data.d.ts +5 -6
  72. package/dist/public/shop-master-data.js +6 -6
  73. package/dist/public/types.d.ts +2 -1
  74. package/dist/shop-config/build-food-shop-config.js +5 -5
  75. package/dist/shop-config/options.d.ts +13 -8
  76. package/dist/shop-config/options.js +4 -14
  77. package/package.json +2 -2
  78. package/dist/modules/documents/domain/merchant.d.ts +0 -22
  79. package/dist/modules/documents/domain/merchant.js +0 -85
@@ -3,20 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderFinancialDocument = void 0;
4
4
  const format_1 = require("./format");
5
5
  const html_utils_1 = require("./html-utils");
6
+ const layout_1 = require("./layout");
6
7
  const labelsByKind = {
7
8
  cancellation: {
8
- date: "Stornodatum",
9
- recipient: "Stornorechnung an",
9
+ number: "Stornonummer",
10
10
  title: "Stornorechnung",
11
11
  },
12
12
  credit_note: {
13
- date: "Gutschriftsdatum",
14
- recipient: "Gutschrift an",
13
+ number: "Gutschriftnummer",
15
14
  title: "Gutschrift",
16
15
  },
17
16
  invoice: {
18
- date: "Rechnungsdatum",
19
- recipient: "Rechnung an",
17
+ number: "Rechnungsnummer",
20
18
  title: "Rechnung",
21
19
  },
22
20
  };
@@ -26,7 +24,8 @@ const renderAddress = (input) => {
26
24
  return (0, html_utils_1.escapeHtml)(input.order.email);
27
25
  }
28
26
  return [
29
- [address.company, address.first_name, address.last_name]
27
+ address.company,
28
+ [address.first_name, address.last_name]
30
29
  .filter((part) => Boolean(part))
31
30
  .join(" "),
32
31
  address.address_1,
@@ -34,34 +33,43 @@ const renderAddress = (input) => {
34
33
  [address.postal_code, address.city]
35
34
  .filter((part) => Boolean(part))
36
35
  .join(" "),
37
- address.country_code?.toUpperCase(),
36
+ address.country_code ? (0, layout_1.formatCountryName)(address.country_code) : null,
38
37
  ]
39
38
  .filter((part) => Boolean(part))
40
39
  .map(html_utils_1.escapeHtml)
41
40
  .join("<br />");
42
41
  };
43
- const renderLines = (input, timeZone) => input.lines
44
- .map((line) => {
45
- const batchDetails = line.batch_allocations
46
- .map((allocation) => `<div class="batch">Charge: ${(0, html_utils_1.escapeHtml)(allocation.lot_number)} · MHD: ${(0, html_utils_1.escapeHtml)((0, format_1.formatDate)(allocation.expiry_date, { timeZone }))} · ${(0, html_utils_1.escapeHtml)(allocation.quantity)}</div>`)
42
+ /** "Charge: L-1, MHD: 30.06.2027"; the quantity only when a line has several batches. */
43
+ const renderBatchDetails = (line, timeZone) => {
44
+ const showQuantity = line.batch_allocations.length > 1;
45
+ return line.batch_allocations
46
+ .map((allocation) => {
47
+ const parts = [
48
+ `Charge: ${allocation.lot_number}`,
49
+ `MHD: ${(0, format_1.formatDocumentDate)(allocation.expiry_date, timeZone)}`,
50
+ showQuantity ? `Menge: ${allocation.quantity}` : null,
51
+ ].filter((part) => Boolean(part));
52
+ return `<div class="batch">${(0, html_utils_1.escapeHtml)(parts.join(", "))}</div>`;
53
+ })
47
54
  .join("");
48
- return `<tr>
49
- <td>
50
- <strong>${(0, html_utils_1.escapeHtml)(line.title)}</strong>
55
+ };
56
+ const renderLines = (input, timeZone) => input.lines
57
+ .map((line) => `<tr>
58
+ <td class="item">
59
+ <div class="item-title">${(0, html_utils_1.escapeHtml)(line.title)}</div>
51
60
  ${line.sku ? `<div class="sku">SKU: ${(0, html_utils_1.escapeHtml)(line.sku)}</div>` : ""}
52
- ${batchDetails}
61
+ ${renderBatchDetails(line, timeZone)}
53
62
  </td>
54
- <td class="number">${(0, html_utils_1.escapeHtml)(line.quantity)}</td>
55
- <td class="number">${(0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: line.unit_price.amount, currencyCode: line.unit_price.currency_code }))}</td>
56
- <td class="number">${(0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: line.total.amount, currencyCode: line.total.currency_code }))}</td>
57
- </tr>`;
58
- })
63
+ <td class="number quantity">${(0, html_utils_1.escapeHtml)(line.quantity)}</td>
64
+ <td class="number">${(0, html_utils_1.renderMoney)(line.unit_price)}</td>
65
+ <td class="number">${(0, html_utils_1.renderMoney)(line.total)}</td>
66
+ </tr>`)
59
67
  .join("");
68
+ const isExemptIntraCommunitySupply = (input) => input.order.tax_compliance?.exemption_wording ===
69
+ "steuerfreie_innergemeinschaftliche_lieferung";
60
70
  const renderTaxCompliance = (input) => {
61
71
  const evidence = input.order.tax_compliance;
62
- if (!evidence ||
63
- evidence.exemption_wording !==
64
- "steuerfreie_innergemeinschaftliche_lieferung") {
72
+ if (!evidence || !isExemptIntraCommunitySupply(input)) {
65
73
  return "";
66
74
  }
67
75
  return `<section class="tax-compliance">
@@ -72,16 +80,18 @@ const renderTaxCompliance = (input) => {
72
80
  </section>`;
73
81
  };
74
82
  const renderDiscountTotal = (input) => {
75
- const { amount, currency_code: currencyCode } = input.totals.discount_total;
76
- const displayAmount = input.document.kind === "invoice" && Number(amount) > 0
77
- ? `-${amount}`
78
- : amount;
79
- return (0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: displayAmount, currencyCode }));
83
+ const discount = input.totals.discount_total;
84
+ const displayAmount = input.document.kind === "invoice" && Number(discount.amount) > 0
85
+ ? `-${discount.amount}`
86
+ : discount.amount;
87
+ return (0, html_utils_1.renderMoney)(discount, displayAmount);
80
88
  };
81
89
  const formatTaxRate = (rate) => new Intl.NumberFormat(format_1.DEFAULT_FORMAT_LOCALE, {
82
90
  maximumFractionDigits: 2,
83
91
  style: "percent",
84
92
  }).format(rate / 100);
93
+ /** One totals row below the Menge/Einzelpreis columns; the amount sits under "Summe". */
94
+ const renderTotalRow = (label, value, className) => `<tr${className ? ` class="${className}"` : ""}><td class="spacer"></td><td class="label" colspan="2">${(0, html_utils_1.escapeHtml)(label)}</td><td class="number">${value}</td></tr>`;
85
95
  const renderTaxRows = (input) => {
86
96
  const { tax_breakdown: breakdown, tax_total: taxTotal } = input.totals;
87
97
  const rows = breakdown.length > 0
@@ -91,16 +101,17 @@ const renderTaxRows = (input) => {
91
101
  }))
92
102
  : [{ label: "Umsatzsteuer", money: taxTotal }];
93
103
  return rows
94
- .map(({ label, money }) => `<tr><td>${(0, html_utils_1.escapeHtml)(label)}</td><td class="number">${(0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: money.amount, currencyCode: money.currency_code }))}</td></tr>`)
104
+ .map(({ label, money }) => renderTotalRow(label, (0, html_utils_1.renderMoney)(money)))
95
105
  .join("");
96
106
  };
107
+ /** Only invoices ask for payment; cancellations and credit notes do not. */
108
+ const renderPaymentTerms = (input) => input.document.kind === "invoice" && input.settings.payment_terms
109
+ ? `<section class="payment-terms">${(0, html_utils_1.escapeHtml)(input.settings.payment_terms)}</section>`
110
+ : "";
97
111
  const renderFinancialDocument = (input, layout, timeZone) => {
98
112
  const labels = labelsByKind[input.document.kind];
99
113
  const metadata = [
100
- {
101
- label: labels.date,
102
- value: (0, format_1.formatDate)(input.document.issued_at, { timeZone }),
103
- },
114
+ { label: labels.number, value: input.document.number },
104
115
  {
105
116
  label: "Bestellung",
106
117
  value: (0, html_utils_1.formatOrderReference)(input.order.display_id),
@@ -112,30 +123,37 @@ const renderFinancialDocument = (input, layout, timeZone) => {
112
123
  value: input.document.source_document_number ?? "-",
113
124
  });
114
125
  }
115
- const body = `<section class="recipient">
116
- <h2>${labels.recipient}</h2>
117
- ${renderAddress(input)}
118
- </section>
119
- ${renderTaxCompliance(input)}
120
- <table>
121
- <thead><tr><th>Artikel</th><th class="number">Menge</th><th class="number">Einzelpreis</th><th class="number">Summe</th></tr></thead>
122
- <tbody>${renderLines(input, timeZone)}</tbody>
123
- </table>
124
- <table class="totals">
125
- <tbody>
126
- <tr><td>Zwischensumme</td><td class="number">${(0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: input.totals.subtotal.amount, currencyCode: input.totals.subtotal.currency_code }))}</td></tr>
127
- <tr><td>Versand</td><td class="number">${(0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: input.totals.shipping_total.amount, currencyCode: input.totals.shipping_total.currency_code }))}</td></tr>
128
- <tr><td>Rabatt</td><td class="number">${renderDiscountTotal(input)}</td></tr>
126
+ if (input.order.tax_compliance && isExemptIntraCommunitySupply(input)) {
127
+ metadata.push({
128
+ label: "Kunden-UID",
129
+ value: input.order.tax_compliance.customer_vat_number,
130
+ });
131
+ }
132
+ metadata.push({
133
+ label: "Datum",
134
+ value: (0, format_1.formatDocumentDate)(input.document.issued_at, timeZone),
135
+ });
136
+ const { totals } = input;
137
+ const body = `<table class="items financial-items">
138
+ <thead><tr><th class="item">Artikel</th><th class="number quantity">Menge</th><th class="number">Einzelpreis</th><th class="number">Summe</th></tr></thead>
139
+ <tbody class="lines">${renderLines(input, timeZone)}</tbody>
140
+ <tbody class="totals">
141
+ ${renderTotalRow("Zwischensumme", (0, html_utils_1.renderMoney)(totals.subtotal))}
142
+ ${renderTotalRow("Versand", (0, html_utils_1.renderMoney)(totals.shipping_total))}
143
+ ${renderTotalRow("Rabatt", renderDiscountTotal(input))}
129
144
  ${renderTaxRows(input)}
130
- <tr><td>Gesamt</td><td class="number">${(0, html_utils_1.escapeHtml)((0, format_1.formatMoney)({ amount: input.totals.total.amount, currencyCode: input.totals.total.currency_code }))}</td></tr>
145
+ ${renderTotalRow("Gesamt", (0, html_utils_1.renderMoney)(totals.total), "grand-total")}
131
146
  </tbody>
132
- </table>`;
147
+ </table>
148
+ ${renderTaxCompliance(input)}
149
+ ${renderPaymentTerms(input)}`;
133
150
  return layout({
134
151
  body,
135
152
  documentTitle: labels.title,
136
153
  metadata,
137
154
  number: input.document.number,
138
155
  pageTitle: labels.title,
156
+ recipient: renderAddress(input),
139
157
  });
140
158
  };
141
159
  exports.renderFinancialDocument = renderFinancialDocument;
@@ -14,3 +14,17 @@ export type FormatDateOptions = {
14
14
  readonly options?: Omit<Intl.DateTimeFormatOptions, "timeZone">;
15
15
  };
16
16
  export declare const formatDate: (value: string | Date, { locale, options, timeZone }: FormatDateOptions) => string;
17
+ export type MoneyParts = {
18
+ /** Currency symbol or code; empty when the amount could not be formatted. */
19
+ readonly currency: string;
20
+ /** Signed, localized number without the currency. */
21
+ readonly amount: string;
22
+ };
23
+ /**
24
+ * `formatMoney` split into currency and number, so a template can align the
25
+ * currency and the amount separately. Falls back to the whole
26
+ * `formatMoney` text as `amount`.
27
+ */
28
+ export declare const formatMoneyParts: ({ amount, currencyCode }: FormatMoneyInput, { locale }?: FormatMoneyOptions) => MoneyParts;
29
+ /** A document date as "01.01.2027" in the given time zone. */
30
+ export declare const formatDocumentDate: (value: string | Date, timeZone: string) => string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.formatDate = exports.formatMoney = exports.DEFAULT_FORMAT_LOCALE = void 0;
3
+ exports.formatDocumentDate = exports.formatMoneyParts = exports.formatDate = exports.formatMoney = exports.DEFAULT_FORMAT_LOCALE = void 0;
4
4
  exports.DEFAULT_FORMAT_LOCALE = "de-AT";
5
5
  const formatMoney = ({ amount, currencyCode }, { locale = exports.DEFAULT_FORMAT_LOCALE } = {}) => {
6
6
  const numericAmount = Number(amount);
@@ -28,3 +28,45 @@ const formatDate = (value, { locale = exports.DEFAULT_FORMAT_LOCALE, options, ti
28
28
  return new Intl.DateTimeFormat(locale, { timeZone, ...options }).format(date);
29
29
  };
30
30
  exports.formatDate = formatDate;
31
+ /**
32
+ * `formatMoney` split into currency and number, so a template can align the
33
+ * currency and the amount separately. Falls back to the whole
34
+ * `formatMoney` text as `amount`.
35
+ */
36
+ const formatMoneyParts = ({ amount, currencyCode }, { locale = exports.DEFAULT_FORMAT_LOCALE } = {}) => {
37
+ const numericAmount = Number(amount);
38
+ const fallback = {
39
+ currency: "",
40
+ amount: (0, exports.formatMoney)({ amount, currencyCode }, { locale }),
41
+ };
42
+ if (!Number.isFinite(numericAmount)) {
43
+ return fallback;
44
+ }
45
+ try {
46
+ const parts = new Intl.NumberFormat(locale, {
47
+ currency: currencyCode.toUpperCase(),
48
+ style: "currency",
49
+ }).formatToParts(numericAmount);
50
+ return {
51
+ currency: parts
52
+ .filter((part) => part.type === "currency")
53
+ .map((part) => part.value)
54
+ .join(""),
55
+ amount: parts
56
+ .filter((part) => part.type !== "currency" &&
57
+ !(part.type === "literal" && !part.value.trim()))
58
+ .map((part) => part.value)
59
+ .join(""),
60
+ };
61
+ }
62
+ catch {
63
+ return fallback;
64
+ }
65
+ };
66
+ exports.formatMoneyParts = formatMoneyParts;
67
+ /** A document date as "01.01.2027" in the given time zone. */
68
+ const formatDocumentDate = (value, timeZone) => (0, exports.formatDate)(value, {
69
+ timeZone,
70
+ options: { day: "2-digit", month: "2-digit", year: "numeric" },
71
+ });
72
+ exports.formatDocumentDate = formatDocumentDate;
@@ -1,11 +1,15 @@
1
1
  import type { DocumentHtmlFactory } from "../types";
2
+ import { type DocumentTheme } from "./layout";
2
3
  /**
3
4
  * The plugin's default document markup (German, locale de-AT). The legal
4
5
  * sender comes with every render input (`input.merchant`, the base plugin's
5
6
  * shop master data). `timeZone` is the IANA time zone in which printed dates
6
7
  * are shown; the plugin passes its resolved `numbering.timeZone` so the date
7
- * on a document matches the period its number was allocated in.
8
+ * on a document matches the period its number was allocated in. `theme`
9
+ * adds a project's CSS and page background (watermark) on top of the default
10
+ * look; the logo comes from the Documents settings (`input.settings`).
8
11
  */
9
- export declare const createDefaultDocumentHtmlFactory: ({ timeZone, }: {
12
+ export declare const createDefaultDocumentHtmlFactory: ({ timeZone, theme, }: {
10
13
  timeZone: string;
14
+ theme?: DocumentTheme;
11
15
  }) => DocumentHtmlFactory;
@@ -10,9 +10,11 @@ const packing_list_1 = require("./packing-list");
10
10
  * sender comes with every render input (`input.merchant`, the base plugin's
11
11
  * shop master data). `timeZone` is the IANA time zone in which printed dates
12
12
  * are shown; the plugin passes its resolved `numbering.timeZone` so the date
13
- * on a document matches the period its number was allocated in.
13
+ * on a document matches the period its number was allocated in. `theme`
14
+ * adds a project's CSS and page background (watermark) on top of the default
15
+ * look; the logo comes from the Documents settings (`input.settings`).
14
16
  */
15
- const createDefaultDocumentHtmlFactory = ({ timeZone, }) => {
17
+ const createDefaultDocumentHtmlFactory = ({ timeZone, theme, }) => {
16
18
  return (input) => {
17
19
  const kind = input.document.kind;
18
20
  switch (kind) {
@@ -20,12 +22,12 @@ const createDefaultDocumentHtmlFactory = ({ timeZone, }) => {
20
22
  case "cancellation":
21
23
  case "credit_note":
22
24
  if ("totals" in input) {
23
- return (0, financial_document_1.renderFinancialDocument)(input, (0, layout_1.createDocumentLayout)(input.merchant, true), timeZone);
25
+ return (0, financial_document_1.renderFinancialDocument)(input, (0, layout_1.createDocumentLayout)(input.merchant, input.settings, true, theme), timeZone);
24
26
  }
25
27
  break;
26
28
  case "packing_list":
27
29
  if ("fulfillment" in input) {
28
- return (0, packing_list_1.renderPackingList)(input, (0, layout_1.createDocumentLayout)(input.merchant, false), timeZone);
30
+ return (0, packing_list_1.renderPackingList)(input, (0, layout_1.createDocumentLayout)(input.merchant, input.settings, false, theme), timeZone);
29
31
  }
30
32
  break;
31
33
  default: {
@@ -1,2 +1,8 @@
1
+ import type { DocumentMoney } from "../types";
1
2
  export declare const escapeHtml: (value: string | number | null | undefined) => string;
2
3
  export declare const formatOrderReference: (displayId: number | null) => string;
4
+ /**
5
+ * An amount as `<span class="money">` with separate currency and amount
6
+ * spans; `amount` overrides the money's own amount (e.g. a negated value).
7
+ */
8
+ export declare const renderMoney: (money: DocumentMoney, amount?: string) => string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.formatOrderReference = exports.escapeHtml = void 0;
3
+ exports.renderMoney = exports.formatOrderReference = exports.escapeHtml = void 0;
4
+ const format_1 = require("./format");
4
5
  const escapeHtml = (value) => String(value ?? "")
5
6
  .replaceAll("&", "&amp;")
6
7
  .replaceAll("<", "&lt;")
@@ -10,3 +11,12 @@ const escapeHtml = (value) => String(value ?? "")
10
11
  exports.escapeHtml = escapeHtml;
11
12
  const formatOrderReference = (displayId) => displayId === null ? "-" : `#${displayId}`;
12
13
  exports.formatOrderReference = formatOrderReference;
14
+ /**
15
+ * An amount as `<span class="money">` with separate currency and amount
16
+ * spans; `amount` overrides the money's own amount (e.g. a negated value).
17
+ */
18
+ const renderMoney = (money, amount = money.amount) => {
19
+ const parts = (0, format_1.formatMoneyParts)({ amount, currencyCode: money.currency_code });
20
+ return `<span class="money"><span class="currency">${(0, exports.escapeHtml)(parts.currency)}</span> <span class="amount">${(0, exports.escapeHtml)(parts.amount)}</span></span>`;
21
+ };
22
+ exports.renderMoney = renderMoney;
@@ -1,4 +1,19 @@
1
- import type { DocumentMerchant } from "../types";
1
+ import type { DocumentRenderSettings, DocumentMerchant } from "../types";
2
+ /**
3
+ * Shop-specific look on top of the default markup. A project passes it to
4
+ * `createDefaultDocumentHtmlFactory` from its own `htmlFactory`, so it keeps
5
+ * the plugin's content and changes only the appearance.
6
+ */
7
+ export type DocumentTheme = {
8
+ /**
9
+ * CSS appended after the default stylesheet; later rules win. A
10
+ * full-page image such as a watermark belongs in `@page { background }`:
11
+ * it covers the whole sheet on every page, while Chromium clips fixed
12
+ * elements at the page margin and sizes an inline `<svg>` in them wrongly
13
+ * from the second page on.
14
+ */
15
+ readonly stylesheet?: string;
16
+ };
2
17
  export type DocumentLayoutInput = {
3
18
  readonly body: string;
4
19
  readonly documentTitle: string;
@@ -8,10 +23,13 @@ export type DocumentLayoutInput = {
8
23
  }[];
9
24
  readonly number: string;
10
25
  readonly pageTitle: string;
26
+ /** Escaped recipient address markup; `null` leaves the block empty. */
27
+ readonly recipient: string | null;
11
28
  };
12
29
  export type DocumentLayout = (input: DocumentLayoutInput) => string;
30
+ export declare const formatCountryName: (countryCode: string) => string;
13
31
  /**
14
32
  * `includeLegalDetails` adds UID, company register, contact and bank details
15
- * to the footer (financial documents); packing lists show only the name.
33
+ * to the footer (financial documents); packing lists show only the sender.
16
34
  */
17
- export declare const createDocumentLayout: (merchant: DocumentMerchant, includeLegalDetails: boolean) => DocumentLayout;
35
+ export declare const createDocumentLayout: (merchant: DocumentMerchant, settings: DocumentRenderSettings, includeLegalDetails: boolean, theme?: DocumentTheme) => DocumentLayout;
@@ -1,96 +1,141 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createDocumentLayout = void 0;
3
+ exports.createDocumentLayout = exports.formatCountryName = void 0;
4
4
  const html_utils_1 = require("./html-utils");
5
5
  const formatCountryName = (countryCode) => new Intl.DisplayNames("de", { type: "region" }).of(countryCode.toUpperCase()) ?? countryCode.toUpperCase();
6
- const renderMerchantAddress = (merchant) => [
7
- merchant.address_line1,
8
- merchant.address_line2,
9
- [merchant.postal_code, merchant.city].join(" "),
10
- formatCountryName(merchant.country_code),
11
- ]
12
- .filter((part) => Boolean(part))
6
+ exports.formatCountryName = formatCountryName;
7
+ const joinLines = (lines) => lines
8
+ .filter((line) => Boolean(line))
13
9
  .map(html_utils_1.escapeHtml)
14
10
  .join("<br />");
15
- /** Legal footer: name, UID, company register, contact and bank details that are set. */
11
+ const renderFooterColumn = (heading, lines) => {
12
+ const content = joinLines(lines);
13
+ if (!content) {
14
+ return "";
15
+ }
16
+ const title = heading
17
+ ? `<div class="footer-heading">${(0, html_utils_1.escapeHtml)(heading)}</div>`
18
+ : "";
19
+ return `<div class="footer-column">${title}${content}</div>`;
20
+ };
21
+ /**
22
+ * Legal footer in three columns: sender with UID and company register,
23
+ * contact, bank. Packing lists (`includeLegalDetails: false`) show only the
24
+ * sender's name and address.
25
+ */
16
26
  const renderMerchantFooter = (merchant, includeLegalDetails) => {
27
+ const sender = `<div class="footer-column"><strong>${(0, html_utils_1.escapeHtml)(merchant.legal_name)}</strong><br />${joinLines([
28
+ merchant.address_line1,
29
+ merchant.address_line2,
30
+ `${merchant.postal_code} ${merchant.city}`,
31
+ (0, exports.formatCountryName)(merchant.country_code),
32
+ ...(includeLegalDetails
33
+ ? [
34
+ merchant.vat_number ? `UID: ${merchant.vat_number}` : null,
35
+ merchant.company_register_number
36
+ ? `Firmenbuchnummer: ${merchant.company_register_number}`
37
+ : null,
38
+ merchant.company_register_court
39
+ ? `Firmenbuchgericht: ${merchant.company_register_court}`
40
+ : null,
41
+ ]
42
+ : []),
43
+ ])}</div>`;
17
44
  if (!includeLegalDetails) {
18
- return (0, html_utils_1.escapeHtml)(merchant.legal_name);
45
+ return sender;
19
46
  }
20
- const register = [
21
- merchant.company_register_number
22
- ? `Firmenbuchnummer: ${merchant.company_register_number}`
23
- : null,
24
- merchant.company_register_court
25
- ? `Firmenbuchgericht: ${merchant.company_register_court}`
26
- : null,
27
- ];
28
- const lines = [
29
- [
30
- merchant.legal_name,
31
- merchant.vat_number ? `UID: ${merchant.vat_number}` : null,
32
- ...register,
33
- ],
34
- [merchant.email, merchant.phone, merchant.website],
35
- [
36
- merchant.iban ? `IBAN: ${merchant.iban}` : null,
47
+ return [
48
+ sender,
49
+ renderFooterColumn("Kontakt", [
50
+ merchant.phone,
51
+ merchant.email,
52
+ merchant.website,
53
+ ]),
54
+ // The heading names the IBAN; only the BIC keeps its label.
55
+ renderFooterColumn("Bankverbindung", [
56
+ merchant.iban,
37
57
  merchant.bic ? `BIC: ${merchant.bic}` : null,
38
- ],
39
- ];
40
- return lines
41
- .map((parts) => parts
42
- .filter((part) => Boolean(part))
43
- .map(html_utils_1.escapeHtml)
44
- .join(" · "))
45
- .filter(Boolean)
46
- .join("<br />");
58
+ ]),
59
+ ].join("");
47
60
  };
61
+ const renderLetterhead = (merchant, settings) => settings.logo_src
62
+ ? `<img class="logo" src="${(0, html_utils_1.escapeHtml)(settings.logo_src)}" alt="${(0, html_utils_1.escapeHtml)(merchant.legal_name)}" />`
63
+ : `<div class="logo-text">${(0, html_utils_1.escapeHtml)(merchant.legal_name)}</div>`;
48
64
  const renderMetadata = (metadata) => metadata
49
- .map(({ label, value }) => `<div><strong>${(0, html_utils_1.escapeHtml)(label)}:</strong> ${(0, html_utils_1.escapeHtml)(value)}</div>`)
65
+ .map(({ label, value }) => `<tr><th scope="row">${(0, html_utils_1.escapeHtml)(label)}</th><td>${(0, html_utils_1.escapeHtml)(value)}</td></tr>`)
50
66
  .join("");
51
67
  /**
52
- * `includeLegalDetails` adds UID, company register, contact and bank details
53
- * to the footer (financial documents); packing lists show only the name.
68
+ * A4 with only a bottom page margin, which holds the page number
69
+ * (`@bottom-right`; `counter(pages)` works only in page margin boxes). `.page`
70
+ * carries the other margins and repeats them on every printed page
71
+ * (`box-decoration-break: clone`); the fixed footer is positioned in the page
72
+ * area, `--footer-height` reserves its space at the bottom of each page.
54
73
  */
55
- const createDocumentLayout = (merchant, includeLegalDetails) => ({ body, documentTitle, metadata, number, pageTitle }) => `<!doctype html>
56
- <html lang="de">
57
- <head>
58
- <meta charset="utf-8" />
59
- <title>${(0, html_utils_1.escapeHtml)(pageTitle)} ${(0, html_utils_1.escapeHtml)(number)}</title>
60
- <style>
61
- @page { margin: 18mm; }
62
- body { color: #1f2937; font-family: Arial, sans-serif; font-size: 11px; line-height: 1.45; }
63
- h1 { font-size: 24px; margin: 0 0 6px; }
64
- h2 { break-after: avoid; font-size: 12px; margin: 22px 0 6px; page-break-after: avoid; text-transform: uppercase; }
65
- .header { border-bottom: 2px solid #111827; display: flex; gap: 32px; justify-content: space-between; padding-bottom: 16px; }
66
- .document-metadata { display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 18px 0 22px; }
67
- .document-metadata div { min-width: 180px; }
68
- .muted, .sku, .batch { color: #6b7280; }
69
- .batch, .packing-batch { font-size: 10px; margin-top: 2px; }
74
+ const DEFAULT_STYLESHEET = `
75
+ @page { size: A4; margin: 0 0 8mm 0; @bottom-right { color: #6b7280; content: "Seite " counter(page) " von " counter(pages); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 7.5pt; padding-right: 20mm; vertical-align: top; } }
76
+ :root { --accent: #111827; --text: #1f2937; --muted: #6b7280; --line: #d1d5db; --table-head: #f3f4f6; --page-x: 20mm; --page-top: 18mm; --footer-height: 30mm; }
77
+ * { box-sizing: border-box; }
78
+ html, body { margin: 0; padding: 0; }
79
+ body { color: var(--text); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 10pt; line-height: 1.4; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
80
+ .page { -webkit-box-decoration-break: clone; box-decoration-break: clone; padding: var(--page-top) var(--page-x) calc(var(--footer-height) + 8mm); }
81
+ .letterhead { margin-bottom: 14mm; min-height: 16mm; }
82
+ .logo { display: block; height: 22mm; max-width: 90mm; object-fit: contain; object-position: left center; width: auto; }
83
+ .logo-text { color: var(--accent); font-size: 20pt; font-weight: 700; letter-spacing: 0.02em; }
84
+ .document-head { break-inside: avoid; display: flex; gap: 12mm; justify-content: space-between; margin-bottom: 14mm; page-break-inside: avoid; }
85
+ .recipient { flex: 1 1 0; font-style: normal; padding-top: 9mm; }
86
+ .document-info { flex: 0 0 78mm; }
87
+ h1 { color: var(--accent); font-size: 16pt; font-weight: 400; letter-spacing: 0.08em; margin: 0 0 3mm; text-transform: uppercase; }
88
+ h2 { break-after: avoid; font-size: 10pt; margin: 8mm 0 2mm; page-break-after: avoid; }
89
+ .document-metadata { border-collapse: collapse; width: 100%; }
90
+ .document-metadata th, .document-metadata td { border: 0; font-weight: 400; padding: 0; text-align: left; vertical-align: top; }
91
+ .document-metadata td { text-align: right; }
92
+ .muted, .sku, .batch { color: var(--muted); }
93
+ .batch, .packing-batch { font-size: 8pt; margin-top: 1mm; }
70
94
  .packing-batch { display: flex; gap: 12px; justify-content: space-between; }
71
95
  table { border-collapse: collapse; page-break-inside: auto; width: 100%; }
72
96
  thead { display: table-header-group; }
73
97
  tfoot { display: table-footer-group; }
74
98
  tbody { page-break-inside: auto; }
75
99
  tr { break-inside: avoid; page-break-inside: avoid; }
76
- th { background: #f3f4f6; font-size: 10px; text-align: left; text-transform: uppercase; }
77
- th, td { border-bottom: 1px solid #d1d5db; padding: 8px 6px; vertical-align: top; }
100
+ .items th { background: var(--table-head); font-weight: 700; text-align: left; }
101
+ .items th.number { text-align: right; }
102
+ .items th, .items td { padding: 2.5mm 2mm; vertical-align: top; }
103
+ .items tbody.lines td { border-bottom: 1px solid var(--line); }
78
104
  .number { text-align: right; white-space: nowrap; }
79
- .recipient, .tax-compliance, .totals { break-inside: avoid; page-break-inside: avoid; }
80
- .totals { margin-left: auto; margin-top: 16px; width: 260px; }
81
- .totals td { border: 0; padding: 3px 0; }
82
- .totals tr:last-child td { border-top: 2px solid #111827; font-size: 14px; font-weight: 700; padding-top: 7px; }
83
- footer { border-top: 1px solid #d1d5db; break-inside: avoid; color: #6b7280; font-size: 9px; margin-top: 28px; page-break-inside: avoid; padding-top: 10px; }
84
- </style>
105
+ .money { white-space: nowrap; }
106
+ .items tbody.totals td { padding-bottom: 1.5mm; padding-top: 1.5mm; }
107
+ .items tbody.totals tr:first-child td { padding-top: 3mm; }
108
+ .items tbody.totals tr.grand-total td.label, .items tbody.totals tr.grand-total td.number { border-bottom: 1px solid var(--line); border-top: 1px solid var(--line); font-weight: 700; padding-bottom: 2mm; padding-top: 2mm; }
109
+ .payment-terms { margin-top: 10mm; white-space: pre-line; }
110
+ .recipient, .tax-compliance, .payment-terms, .items tbody.totals { break-inside: avoid; page-break-inside: avoid; }
111
+ .page-footer { align-items: flex-start; bottom: 4mm; color: var(--text); display: flex; font-size: 8pt; gap: 8mm; left: var(--page-x); line-height: 1.35; position: fixed; right: var(--page-x); }
112
+ .footer-column { flex: 1 1 0; }
113
+ .footer-heading { color: var(--muted); }
114
+ `;
115
+ /**
116
+ * `includeLegalDetails` adds UID, company register, contact and bank details
117
+ * to the footer (financial documents); packing lists show only the sender.
118
+ */
119
+ const createDocumentLayout = (merchant, settings, includeLegalDetails, theme = {}) => ({ body, documentTitle, metadata, number, pageTitle, recipient }) => `<!doctype html>
120
+ <html lang="de">
121
+ <head>
122
+ <meta charset="utf-8" />
123
+ <title>${(0, html_utils_1.escapeHtml)(pageTitle)} ${(0, html_utils_1.escapeHtml)(number)}</title>
124
+ <style>${DEFAULT_STYLESHEET}${theme.stylesheet ?? ""}</style>
85
125
  </head>
86
126
  <body>
87
- <section class="header">
88
- <div><h1>${(0, html_utils_1.escapeHtml)(documentTitle)}</h1><div class="muted">${(0, html_utils_1.escapeHtml)(number)}</div></div>
89
- <div><strong>${(0, html_utils_1.escapeHtml)(merchant.legal_name)}</strong><br />${renderMerchantAddress(merchant)}</div>
90
- </section>
91
- <section class="document-metadata">${renderMetadata(metadata)}</section>
92
- ${body}
93
- <footer>${renderMerchantFooter(merchant, includeLegalDetails)}</footer>
127
+ <footer class="page-footer">${renderMerchantFooter(merchant, includeLegalDetails)}</footer>
128
+ <main class="page">
129
+ <header class="letterhead">${renderLetterhead(merchant, settings)}</header>
130
+ <section class="document-head">
131
+ <address class="recipient">${recipient ?? ""}</address>
132
+ <div class="document-info">
133
+ <h1>${(0, html_utils_1.escapeHtml)(documentTitle)}</h1>
134
+ <table class="document-metadata"><tbody>${renderMetadata(metadata)}</tbody></table>
135
+ </div>
136
+ </section>
137
+ ${body}
138
+ </main>
94
139
  </body>
95
140
  </html>`;
96
141
  exports.createDocumentLayout = createDocumentLayout;