@porulle/core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +26 -7
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +8 -5
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +7 -0
- package/dist/config/types.d.ts +26 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/hooks/checkout-completion.d.ts.map +1 -1
- package/dist/hooks/checkout-completion.js +2 -2
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +17 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/rest/index.d.ts.map +1 -1
- package/dist/interfaces/rest/index.js +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/staff.js +206 -0
- package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
- package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/analytics.js +24 -0
- package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/carts.js +39 -2
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +17 -1
- package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +97 -21
- package/dist/interfaces/rest/routes/documents.d.ts +10 -0
- package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/documents.js +40 -0
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +13 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +111 -1
- package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/pricing.js +36 -1
- package/dist/interfaces/rest/routes/settings.d.ts +5 -0
- package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/settings.js +50 -0
- package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
- package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/shipping.js +68 -0
- package/dist/interfaces/rest/routes/tax.d.ts +5 -0
- package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/tax.js +68 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/analytics.js +33 -0
- package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/carts.js +38 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +48 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/checkout.js +4 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
- package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/documents.js +65 -0
- package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +234 -1
- package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/pricing.js +70 -0
- package/dist/interfaces/rest/schemas/responses.d.ts +190 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
- package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/settings.js +79 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/shipping.js +146 -0
- package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
- package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/tax.js +132 -0
- package/dist/kernel/database/schema.d.ts +4 -0
- package/dist/kernel/database/schema.d.ts.map +1 -1
- package/dist/kernel/database/schema.js +8 -0
- package/dist/kernel/database/scoped-db.d.ts +2 -1
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +62 -1
- package/dist/kernel/hooks/types.d.ts +1 -1
- package/dist/kernel/hooks/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +26 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +17 -2
- package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.js +16 -3
- package/dist/modules/analytics/module.d.ts.map +1 -1
- package/dist/modules/analytics/module.js +6 -0
- package/dist/modules/analytics/reports.d.ts +58 -0
- package/dist/modules/analytics/reports.d.ts.map +1 -0
- package/dist/modules/analytics/reports.js +249 -0
- package/dist/modules/analytics/service.d.ts +10 -0
- package/dist/modules/analytics/service.d.ts.map +1 -1
- package/dist/modules/analytics/service.js +14 -0
- package/dist/modules/cart/repository/index.d.ts +7 -0
- package/dist/modules/cart/repository/index.d.ts.map +1 -1
- package/dist/modules/cart/repository/index.js +25 -1
- package/dist/modules/cart/schema.d.ts +17 -0
- package/dist/modules/cart/schema.d.ts.map +1 -1
- package/dist/modules/cart/schema.js +2 -0
- package/dist/modules/cart/schemas.d.ts +1 -0
- package/dist/modules/cart/schemas.d.ts.map +1 -1
- package/dist/modules/cart/schemas.js +1 -0
- package/dist/modules/cart/service.d.ts +32 -0
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +61 -2
- package/dist/modules/catalog/entity-service.d.ts +21 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +166 -5
- package/dist/modules/catalog/schema.d.ts +34 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +5 -0
- package/dist/modules/catalog/schemas.d.ts +3 -0
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +3 -0
- package/dist/modules/catalog/service.d.ts +36 -0
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +6 -0
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +6 -0
- package/dist/modules/documents/module.d.ts +7 -0
- package/dist/modules/documents/module.d.ts.map +1 -0
- package/dist/modules/documents/module.js +12 -0
- package/dist/modules/documents/pdf.d.ts +25 -0
- package/dist/modules/documents/pdf.d.ts.map +1 -0
- package/dist/modules/documents/pdf.js +80 -0
- package/dist/modules/documents/render.d.ts +32 -0
- package/dist/modules/documents/render.d.ts.map +1 -0
- package/dist/modules/documents/render.js +190 -0
- package/dist/modules/documents/repository/index.d.ts +25 -0
- package/dist/modules/documents/repository/index.d.ts.map +1 -0
- package/dist/modules/documents/repository/index.js +47 -0
- package/dist/modules/documents/schema.d.ts +214 -0
- package/dist/modules/documents/schema.d.ts.map +1 -0
- package/dist/modules/documents/schema.js +42 -0
- package/dist/modules/documents/service.d.ts +51 -0
- package/dist/modules/documents/service.d.ts.map +1 -0
- package/dist/modules/documents/service.js +119 -0
- package/dist/modules/fulfillment/service.d.ts +18 -0
- package/dist/modules/fulfillment/service.d.ts.map +1 -1
- package/dist/modules/fulfillment/service.js +85 -1
- package/dist/modules/orders/repository/index.d.ts +18 -1
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +86 -1
- package/dist/modules/orders/schema.d.ts +391 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +56 -1
- package/dist/modules/orders/service.d.ts +102 -3
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +653 -29
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +13 -1
- package/dist/modules/pricing/repository/index.d.ts +5 -0
- package/dist/modules/pricing/repository/index.d.ts.map +1 -1
- package/dist/modules/pricing/repository/index.js +19 -0
- package/dist/modules/pricing/service.d.ts +18 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +43 -5
- package/dist/modules/settings/module.d.ts +6 -0
- package/dist/modules/settings/module.d.ts.map +1 -0
- package/dist/modules/settings/module.js +11 -0
- package/dist/modules/settings/repository/index.d.ts +16 -0
- package/dist/modules/settings/repository/index.d.ts.map +1 -0
- package/dist/modules/settings/repository/index.js +41 -0
- package/dist/modules/settings/schema.d.ts +121 -0
- package/dist/modules/settings/schema.d.ts.map +1 -0
- package/dist/modules/settings/schema.js +24 -0
- package/dist/modules/settings/service.d.ts +26 -0
- package/dist/modules/settings/service.d.ts.map +1 -0
- package/dist/modules/settings/service.js +51 -0
- package/dist/modules/shipping/calculator.d.ts +5 -0
- package/dist/modules/shipping/calculator.d.ts.map +1 -1
- package/dist/modules/shipping/calculator.js +10 -0
- package/dist/modules/shipping/module.d.ts +5 -1
- package/dist/modules/shipping/module.d.ts.map +1 -1
- package/dist/modules/shipping/module.js +4 -1
- package/dist/modules/shipping/repository/index.d.ts +27 -2
- package/dist/modules/shipping/repository/index.d.ts.map +1 -1
- package/dist/modules/shipping/repository/index.js +105 -1
- package/dist/modules/shipping/schema.d.ts +419 -0
- package/dist/modules/shipping/schema.d.ts.map +1 -0
- package/dist/modules/shipping/schema.js +56 -0
- package/dist/modules/shipping/service.d.ts +54 -0
- package/dist/modules/shipping/service.d.ts.map +1 -1
- package/dist/modules/shipping/service.js +161 -2
- package/dist/modules/tax/adapter.d.ts +13 -0
- package/dist/modules/tax/adapter.d.ts.map +1 -1
- package/dist/modules/tax/module.d.ts +5 -1
- package/dist/modules/tax/module.d.ts.map +1 -1
- package/dist/modules/tax/module.js +4 -1
- package/dist/modules/tax/repository/index.d.ts +38 -2
- package/dist/modules/tax/repository/index.d.ts.map +1 -1
- package/dist/modules/tax/repository/index.js +138 -1
- package/dist/modules/tax/schema.d.ts +355 -0
- package/dist/modules/tax/schema.d.ts.map +1 -0
- package/dist/modules/tax/schema.js +55 -0
- package/dist/modules/tax/service.d.ts +60 -1
- package/dist/modules/tax/service.d.ts.map +1 -1
- package/dist/modules/tax/service.js +227 -2
- package/dist/runtime/kernel-modules.d.ts +15 -2
- package/dist/runtime/kernel-modules.d.ts.map +1 -1
- package/dist/runtime/kernel-modules.js +4 -0
- package/dist/runtime/kernel-types.d.ts +5 -1
- package/dist/runtime/kernel-types.d.ts.map +1 -1
- package/dist/runtime/kernel-types.js +2 -0
- package/dist/runtime/server.js +1 -1
- package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
- package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
- package/dist/test-utils/create-plugin-test-app.js +15 -3
- package/dist/test-utils/create-test-config.d.ts.map +1 -1
- package/dist/test-utils/create-test-config.js +2 -0
- package/dist/test-utils/order-test-helpers.d.ts +12 -0
- package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
- package/dist/test-utils/order-test-helpers.js +18 -0
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +7 -0
- package/dist/test-utils/test-actors.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -0
- package/package.json +3 -3
- package/src/auth/middleware.ts +26 -7
- package/src/auth/setup.ts +11 -4
- package/src/config/defaults.ts +7 -0
- package/src/config/types.ts +26 -1
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/hooks/checkout-completion.ts +22 -16
- package/src/hooks/checkout.ts +36 -11
- package/src/index.ts +1 -0
- package/src/interfaces/rest/index.ts +12 -0
- package/src/interfaces/rest/routes/admin/staff.ts +260 -0
- package/src/interfaces/rest/routes/analytics.ts +33 -0
- package/src/interfaces/rest/routes/carts.ts +52 -2
- package/src/interfaces/rest/routes/catalog.ts +34 -0
- package/src/interfaces/rest/routes/checkout.ts +114 -27
- package/src/interfaces/rest/routes/documents.ts +61 -0
- package/src/interfaces/rest/routes/inventory.ts +14 -0
- package/src/interfaces/rest/routes/orders.ts +148 -1
- package/src/interfaces/rest/routes/pricing.ts +44 -1
- package/src/interfaces/rest/routes/settings.ts +61 -0
- package/src/interfaces/rest/routes/shipping.ts +92 -0
- package/src/interfaces/rest/routes/tax.ts +90 -0
- package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
- package/src/interfaces/rest/schemas/analytics.ts +39 -0
- package/src/interfaces/rest/schemas/carts.ts +40 -0
- package/src/interfaces/rest/schemas/catalog.ts +53 -0
- package/src/interfaces/rest/schemas/checkout.ts +4 -0
- package/src/interfaces/rest/schemas/documents.ts +72 -0
- package/src/interfaces/rest/schemas/orders.ts +258 -1
- package/src/interfaces/rest/schemas/pricing.ts +75 -0
- package/src/interfaces/rest/schemas/settings.ts +93 -0
- package/src/interfaces/rest/schemas/shipping.ts +164 -0
- package/src/interfaces/rest/schemas/tax.ts +148 -0
- package/src/kernel/database/schema.ts +12 -0
- package/src/kernel/database/scoped-db.ts +70 -1
- package/src/kernel/hooks/types.ts +1 -0
- package/src/kernel/plugin/manifest.ts +41 -2
- package/src/modules/analytics/drizzle-adapter.ts +19 -3
- package/src/modules/analytics/module.ts +10 -0
- package/src/modules/analytics/reports.ts +311 -0
- package/src/modules/analytics/service.ts +23 -0
- package/src/modules/cart/repository/index.ts +30 -1
- package/src/modules/cart/schema.ts +2 -0
- package/src/modules/cart/schemas.ts +1 -0
- package/src/modules/cart/service.ts +104 -2
- package/src/modules/catalog/entity-service.ts +196 -4
- package/src/modules/catalog/schema.ts +5 -0
- package/src/modules/catalog/schemas.ts +3 -0
- package/src/modules/catalog/service.ts +30 -0
- package/src/modules/customers/service.ts +6 -0
- package/src/modules/documents/module.ts +19 -0
- package/src/modules/documents/pdf.ts +101 -0
- package/src/modules/documents/render.ts +231 -0
- package/src/modules/documents/repository/index.ts +68 -0
- package/src/modules/documents/schema.ts +44 -0
- package/src/modules/documents/service.ts +176 -0
- package/src/modules/fulfillment/service.ts +139 -1
- package/src/modules/orders/repository/index.ts +127 -1
- package/src/modules/orders/schema.ts +58 -1
- package/src/modules/orders/service.ts +1041 -65
- package/src/modules/orders/stale-order-cleanup.ts +18 -4
- package/src/modules/pricing/repository/index.ts +34 -0
- package/src/modules/pricing/service.ts +76 -6
- package/src/modules/settings/module.ts +18 -0
- package/src/modules/settings/repository/index.ts +59 -0
- package/src/modules/settings/schema.ts +25 -0
- package/src/modules/settings/service.ts +82 -0
- package/src/modules/shipping/calculator.ts +27 -0
- package/src/modules/shipping/module.ts +6 -2
- package/src/modules/shipping/repository/index.ts +136 -2
- package/src/modules/shipping/schema.ts +58 -0
- package/src/modules/shipping/service.ts +260 -1
- package/src/modules/tax/adapter.ts +9 -0
- package/src/modules/tax/module.ts +6 -2
- package/src/modules/tax/repository/index.ts +169 -2
- package/src/modules/tax/schema.ts +57 -0
- package/src/modules/tax/service.ts +313 -2
- package/src/runtime/kernel-modules.ts +4 -0
- package/src/runtime/kernel-types.ts +6 -0
- package/src/runtime/server.ts +1 -1
- package/src/test-utils/create-plugin-test-app.ts +17 -4
- package/src/test-utils/create-test-config.ts +2 -0
- package/src/test-utils/order-test-helpers.ts +26 -0
- package/src/test-utils/rest-api-test-utils.ts +7 -0
- package/src/test-utils/test-actors.ts +1 -1
- package/src/testing.ts +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { HydratedOrder } from "../orders/service.js";
|
|
2
|
+
import { A4, type PdfTextOp } from "./pdf.js";
|
|
3
|
+
|
|
4
|
+
/** Branding values read from the settings module's `branding` group. */
|
|
5
|
+
export interface DocumentBranding {
|
|
6
|
+
storeName?: string;
|
|
7
|
+
receiptHeader?: string;
|
|
8
|
+
receiptFooter?: string;
|
|
9
|
+
logoUrl?: string;
|
|
10
|
+
address?: string;
|
|
11
|
+
phone?: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
taxId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DocumentContext {
|
|
17
|
+
order: HydratedOrder;
|
|
18
|
+
branding: DocumentBranding;
|
|
19
|
+
invoiceNumber: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function escapeHtml(value: unknown): string {
|
|
23
|
+
return String(value ?? "")
|
|
24
|
+
.replaceAll("&", "&")
|
|
25
|
+
.replaceAll("<", "<")
|
|
26
|
+
.replaceAll(">", ">")
|
|
27
|
+
.replaceAll('"', """)
|
|
28
|
+
.replaceAll("'", "'");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Minor units → "1,234.50 LKR". */
|
|
32
|
+
export function formatAmount(minor: number, currency: string): string {
|
|
33
|
+
const major = minor / 100;
|
|
34
|
+
const formatted = major.toLocaleString("en-US", {
|
|
35
|
+
minimumFractionDigits: 2,
|
|
36
|
+
maximumFractionDigits: 2,
|
|
37
|
+
});
|
|
38
|
+
return `${formatted} ${currency}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatDate(date: Date | string | null | undefined): string {
|
|
42
|
+
if (!date) return "";
|
|
43
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
44
|
+
return d.toISOString().slice(0, 10);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sellerBlockHtml(branding: DocumentBranding): string {
|
|
48
|
+
const lines = [
|
|
49
|
+
branding.storeName && `<strong>${escapeHtml(branding.storeName)}</strong>`,
|
|
50
|
+
branding.address && escapeHtml(branding.address),
|
|
51
|
+
branding.phone && escapeHtml(branding.phone),
|
|
52
|
+
branding.email && escapeHtml(branding.email),
|
|
53
|
+
branding.taxId && `Tax ID: ${escapeHtml(branding.taxId)}`,
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
return lines.join("<br>");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function lineRowsHtml(order: HydratedOrder): string {
|
|
59
|
+
return order.lineItems
|
|
60
|
+
.map(
|
|
61
|
+
(li) => `<tr>
|
|
62
|
+
<td>${escapeHtml(li.title)}${li.sku ? `<br><small>${escapeHtml(li.sku)}</small>` : ""}</td>
|
|
63
|
+
<td class="num">${li.quantity}</td>
|
|
64
|
+
<td class="num">${escapeHtml(formatAmount(li.unitPrice, order.currency))}</td>
|
|
65
|
+
<td class="num">${escapeHtml(formatAmount(li.totalPrice, order.currency))}</td>
|
|
66
|
+
</tr>`,
|
|
67
|
+
)
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function totalsRowsHtml(order: HydratedOrder): string {
|
|
72
|
+
const rows: Array<[string, number]> = [
|
|
73
|
+
["Subtotal", order.subtotal],
|
|
74
|
+
...(order.discountTotal ? ([["Discount", -order.discountTotal]] as Array<[string, number]>) : []),
|
|
75
|
+
["Tax", order.taxTotal],
|
|
76
|
+
...(order.shippingTotal ? ([["Shipping", order.shippingTotal]] as Array<[string, number]>) : []),
|
|
77
|
+
];
|
|
78
|
+
const body = rows
|
|
79
|
+
.map(([label, amount]) => `<tr><td>${label}</td><td class="num">${escapeHtml(formatAmount(amount, order.currency))}</td></tr>`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
return `${body}\n<tr class="grand"><td>Total</td><td class="num">${escapeHtml(formatAmount(order.grandTotal, order.currency))}</td></tr>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const BASE_STYLE = `body{font-family:system-ui,-apple-system,sans-serif;color:#111;margin:2rem auto;line-height:1.45}
|
|
85
|
+
table{width:100%;border-collapse:collapse;margin:1rem 0}
|
|
86
|
+
th,td{text-align:left;padding:.4rem .5rem;border-bottom:1px solid #ddd}
|
|
87
|
+
td.num,th.num{text-align:right}
|
|
88
|
+
tr.grand td{font-weight:700;border-top:2px solid #111}
|
|
89
|
+
small{color:#666}`;
|
|
90
|
+
|
|
91
|
+
/** A4-style HTML invoice with seller branding and fiscal invoice number. */
|
|
92
|
+
export function invoiceHtml(ctx: DocumentContext): string {
|
|
93
|
+
const { order, branding, invoiceNumber } = ctx;
|
|
94
|
+
return `<!doctype html>
|
|
95
|
+
<html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoiceNumber)}</title>
|
|
96
|
+
<style>${BASE_STYLE}
|
|
97
|
+
body{max-width:48rem}</style></head>
|
|
98
|
+
<body>
|
|
99
|
+
<table style="border:none"><tr>
|
|
100
|
+
<td style="border:none">${sellerBlockHtml(branding)}</td>
|
|
101
|
+
<td style="border:none;text-align:right">
|
|
102
|
+
<h1 style="margin:0">INVOICE</h1>
|
|
103
|
+
<div>Invoice #: <strong>${escapeHtml(invoiceNumber)}</strong></div>
|
|
104
|
+
<div>Order #: ${escapeHtml(order.orderNumber)}</div>
|
|
105
|
+
<div>Date: ${escapeHtml(formatDate(order.placedAt))}</div>
|
|
106
|
+
</td>
|
|
107
|
+
</tr></table>
|
|
108
|
+
<table>
|
|
109
|
+
<thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
|
|
110
|
+
<tbody>${lineRowsHtml(order)}</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
<table style="max-width:20rem;margin-left:auto"><tbody>${totalsRowsHtml(order)}</tbody></table>
|
|
113
|
+
${branding.receiptFooter ? `<p><small>${escapeHtml(branding.receiptFooter)}</small></p>` : ""}
|
|
114
|
+
</body></html>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Narrow receipt-style HTML with the configured header/footer. */
|
|
118
|
+
export function receiptHtml(ctx: Omit<DocumentContext, "invoiceNumber"> & { invoiceNumber?: string }): string {
|
|
119
|
+
const { order, branding } = ctx;
|
|
120
|
+
return `<!doctype html>
|
|
121
|
+
<html><head><meta charset="utf-8"><title>Receipt ${escapeHtml(order.orderNumber)}</title>
|
|
122
|
+
<style>${BASE_STYLE}
|
|
123
|
+
body{max-width:22rem;font-size:.9rem}
|
|
124
|
+
h2,p.center{text-align:center}</style></head>
|
|
125
|
+
<body>
|
|
126
|
+
${branding.storeName ? `<h2>${escapeHtml(branding.storeName)}</h2>` : ""}
|
|
127
|
+
${branding.receiptHeader ? `<p class="center">${escapeHtml(branding.receiptHeader)}</p>` : ""}
|
|
128
|
+
${branding.taxId ? `<p class="center"><small>Tax ID: ${escapeHtml(branding.taxId)}</small></p>` : ""}
|
|
129
|
+
<p>Order #: ${escapeHtml(order.orderNumber)}<br>Date: ${escapeHtml(formatDate(order.placedAt))}</p>
|
|
130
|
+
<table>
|
|
131
|
+
<tbody>${lineRowsHtml(order)}</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
<table><tbody>${totalsRowsHtml(order)}</tbody></table>
|
|
134
|
+
${branding.receiptFooter ? `<p class="center"><small>${escapeHtml(branding.receiptFooter)}</small></p>` : ""}
|
|
135
|
+
</body></html>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── PDF layout ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const MARGIN = 50;
|
|
141
|
+
const LINE_HEIGHT = 16;
|
|
142
|
+
const COL = {
|
|
143
|
+
item: MARGIN,
|
|
144
|
+
qty: 380,
|
|
145
|
+
unit: 440,
|
|
146
|
+
total: 515,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function truncate(text: string, max: number): string {
|
|
150
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Lays an invoice out as pages of text ops for the minimal PDF writer.
|
|
155
|
+
*/
|
|
156
|
+
export function invoicePdfOps(ctx: DocumentContext): PdfTextOp[][] {
|
|
157
|
+
const { order, branding, invoiceNumber } = ctx;
|
|
158
|
+
const pages: PdfTextOp[][] = [];
|
|
159
|
+
let ops: PdfTextOp[] = [];
|
|
160
|
+
let y = A4.height - MARGIN;
|
|
161
|
+
|
|
162
|
+
const write = (text: string, x: number, opts?: { size?: number | undefined; bold?: boolean | undefined }) => {
|
|
163
|
+
ops.push({ x, y, size: opts?.size ?? 10, bold: opts?.bold, text });
|
|
164
|
+
};
|
|
165
|
+
const newline = (lines = 1) => {
|
|
166
|
+
y -= LINE_HEIGHT * lines;
|
|
167
|
+
if (y < MARGIN + LINE_HEIGHT) {
|
|
168
|
+
pages.push(ops);
|
|
169
|
+
ops = [];
|
|
170
|
+
y = A4.height - MARGIN;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
write("INVOICE", MARGIN, { size: 20, bold: true });
|
|
175
|
+
newline(2);
|
|
176
|
+
write(`Invoice #: ${invoiceNumber}`, MARGIN, { bold: true });
|
|
177
|
+
newline();
|
|
178
|
+
write(`Order #: ${order.orderNumber}`, MARGIN);
|
|
179
|
+
newline();
|
|
180
|
+
write(`Date: ${formatDate(order.placedAt)}`, MARGIN);
|
|
181
|
+
newline(2);
|
|
182
|
+
|
|
183
|
+
const seller = [
|
|
184
|
+
branding.storeName,
|
|
185
|
+
branding.address,
|
|
186
|
+
branding.phone,
|
|
187
|
+
branding.email,
|
|
188
|
+
branding.taxId ? `Tax ID: ${branding.taxId}` : undefined,
|
|
189
|
+
].filter((v): v is string => Boolean(v));
|
|
190
|
+
for (const [i, line] of seller.entries()) {
|
|
191
|
+
write(line, MARGIN, { bold: i === 0 });
|
|
192
|
+
newline();
|
|
193
|
+
}
|
|
194
|
+
newline();
|
|
195
|
+
|
|
196
|
+
write("Item", COL.item, { bold: true });
|
|
197
|
+
write("Qty", COL.qty, { bold: true });
|
|
198
|
+
write("Unit", COL.unit, { bold: true });
|
|
199
|
+
write("Amount", COL.total, { bold: true });
|
|
200
|
+
newline();
|
|
201
|
+
|
|
202
|
+
for (const li of order.lineItems) {
|
|
203
|
+
write(truncate(li.title, 52), COL.item);
|
|
204
|
+
write(String(li.quantity), COL.qty);
|
|
205
|
+
write(formatAmount(li.unitPrice, order.currency), COL.unit);
|
|
206
|
+
write(formatAmount(li.totalPrice, order.currency), COL.total);
|
|
207
|
+
newline();
|
|
208
|
+
}
|
|
209
|
+
newline();
|
|
210
|
+
|
|
211
|
+
const totals: Array<[string, number, boolean?]> = [
|
|
212
|
+
["Subtotal", order.subtotal],
|
|
213
|
+
...(order.discountTotal ? ([["Discount", -order.discountTotal]] as Array<[string, number]>) : []),
|
|
214
|
+
["Tax", order.taxTotal],
|
|
215
|
+
...(order.shippingTotal ? ([["Shipping", order.shippingTotal]] as Array<[string, number]>) : []),
|
|
216
|
+
["Total", order.grandTotal, true],
|
|
217
|
+
];
|
|
218
|
+
for (const [label, amount, bold] of totals) {
|
|
219
|
+
write(label, COL.qty, { bold });
|
|
220
|
+
write(formatAmount(amount, order.currency), COL.total, { bold });
|
|
221
|
+
newline();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (branding.receiptFooter) {
|
|
225
|
+
newline();
|
|
226
|
+
write(branding.receiptFooter, MARGIN, { size: 8 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
pages.push(ops);
|
|
230
|
+
return pages;
|
|
231
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
|
+
import type {
|
|
4
|
+
DrizzleDatabase,
|
|
5
|
+
DbOrTx,
|
|
6
|
+
} from "../../../kernel/database/drizzle-db.js";
|
|
7
|
+
import { invoiceSequences, orderDocuments } from "../schema.js";
|
|
8
|
+
|
|
9
|
+
export type OrderDocument = typeof orderDocuments.$inferSelect;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Persistence for fiscal sequences and issued documents (issue #47).
|
|
13
|
+
*/
|
|
14
|
+
export class DocumentsRepository {
|
|
15
|
+
constructor(private readonly db: DrizzleDatabase) {}
|
|
16
|
+
|
|
17
|
+
private getDb(ctx?: TxContext): DbOrTx {
|
|
18
|
+
return (ctx?.tx as DbOrTx | undefined) ?? this.db;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Atomically allocates the next sequence value for (org, series).
|
|
23
|
+
* Single upsert-returning statement — safe under concurrency.
|
|
24
|
+
*/
|
|
25
|
+
async allocate(orgId: string, series: string, ctx?: TxContext): Promise<number> {
|
|
26
|
+
const db = this.getDb(ctx);
|
|
27
|
+
const rows = await db
|
|
28
|
+
.insert(invoiceSequences)
|
|
29
|
+
.values({ organizationId: orgId, series, nextValue: 2 })
|
|
30
|
+
.onConflictDoUpdate({
|
|
31
|
+
target: [invoiceSequences.organizationId, invoiceSequences.series],
|
|
32
|
+
set: {
|
|
33
|
+
nextValue: sql`${invoiceSequences.nextValue} + 1`,
|
|
34
|
+
updatedAt: new Date(),
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
.returning({ nextValue: invoiceSequences.nextValue });
|
|
38
|
+
// nextValue is post-increment; the allocated number is one less.
|
|
39
|
+
return rows[0]!.nextValue - 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async findDocument(
|
|
43
|
+
orgId: string,
|
|
44
|
+
orderId: string,
|
|
45
|
+
type: string,
|
|
46
|
+
ctx?: TxContext,
|
|
47
|
+
): Promise<OrderDocument | undefined> {
|
|
48
|
+
const db = this.getDb(ctx);
|
|
49
|
+
const rows = await db
|
|
50
|
+
.select()
|
|
51
|
+
.from(orderDocuments)
|
|
52
|
+
.where(and(
|
|
53
|
+
eq(orderDocuments.organizationId, orgId),
|
|
54
|
+
eq(orderDocuments.orderId, orderId),
|
|
55
|
+
eq(orderDocuments.type, type),
|
|
56
|
+
));
|
|
57
|
+
return rows[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async createDocument(
|
|
61
|
+
data: { organizationId: string; orderId: string; type: string; documentNumber: string },
|
|
62
|
+
ctx?: TxContext,
|
|
63
|
+
): Promise<OrderDocument> {
|
|
64
|
+
const db = this.getDb(ctx);
|
|
65
|
+
const rows = await db.insert(orderDocuments).values(data).returning();
|
|
66
|
+
return rows[0]!;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
2
|
+
import { organization } from "../../auth/auth-schema.js";
|
|
3
|
+
import { orders } from "../orders/schema.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fiscal document sequences (issue #47).
|
|
7
|
+
*
|
|
8
|
+
* Per-(org, series) counters for legally sequential invoice numbers.
|
|
9
|
+
* Allocation is a single atomic upsert-returning statement, so concurrent
|
|
10
|
+
* requests never observe the same value.
|
|
11
|
+
*/
|
|
12
|
+
export const invoiceSequences = pgTable("invoice_sequences", {
|
|
13
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
14
|
+
organizationId: text("organization_id")
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
17
|
+
series: text("series").notNull().default("default"),
|
|
18
|
+
// The NEXT value to hand out; allocation returns the pre-increment value.
|
|
19
|
+
nextValue: integer("next_value").notNull().default(1),
|
|
20
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
21
|
+
}, (table) => ({
|
|
22
|
+
orgSeriesUnique: uniqueIndex("invoice_sequences_org_series_unique").on(table.organizationId, table.series),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Issued documents (issue #47). One row per (org, order, type) — re-rendering
|
|
27
|
+
* an order's invoice returns the number issued the first time, so a fiscal
|
|
28
|
+
* number never changes once handed out.
|
|
29
|
+
*/
|
|
30
|
+
export const orderDocuments = pgTable("order_documents", {
|
|
31
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
32
|
+
organizationId: text("organization_id")
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
35
|
+
orderId: uuid("order_id")
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => orders.id, { onDelete: "cascade" }),
|
|
38
|
+
type: text("type").notNull(), // "invoice" | "receipt"
|
|
39
|
+
documentNumber: text("document_number").notNull(),
|
|
40
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
41
|
+
}, (table) => ({
|
|
42
|
+
orgOrderTypeUnique: uniqueIndex("order_documents_org_order_type_unique").on(table.organizationId, table.orderId, table.type),
|
|
43
|
+
orgIdx: index("idx_order_documents_org").on(table.organizationId),
|
|
44
|
+
}));
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { resolveOrgId } from "../../auth/org.js";
|
|
2
|
+
import type { Actor } from "../../auth/types.js";
|
|
3
|
+
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
4
|
+
import { CommerceValidationError } from "../../kernel/errors.js";
|
|
5
|
+
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
6
|
+
import type { HydratedOrder, OrderService } from "../orders/service.js";
|
|
7
|
+
import type { SettingsService } from "../settings/service.js";
|
|
8
|
+
import { buildPdf } from "./pdf.js";
|
|
9
|
+
import {
|
|
10
|
+
invoiceHtml,
|
|
11
|
+
invoicePdfOps,
|
|
12
|
+
receiptHtml,
|
|
13
|
+
type DocumentBranding,
|
|
14
|
+
} from "./render.js";
|
|
15
|
+
import type { DocumentsRepository, OrderDocument } from "./repository/index.js";
|
|
16
|
+
|
|
17
|
+
interface DocumentsServiceDeps {
|
|
18
|
+
repository: DocumentsRepository;
|
|
19
|
+
// The shared kernel service container — orders/settings/email are resolved
|
|
20
|
+
// at call time (they may instantiate after this module).
|
|
21
|
+
services: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface EmailAdapter {
|
|
25
|
+
send(input: { template: string; to: string; data?: Record<string, unknown> }): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_INVOICE_PREFIX = "INV-";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Order document rendering + fiscal numbering (issue #47).
|
|
32
|
+
*/
|
|
33
|
+
export class DocumentsService {
|
|
34
|
+
private repository: DocumentsRepository;
|
|
35
|
+
private services: Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
constructor(deps: DocumentsServiceDeps) {
|
|
38
|
+
this.repository = deps.repository;
|
|
39
|
+
this.services = deps.services;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private get orders(): OrderService {
|
|
43
|
+
return this.services.orders as OrderService;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private get settings(): SettingsService {
|
|
47
|
+
return this.services.settings as SettingsService;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async brandingFor(orgId: string, ctx?: TxContext): Promise<DocumentBranding> {
|
|
51
|
+
return (await this.settings.read(orgId, "branding", ctx)) as DocumentBranding;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Issues (or returns the previously issued) fiscal invoice number for an
|
|
56
|
+
* order. Idempotent per (org, order): once handed out, the number never
|
|
57
|
+
* changes. Sequence allocation is atomic; a lost creation race falls back
|
|
58
|
+
* to the winner's document.
|
|
59
|
+
*/
|
|
60
|
+
async issueInvoiceNumber(
|
|
61
|
+
order: HydratedOrder,
|
|
62
|
+
ctx?: TxContext,
|
|
63
|
+
): Promise<OrderDocument> {
|
|
64
|
+
const orgId = order.organizationId;
|
|
65
|
+
const existing = await this.repository.findDocument(orgId, order.id, "invoice", ctx);
|
|
66
|
+
if (existing) return existing;
|
|
67
|
+
|
|
68
|
+
const documentsSettings = await this.settings.read(orgId, "documents", ctx);
|
|
69
|
+
const prefix =
|
|
70
|
+
typeof documentsSettings.invoicePrefix === "string"
|
|
71
|
+
? documentsSettings.invoicePrefix
|
|
72
|
+
: DEFAULT_INVOICE_PREFIX;
|
|
73
|
+
|
|
74
|
+
const value = await this.repository.allocate(orgId, "invoice", ctx);
|
|
75
|
+
const documentNumber = `${prefix}${String(value).padStart(6, "0")}`;
|
|
76
|
+
try {
|
|
77
|
+
return await this.repository.createDocument(
|
|
78
|
+
{ organizationId: orgId, orderId: order.id, type: "invoice", documentNumber },
|
|
79
|
+
ctx,
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Unique (org, order, type) — a concurrent request won; use its number.
|
|
83
|
+
const winner = await this.repository.findDocument(orgId, order.id, "invoice", ctx);
|
|
84
|
+
if (winner) return winner;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async loadOrder(
|
|
90
|
+
orderId: string,
|
|
91
|
+
actor: Actor | null,
|
|
92
|
+
ctx?: TxContext,
|
|
93
|
+
): Promise<Result<HydratedOrder>> {
|
|
94
|
+
return this.orders.getById(orderId, actor, ctx);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async renderInvoiceHtml(
|
|
98
|
+
orderId: string,
|
|
99
|
+
actor: Actor | null,
|
|
100
|
+
ctx?: TxContext,
|
|
101
|
+
): Promise<Result<{ html: string; invoiceNumber: string }>> {
|
|
102
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
103
|
+
if (!order.ok) return order;
|
|
104
|
+
const branding = await this.brandingFor(order.value.organizationId, ctx);
|
|
105
|
+
const doc = await this.issueInvoiceNumber(order.value, ctx);
|
|
106
|
+
return Ok({
|
|
107
|
+
html: invoiceHtml({ order: order.value, branding, invoiceNumber: doc.documentNumber }),
|
|
108
|
+
invoiceNumber: doc.documentNumber,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async renderInvoicePdf(
|
|
113
|
+
orderId: string,
|
|
114
|
+
actor: Actor | null,
|
|
115
|
+
ctx?: TxContext,
|
|
116
|
+
): Promise<Result<{ pdf: Uint8Array; invoiceNumber: string }>> {
|
|
117
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
118
|
+
if (!order.ok) return order;
|
|
119
|
+
const branding = await this.brandingFor(order.value.organizationId, ctx);
|
|
120
|
+
const doc = await this.issueInvoiceNumber(order.value, ctx);
|
|
121
|
+
const pages = invoicePdfOps({
|
|
122
|
+
order: order.value,
|
|
123
|
+
branding,
|
|
124
|
+
invoiceNumber: doc.documentNumber,
|
|
125
|
+
});
|
|
126
|
+
return Ok({ pdf: buildPdf(pages), invoiceNumber: doc.documentNumber });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async renderReceiptHtml(
|
|
130
|
+
orderId: string,
|
|
131
|
+
actor: Actor | null,
|
|
132
|
+
ctx?: TxContext,
|
|
133
|
+
): Promise<Result<{ html: string }>> {
|
|
134
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
135
|
+
if (!order.ok) return order;
|
|
136
|
+
const branding = await this.brandingFor(order.value.organizationId, ctx);
|
|
137
|
+
return Ok({ html: receiptHtml({ order: order.value, branding }) });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Emails the invoice via the configured email adapter. The adapter's
|
|
142
|
+
* template contract is (template, to, data) — the rendered HTML and the
|
|
143
|
+
* fiscal number ride in `data` so template-based adapters can use either.
|
|
144
|
+
*/
|
|
145
|
+
async emailInvoice(
|
|
146
|
+
orderId: string,
|
|
147
|
+
to: string,
|
|
148
|
+
actor: Actor | null,
|
|
149
|
+
ctx?: TxContext,
|
|
150
|
+
): Promise<Result<{ sent: true; invoiceNumber: string; to: string }>> {
|
|
151
|
+
const email = this.services.email as EmailAdapter | undefined;
|
|
152
|
+
if (!email) {
|
|
153
|
+
return Err(new CommerceValidationError("No email adapter is configured."));
|
|
154
|
+
}
|
|
155
|
+
const rendered = await this.renderInvoiceHtml(orderId, actor, ctx);
|
|
156
|
+
if (!rendered.ok) return rendered;
|
|
157
|
+
|
|
158
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
159
|
+
if (!order.ok) return order;
|
|
160
|
+
|
|
161
|
+
await email.send({
|
|
162
|
+
template: "order-invoice",
|
|
163
|
+
to,
|
|
164
|
+
data: {
|
|
165
|
+
orgId: resolveOrgId(actor ?? ctx?.actor ?? null),
|
|
166
|
+
orderId,
|
|
167
|
+
orderNumber: order.value.orderNumber,
|
|
168
|
+
invoiceNumber: rendered.value.invoiceNumber,
|
|
169
|
+
grandTotal: order.value.grandTotal,
|
|
170
|
+
currency: order.value.currency,
|
|
171
|
+
html: rendered.value.html,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return Ok({ sent: true, invoiceNumber: rendered.value.invoiceNumber, to });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { Actor } from "../../auth/types.js";
|
|
|
4
4
|
import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
|
|
5
5
|
import type { PluginDb } from "../../kernel/database/plugin-types.js";
|
|
6
6
|
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
7
|
-
import { CommerceNotFoundError } from "../../kernel/errors.js";
|
|
7
|
+
import { CommerceNotFoundError, CommerceValidationError } from "../../kernel/errors.js";
|
|
8
8
|
import { runAfterHooks } from "../../kernel/hooks/executor.js";
|
|
9
9
|
import { createHookContext } from "../../kernel/hooks/create-context.js";
|
|
10
10
|
import type { HookRegistry } from "../../kernel/hooks/registry.js";
|
|
@@ -346,6 +346,144 @@ export class FulfillmentService {
|
|
|
346
346
|
return Ok(undefined);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Records a fulfillment (e.g. a shipment) for a subset of an order's line
|
|
351
|
+
* items, with optional carrier/tracking details. Supports partial
|
|
352
|
+
* fulfillment (per-line quantities) and multiple fulfillments per order.
|
|
353
|
+
*/
|
|
354
|
+
async createFulfillment(
|
|
355
|
+
input: {
|
|
356
|
+
orderId: string;
|
|
357
|
+
lineItems: Array<{ orderLineItemId: string; quantity: number }>;
|
|
358
|
+
carrier?: string;
|
|
359
|
+
trackingNumber?: string;
|
|
360
|
+
trackingUrl?: string;
|
|
361
|
+
type?: string;
|
|
362
|
+
status?: string;
|
|
363
|
+
metadata?: Record<string, unknown>;
|
|
364
|
+
},
|
|
365
|
+
actor?: Actor | null,
|
|
366
|
+
ctx?: TxContext,
|
|
367
|
+
): Promise<Result<FulfillmentRecord>> {
|
|
368
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
369
|
+
const order = await this.deps.ordersRepository.findById(orgId, input.orderId, ctx);
|
|
370
|
+
if (!order) return Err(new CommerceNotFoundError("Order not found."));
|
|
371
|
+
|
|
372
|
+
const orderLines = await this.deps.ordersRepository.findLineItemsByOrderId(
|
|
373
|
+
input.orderId,
|
|
374
|
+
ctx,
|
|
375
|
+
);
|
|
376
|
+
const linesById = new Map(orderLines.map((li) => [li.id, li]));
|
|
377
|
+
|
|
378
|
+
// Validate every requested line before writing anything: it must belong
|
|
379
|
+
// to the order, and requested + already-fulfilled must not exceed ordered.
|
|
380
|
+
const alreadyFulfilled = new Map<string, number>();
|
|
381
|
+
for (const req of input.lineItems) {
|
|
382
|
+
const line = linesById.get(req.orderLineItemId);
|
|
383
|
+
if (!line) {
|
|
384
|
+
return Err(
|
|
385
|
+
new CommerceValidationError(
|
|
386
|
+
`Line item ${req.orderLineItemId} does not belong to this order.`,
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const fulfilled = await this.deps.repository.getFulfilledQuantity(
|
|
391
|
+
req.orderLineItemId,
|
|
392
|
+
ctx,
|
|
393
|
+
);
|
|
394
|
+
alreadyFulfilled.set(req.orderLineItemId, fulfilled);
|
|
395
|
+
if (fulfilled + req.quantity > line.quantity) {
|
|
396
|
+
return Err(
|
|
397
|
+
new CommerceValidationError(
|
|
398
|
+
`Cannot fulfill ${req.quantity} of "${line.title}" — only ${line.quantity - fulfilled} remaining.`,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const status = input.status ?? "shipped";
|
|
405
|
+
const created = await this.deps.repository.create(
|
|
406
|
+
{
|
|
407
|
+
orderId: order.id,
|
|
408
|
+
type: input.type ?? "physical",
|
|
409
|
+
status,
|
|
410
|
+
carrier: input.carrier ?? null,
|
|
411
|
+
trackingNumber: input.trackingNumber ?? null,
|
|
412
|
+
trackingUrl: input.trackingUrl ?? null,
|
|
413
|
+
...(status === "shipped" ? { shippedAt: new Date() } : {}),
|
|
414
|
+
...(status === "delivered" ? { deliveredAt: new Date() } : {}),
|
|
415
|
+
...(order.customerId != null ? { customerId: order.customerId } : {}),
|
|
416
|
+
metadata: input.metadata ?? {},
|
|
417
|
+
},
|
|
418
|
+
ctx,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await this.deps.repository.createLineItems(
|
|
422
|
+
input.lineItems.map((li) => ({
|
|
423
|
+
fulfillmentId: created.id,
|
|
424
|
+
orderLineItemId: li.orderLineItemId,
|
|
425
|
+
quantity: li.quantity,
|
|
426
|
+
})),
|
|
427
|
+
ctx,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
await this.deps.repository.createEvent(
|
|
431
|
+
{
|
|
432
|
+
fulfillmentId: created.id,
|
|
433
|
+
eventType: "created",
|
|
434
|
+
toStatus: status,
|
|
435
|
+
...(actor?.userId !== undefined ? { actorId: actor.userId } : {}),
|
|
436
|
+
description: input.trackingNumber
|
|
437
|
+
? `Shipment recorded (${input.trackingNumber})`
|
|
438
|
+
: "Fulfillment recorded",
|
|
439
|
+
},
|
|
440
|
+
ctx,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
// Reflect per-line fulfillment progress on the order line items
|
|
444
|
+
for (const req of input.lineItems) {
|
|
445
|
+
const line = linesById.get(req.orderLineItemId)!;
|
|
446
|
+
const total = (alreadyFulfilled.get(req.orderLineItemId) ?? 0) + req.quantity;
|
|
447
|
+
await this.deps.ordersRepository.updateLineItem(
|
|
448
|
+
req.orderLineItemId,
|
|
449
|
+
{ fulfillmentStatus: total >= line.quantity ? "fulfilled" : "partial" },
|
|
450
|
+
ctx,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const record: FulfillmentRecord = {
|
|
455
|
+
...toServiceRecord(
|
|
456
|
+
created,
|
|
457
|
+
input.lineItems.map((li) => ({
|
|
458
|
+
id: "",
|
|
459
|
+
fulfillmentId: created.id,
|
|
460
|
+
orderLineItemId: li.orderLineItemId,
|
|
461
|
+
quantity: li.quantity,
|
|
462
|
+
})),
|
|
463
|
+
),
|
|
464
|
+
lineItems: input.lineItems.map((li) => ({
|
|
465
|
+
id: li.orderLineItemId,
|
|
466
|
+
title: linesById.get(li.orderLineItemId)?.title ?? "",
|
|
467
|
+
quantity: li.quantity,
|
|
468
|
+
})),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const afterHooks = this.deps.hooks.resolve(
|
|
472
|
+
"fulfillment.afterCreate",
|
|
473
|
+
) as AfterHook<FulfillmentRecord>[];
|
|
474
|
+
const hookCtx: HookContext = createHookContext({
|
|
475
|
+
actor: actor ?? ctx?.actor ?? null,
|
|
476
|
+
tx: ctx?.tx ?? null,
|
|
477
|
+
logger: createLogger("fulfillment"),
|
|
478
|
+
services: this.deps.services,
|
|
479
|
+
context: { moduleName: "fulfillment" },
|
|
480
|
+
database: { db: this.deps.database.db as PluginDb },
|
|
481
|
+
});
|
|
482
|
+
await runAfterHooks(afterHooks, null, record, "create", hookCtx);
|
|
483
|
+
|
|
484
|
+
return Ok(record);
|
|
485
|
+
}
|
|
486
|
+
|
|
349
487
|
async getByOrderId(
|
|
350
488
|
orderId: string,
|
|
351
489
|
ctx?: TxContext,
|