@porulle/core 0.7.0 → 0.8.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 (289) hide show
  1. package/dist/auth/setup.d.ts.map +1 -1
  2. package/dist/auth/setup.js +1 -0
  3. package/dist/config/defaults.d.ts.map +1 -1
  4. package/dist/config/defaults.js +5 -0
  5. package/dist/config/types.d.ts +17 -1
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/generated/plugin-manifest.d.ts +3 -0
  8. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.js +1 -0
  10. package/dist/hooks/checkout.d.ts.map +1 -1
  11. package/dist/hooks/checkout.js +16 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/interfaces/rest/index.d.ts.map +1 -1
  16. package/dist/interfaces/rest/index.js +12 -0
  17. package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
  18. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
  19. package/dist/interfaces/rest/routes/admin/staff.js +172 -0
  20. package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
  21. package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
  22. package/dist/interfaces/rest/routes/analytics.js +24 -0
  23. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  24. package/dist/interfaces/rest/routes/carts.js +39 -2
  25. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  26. package/dist/interfaces/rest/routes/catalog.js +17 -1
  27. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  28. package/dist/interfaces/rest/routes/checkout.js +17 -0
  29. package/dist/interfaces/rest/routes/documents.d.ts +10 -0
  30. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
  31. package/dist/interfaces/rest/routes/documents.js +40 -0
  32. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  33. package/dist/interfaces/rest/routes/orders.js +98 -1
  34. package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
  35. package/dist/interfaces/rest/routes/pricing.js +36 -1
  36. package/dist/interfaces/rest/routes/settings.d.ts +5 -0
  37. package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
  38. package/dist/interfaces/rest/routes/settings.js +50 -0
  39. package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
  40. package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
  41. package/dist/interfaces/rest/routes/shipping.js +68 -0
  42. package/dist/interfaces/rest/routes/tax.d.ts +5 -0
  43. package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
  44. package/dist/interfaces/rest/routes/tax.js +68 -0
  45. package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
  46. package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
  47. package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
  48. package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
  49. package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
  50. package/dist/interfaces/rest/schemas/analytics.js +33 -0
  51. package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
  52. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  53. package/dist/interfaces/rest/schemas/carts.js +38 -0
  54. package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
  55. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  56. package/dist/interfaces/rest/schemas/catalog.js +48 -0
  57. package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
  58. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  59. package/dist/interfaces/rest/schemas/checkout.js +4 -0
  60. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  61. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  62. package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
  63. package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
  64. package/dist/interfaces/rest/schemas/documents.js +65 -0
  65. package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
  66. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  67. package/dist/interfaces/rest/schemas/orders.js +233 -0
  68. package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
  69. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  70. package/dist/interfaces/rest/schemas/pricing.js +70 -0
  71. package/dist/interfaces/rest/schemas/responses.d.ts +156 -0
  72. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  73. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  74. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  75. package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
  76. package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
  77. package/dist/interfaces/rest/schemas/settings.js +79 -0
  78. package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
  79. package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
  80. package/dist/interfaces/rest/schemas/shipping.js +146 -0
  81. package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
  82. package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
  83. package/dist/interfaces/rest/schemas/tax.js +132 -0
  84. package/dist/kernel/database/schema.d.ts +4 -0
  85. package/dist/kernel/database/schema.d.ts.map +1 -1
  86. package/dist/kernel/database/schema.js +8 -0
  87. package/dist/kernel/hooks/types.d.ts +1 -1
  88. package/dist/kernel/hooks/types.d.ts.map +1 -1
  89. package/dist/kernel/plugin/manifest.d.ts +23 -0
  90. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  91. package/dist/kernel/plugin/manifest.js +17 -2
  92. package/dist/modules/analytics/module.d.ts.map +1 -1
  93. package/dist/modules/analytics/module.js +6 -0
  94. package/dist/modules/analytics/reports.d.ts +58 -0
  95. package/dist/modules/analytics/reports.d.ts.map +1 -0
  96. package/dist/modules/analytics/reports.js +249 -0
  97. package/dist/modules/analytics/service.d.ts +10 -0
  98. package/dist/modules/analytics/service.d.ts.map +1 -1
  99. package/dist/modules/analytics/service.js +14 -0
  100. package/dist/modules/cart/repository/index.d.ts +7 -0
  101. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  102. package/dist/modules/cart/repository/index.js +25 -1
  103. package/dist/modules/cart/schema.d.ts +17 -0
  104. package/dist/modules/cart/schema.d.ts.map +1 -1
  105. package/dist/modules/cart/schema.js +2 -0
  106. package/dist/modules/cart/schemas.d.ts +1 -0
  107. package/dist/modules/cart/schemas.d.ts.map +1 -1
  108. package/dist/modules/cart/schemas.js +1 -0
  109. package/dist/modules/cart/service.d.ts +32 -0
  110. package/dist/modules/cart/service.d.ts.map +1 -1
  111. package/dist/modules/cart/service.js +58 -0
  112. package/dist/modules/catalog/entity-service.d.ts +21 -0
  113. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  114. package/dist/modules/catalog/entity-service.js +132 -3
  115. package/dist/modules/catalog/schema.d.ts +34 -0
  116. package/dist/modules/catalog/schema.d.ts.map +1 -1
  117. package/dist/modules/catalog/schema.js +5 -0
  118. package/dist/modules/catalog/schemas.d.ts +3 -0
  119. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  120. package/dist/modules/catalog/schemas.js +3 -0
  121. package/dist/modules/catalog/service.d.ts +36 -0
  122. package/dist/modules/catalog/service.d.ts.map +1 -1
  123. package/dist/modules/catalog/service.js +6 -0
  124. package/dist/modules/documents/module.d.ts +7 -0
  125. package/dist/modules/documents/module.d.ts.map +1 -0
  126. package/dist/modules/documents/module.js +12 -0
  127. package/dist/modules/documents/pdf.d.ts +25 -0
  128. package/dist/modules/documents/pdf.d.ts.map +1 -0
  129. package/dist/modules/documents/pdf.js +80 -0
  130. package/dist/modules/documents/render.d.ts +32 -0
  131. package/dist/modules/documents/render.d.ts.map +1 -0
  132. package/dist/modules/documents/render.js +190 -0
  133. package/dist/modules/documents/repository/index.d.ts +25 -0
  134. package/dist/modules/documents/repository/index.d.ts.map +1 -0
  135. package/dist/modules/documents/repository/index.js +47 -0
  136. package/dist/modules/documents/schema.d.ts +214 -0
  137. package/dist/modules/documents/schema.d.ts.map +1 -0
  138. package/dist/modules/documents/schema.js +42 -0
  139. package/dist/modules/documents/service.d.ts +51 -0
  140. package/dist/modules/documents/service.d.ts.map +1 -0
  141. package/dist/modules/documents/service.js +119 -0
  142. package/dist/modules/fulfillment/service.d.ts +18 -0
  143. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  144. package/dist/modules/fulfillment/service.js +85 -1
  145. package/dist/modules/orders/repository/index.d.ts +18 -1
  146. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  147. package/dist/modules/orders/repository/index.js +86 -1
  148. package/dist/modules/orders/schema.d.ts +374 -0
  149. package/dist/modules/orders/schema.d.ts.map +1 -1
  150. package/dist/modules/orders/schema.js +52 -1
  151. package/dist/modules/orders/service.d.ts +86 -1
  152. package/dist/modules/orders/service.d.ts.map +1 -1
  153. package/dist/modules/orders/service.js +447 -20
  154. package/dist/modules/pricing/repository/index.d.ts +5 -0
  155. package/dist/modules/pricing/repository/index.d.ts.map +1 -1
  156. package/dist/modules/pricing/repository/index.js +19 -0
  157. package/dist/modules/pricing/service.d.ts +18 -0
  158. package/dist/modules/pricing/service.d.ts.map +1 -1
  159. package/dist/modules/pricing/service.js +38 -0
  160. package/dist/modules/settings/module.d.ts +6 -0
  161. package/dist/modules/settings/module.d.ts.map +1 -0
  162. package/dist/modules/settings/module.js +11 -0
  163. package/dist/modules/settings/repository/index.d.ts +16 -0
  164. package/dist/modules/settings/repository/index.d.ts.map +1 -0
  165. package/dist/modules/settings/repository/index.js +41 -0
  166. package/dist/modules/settings/schema.d.ts +121 -0
  167. package/dist/modules/settings/schema.d.ts.map +1 -0
  168. package/dist/modules/settings/schema.js +24 -0
  169. package/dist/modules/settings/service.d.ts +26 -0
  170. package/dist/modules/settings/service.d.ts.map +1 -0
  171. package/dist/modules/settings/service.js +51 -0
  172. package/dist/modules/shipping/calculator.d.ts +5 -0
  173. package/dist/modules/shipping/calculator.d.ts.map +1 -1
  174. package/dist/modules/shipping/calculator.js +10 -0
  175. package/dist/modules/shipping/module.d.ts +5 -1
  176. package/dist/modules/shipping/module.d.ts.map +1 -1
  177. package/dist/modules/shipping/module.js +4 -1
  178. package/dist/modules/shipping/repository/index.d.ts +27 -2
  179. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  180. package/dist/modules/shipping/repository/index.js +105 -1
  181. package/dist/modules/shipping/schema.d.ts +419 -0
  182. package/dist/modules/shipping/schema.d.ts.map +1 -0
  183. package/dist/modules/shipping/schema.js +56 -0
  184. package/dist/modules/shipping/service.d.ts +54 -0
  185. package/dist/modules/shipping/service.d.ts.map +1 -1
  186. package/dist/modules/shipping/service.js +161 -2
  187. package/dist/modules/tax/adapter.d.ts +13 -0
  188. package/dist/modules/tax/adapter.d.ts.map +1 -1
  189. package/dist/modules/tax/module.d.ts +5 -1
  190. package/dist/modules/tax/module.d.ts.map +1 -1
  191. package/dist/modules/tax/module.js +4 -1
  192. package/dist/modules/tax/repository/index.d.ts +38 -2
  193. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  194. package/dist/modules/tax/repository/index.js +138 -1
  195. package/dist/modules/tax/schema.d.ts +355 -0
  196. package/dist/modules/tax/schema.d.ts.map +1 -0
  197. package/dist/modules/tax/schema.js +55 -0
  198. package/dist/modules/tax/service.d.ts +60 -1
  199. package/dist/modules/tax/service.d.ts.map +1 -1
  200. package/dist/modules/tax/service.js +227 -2
  201. package/dist/runtime/kernel-modules.d.ts +15 -2
  202. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  203. package/dist/runtime/kernel-modules.js +4 -0
  204. package/dist/runtime/kernel-types.d.ts +5 -1
  205. package/dist/runtime/kernel-types.d.ts.map +1 -1
  206. package/dist/runtime/kernel-types.js +2 -0
  207. package/dist/runtime/server.js +1 -1
  208. package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
  209. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  210. package/dist/test-utils/create-plugin-test-app.js +15 -3
  211. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  212. package/dist/test-utils/rest-api-test-utils.js +6 -0
  213. package/package.json +3 -3
  214. package/src/auth/setup.ts +1 -0
  215. package/src/config/defaults.ts +5 -0
  216. package/src/config/types.ts +17 -1
  217. package/src/generated/plugin-capabilities.d.ts +1 -0
  218. package/src/generated/plugin-manifest.ts +1 -0
  219. package/src/generated/plugin-repositories.d.ts +1 -0
  220. package/src/hooks/checkout.ts +25 -3
  221. package/src/index.ts +1 -0
  222. package/src/interfaces/rest/index.ts +12 -0
  223. package/src/interfaces/rest/routes/admin/staff.ts +218 -0
  224. package/src/interfaces/rest/routes/analytics.ts +33 -0
  225. package/src/interfaces/rest/routes/carts.ts +52 -2
  226. package/src/interfaces/rest/routes/catalog.ts +34 -0
  227. package/src/interfaces/rest/routes/checkout.ts +22 -0
  228. package/src/interfaces/rest/routes/documents.ts +61 -0
  229. package/src/interfaces/rest/routes/orders.ts +137 -1
  230. package/src/interfaces/rest/routes/pricing.ts +44 -1
  231. package/src/interfaces/rest/routes/settings.ts +61 -0
  232. package/src/interfaces/rest/routes/shipping.ts +92 -0
  233. package/src/interfaces/rest/routes/tax.ts +90 -0
  234. package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
  235. package/src/interfaces/rest/schemas/analytics.ts +39 -0
  236. package/src/interfaces/rest/schemas/carts.ts +40 -0
  237. package/src/interfaces/rest/schemas/catalog.ts +53 -0
  238. package/src/interfaces/rest/schemas/checkout.ts +4 -0
  239. package/src/interfaces/rest/schemas/documents.ts +72 -0
  240. package/src/interfaces/rest/schemas/orders.ts +257 -0
  241. package/src/interfaces/rest/schemas/pricing.ts +75 -0
  242. package/src/interfaces/rest/schemas/settings.ts +93 -0
  243. package/src/interfaces/rest/schemas/shipping.ts +164 -0
  244. package/src/interfaces/rest/schemas/tax.ts +148 -0
  245. package/src/kernel/database/schema.ts +12 -0
  246. package/src/kernel/hooks/types.ts +1 -0
  247. package/src/kernel/plugin/manifest.ts +38 -2
  248. package/src/modules/analytics/module.ts +10 -0
  249. package/src/modules/analytics/reports.ts +311 -0
  250. package/src/modules/analytics/service.ts +23 -0
  251. package/src/modules/cart/repository/index.ts +30 -1
  252. package/src/modules/cart/schema.ts +2 -0
  253. package/src/modules/cart/schemas.ts +1 -0
  254. package/src/modules/cart/service.ts +101 -0
  255. package/src/modules/catalog/entity-service.ts +168 -3
  256. package/src/modules/catalog/schema.ts +5 -0
  257. package/src/modules/catalog/schemas.ts +3 -0
  258. package/src/modules/catalog/service.ts +30 -0
  259. package/src/modules/documents/module.ts +19 -0
  260. package/src/modules/documents/pdf.ts +101 -0
  261. package/src/modules/documents/render.ts +231 -0
  262. package/src/modules/documents/repository/index.ts +68 -0
  263. package/src/modules/documents/schema.ts +44 -0
  264. package/src/modules/documents/service.ts +176 -0
  265. package/src/modules/fulfillment/service.ts +139 -1
  266. package/src/modules/orders/repository/index.ts +127 -1
  267. package/src/modules/orders/schema.ts +54 -1
  268. package/src/modules/orders/service.ts +654 -22
  269. package/src/modules/pricing/repository/index.ts +34 -0
  270. package/src/modules/pricing/service.ts +71 -0
  271. package/src/modules/settings/module.ts +18 -0
  272. package/src/modules/settings/repository/index.ts +59 -0
  273. package/src/modules/settings/schema.ts +25 -0
  274. package/src/modules/settings/service.ts +82 -0
  275. package/src/modules/shipping/calculator.ts +27 -0
  276. package/src/modules/shipping/module.ts +6 -2
  277. package/src/modules/shipping/repository/index.ts +136 -2
  278. package/src/modules/shipping/schema.ts +58 -0
  279. package/src/modules/shipping/service.ts +260 -1
  280. package/src/modules/tax/adapter.ts +9 -0
  281. package/src/modules/tax/module.ts +6 -2
  282. package/src/modules/tax/repository/index.ts +169 -2
  283. package/src/modules/tax/schema.ts +57 -0
  284. package/src/modules/tax/service.ts +313 -2
  285. package/src/runtime/kernel-modules.ts +4 -0
  286. package/src/runtime/kernel-types.ts +6 -0
  287. package/src/runtime/server.ts +1 -1
  288. package/src/test-utils/create-plugin-test-app.ts +17 -4
  289. package/src/test-utils/rest-api-test-utils.ts +6 -0
@@ -212,7 +212,7 @@ export class EntityService {
212
212
  const afterHooks = this.deps.hooks.resolve("catalog.afterCreate") as CatalogCreateAfterHook[];
213
213
  const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.create"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
214
214
  const processedInput = await runBeforeHooks(beforeHooks, input, "create", context);
215
- const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status: "draft", isVisible: false, metadata: processedInput.metadata ?? {} }, ctx);
215
+ const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status: "draft", isVisible: false, ...(processedInput.taxClass !== undefined ? { taxClass: processedInput.taxClass } : {}), metadata: processedInput.metadata ?? {} }, ctx);
216
216
  if (processedInput.attributes) {
217
217
  await this.repo.createAttribute({ entityId: entity.id, locale: processedInput.attributes.locale ?? "en", title: processedInput.attributes.title, subtitle: processedInput.attributes.subtitle, description: processedInput.attributes.description, richDescription: processedInput.attributes.richDescription, seoTitle: processedInput.attributes.seoTitle, seoDescription: processedInput.attributes.seoDescription }, ctx);
218
218
  }
@@ -232,7 +232,7 @@ export class EntityService {
232
232
  const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
233
233
  const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.update"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
234
234
  const processed = await runBeforeHooks(beforeHooks, input, "update", context);
235
- const updated = await this.repo.updateEntity(id, { ...(processed.slug !== undefined ? { slug: processed.slug } : {}), ...(processed.status !== undefined ? { status: processed.status as SellableEntity["status"] } : {}), ...(processed.metadata !== undefined ? { metadata: processed.metadata } : {}), ...(processed.isVisible !== undefined ? { isVisible: processed.isVisible } : {}) }, ctx);
235
+ const updated = await this.repo.updateEntity(id, { ...(processed.slug !== undefined ? { slug: processed.slug } : {}), ...(processed.status !== undefined ? { status: processed.status as SellableEntity["status"] } : {}), ...(processed.taxClass !== undefined ? { taxClass: processed.taxClass } : {}), ...(processed.metadata !== undefined ? { metadata: processed.metadata } : {}), ...(processed.isVisible !== undefined ? { isVisible: processed.isVisible } : {}) }, ctx);
236
236
  if (!updated) return Err(new CommerceNotFoundError("Entity not found."));
237
237
  const hookReport = await runAfterHooks(afterHooks, existing, updated, "update", context);
238
238
  const hydrated = await this.hydrateEntity(updated, undefined, ctx);
@@ -437,7 +437,7 @@ export class EntityService {
437
437
  if (!ov) return Err(new CommerceValidationError(`Option value "${optVal}" does not exist for option type "${optName}".`));
438
438
  optionValueIds.push(ov.id);
439
439
  }
440
- const variant = await this.repo.createVariant({ entityId: input.entityId, status: "active", sortOrder: 0, metadata: {}, ...(input.sku !== undefined ? { sku: input.sku } : {}) }, ctx);
440
+ const variant = await this.repo.createVariant({ entityId: input.entityId, status: "active", sortOrder: 0, metadata: {}, ...(input.sku !== undefined ? { sku: input.sku } : {}), ...(input.taxClass !== undefined ? { taxClass: input.taxClass } : {}) }, ctx);
441
441
  await this.repo.createVariantOptionValues(optionValueIds.map((optionValueId) => ({ variantId: variant.id, optionValueId })), ctx);
442
442
  return Ok(variant);
443
443
  }
@@ -490,4 +490,169 @@ export class EntityService {
490
490
  }
491
491
  return Ok(created);
492
492
  }
493
+
494
+ // ── One-call variant creation (issue #50) ────────────────────────────────
495
+ // Upserts option axes inline, creates variants, and seeds a zero-stock
496
+ // inventory level per variant so it is sellable immediately. Retry-safe:
497
+ // axes upsert by name/value and existing combinations are skipped.
498
+
499
+ private async upsertAxes(
500
+ entityId: string,
501
+ axes: Array<{ name: string; values: string[] }>,
502
+ ctx?: TxContext,
503
+ ): Promise<Map<string, Map<string, string>>> {
504
+ const existingTypes = await this.repo.findOptionTypesByEntityId(entityId, ctx);
505
+ const result = new Map<string, Map<string, string>>();
506
+ for (const axis of axes) {
507
+ let optionType = existingTypes.find((t) => t.name === axis.name);
508
+ if (!optionType) {
509
+ optionType = await this.repo.createOptionType(
510
+ { entityId, name: axis.name, displayName: axis.name, sortOrder: existingTypes.length },
511
+ ctx,
512
+ );
513
+ existingTypes.push(optionType);
514
+ }
515
+ const existingValues = await this.repo.findOptionValuesByTypeId(optionType.id, ctx);
516
+ const valueIds = new Map<string, string>();
517
+ for (const value of axis.values) {
518
+ let optionValue = existingValues.find((v) => v.value === value);
519
+ if (!optionValue) {
520
+ optionValue = await this.repo.createOptionValue(
521
+ { optionTypeId: optionType.id, value, displayValue: value, sortOrder: existingValues.length, metadata: {} },
522
+ ctx,
523
+ );
524
+ existingValues.push(optionValue);
525
+ }
526
+ valueIds.set(value, optionValue.id);
527
+ }
528
+ result.set(axis.name, valueIds);
529
+ }
530
+ return result;
531
+ }
532
+
533
+ private async existingComboKeys(entityId: string, ctx?: TxContext): Promise<Map<string, string>> {
534
+ const existing = new Map<string, string>();
535
+ for (const variant of await this.repo.findVariantsByEntityId(entityId, ctx)) {
536
+ const rows = await this.repo.findVariantOptionValues(variant.id, ctx);
537
+ const key = rows.map((r) => r.optionValueId).sort().join("|");
538
+ existing.set(key, variant.id);
539
+ }
540
+ return existing;
541
+ }
542
+
543
+ private async seedInventoryLevel(
544
+ entityId: string,
545
+ variantId: string,
546
+ actor: Actor | null,
547
+ ctx?: TxContext,
548
+ ): Promise<Result<unknown>> {
549
+ const inventory = this.deps.services.inventory as {
550
+ adjustDetailed: (
551
+ input: { entityId: string; variantId?: string; adjustment: number; reason: string },
552
+ actor?: Actor | null,
553
+ ctx?: TxContext,
554
+ ) => Promise<Result<unknown>>;
555
+ };
556
+ return inventory.adjustDetailed(
557
+ { entityId, variantId, adjustment: 0, reason: "Variant created — seed zero-stock level" },
558
+ actor,
559
+ ctx,
560
+ );
561
+ }
562
+
563
+ async quickCreateVariant(
564
+ entityId: string,
565
+ input: { options: Record<string, string>; sku?: string | undefined; barcode?: string | undefined },
566
+ actor: Actor | null,
567
+ ctx?: TxContext,
568
+ ): Promise<Result<{ variant: Variant; created: boolean }>> {
569
+ try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
570
+ const entity = await this.repo.findEntityById(entityId, ctx);
571
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
572
+ try { this.assertSameOrg(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
573
+
574
+ const axes = Object.entries(input.options).map(([name, value]) => ({ name, values: [value] }));
575
+ const axisValueIds = await this.upsertAxes(entityId, axes, ctx);
576
+ const optionValueIds = Object.entries(input.options)
577
+ .map(([name, value]) => axisValueIds.get(name)!.get(value)!);
578
+
579
+ const key = [...optionValueIds].sort().join("|");
580
+ const existing = await this.existingComboKeys(entityId, ctx);
581
+ const existingVariantId = existing.get(key);
582
+ if (existingVariantId !== undefined) {
583
+ const variant = await this.repo.findVariantById(existingVariantId, ctx);
584
+ return Ok({ variant: variant!, created: false });
585
+ }
586
+
587
+ const variant = await this.repo.createVariant(
588
+ {
589
+ entityId,
590
+ status: "active",
591
+ sortOrder: 0,
592
+ metadata: { generatedBy: "quick" },
593
+ ...(input.sku !== undefined ? { sku: input.sku } : {}),
594
+ ...(input.barcode !== undefined ? { barcode: input.barcode } : {}),
595
+ },
596
+ ctx,
597
+ );
598
+ await this.repo.createVariantOptionValues(
599
+ optionValueIds.map((optionValueId) => ({ variantId: variant.id, optionValueId })),
600
+ ctx,
601
+ );
602
+ const seeded = await this.seedInventoryLevel(entityId, variant.id, actor, ctx);
603
+ if (!seeded.ok) return seeded;
604
+ return Ok({ variant, created: true });
605
+ }
606
+
607
+ async bulkCreateVariants(
608
+ entityId: string,
609
+ input: { axes: Array<{ name: string; values: string[] }>; skuPrefix?: string | undefined },
610
+ actor: Actor | null,
611
+ ctx?: TxContext,
612
+ ): Promise<Result<{ created: Variant[]; skipped: number }>> {
613
+ try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
614
+ const entity = await this.repo.findEntityById(entityId, ctx);
615
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
616
+ try { this.assertSameOrg(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
617
+
618
+ const axisValueIds = await this.upsertAxes(entityId, input.axes, ctx);
619
+ // Empty axis list → one option-less variant.
620
+ const groups = input.axes.map((axis) =>
621
+ axis.values.map((value) => ({ value, id: axisValueIds.get(axis.name)!.get(value)! })),
622
+ );
623
+ const combinations = cartesian(groups);
624
+
625
+ const existing = await this.existingComboKeys(entityId, ctx);
626
+ const created: Variant[] = [];
627
+ let skipped = 0;
628
+ for (const combo of combinations) {
629
+ const key = combo.map((c) => c.id).sort().join("|");
630
+ if (existing.has(key)) {
631
+ skipped += 1;
632
+ continue;
633
+ }
634
+ const sku = input.skuPrefix !== undefined
635
+ ? [input.skuPrefix, ...combo.map((c) => c.value)].join("-").toUpperCase().replace(/\s+/g, "-")
636
+ : undefined;
637
+ const variant = await this.repo.createVariant(
638
+ {
639
+ entityId,
640
+ status: "active",
641
+ sortOrder: 0,
642
+ metadata: { generatedBy: "bulk" },
643
+ ...(sku !== undefined ? { sku } : {}),
644
+ },
645
+ ctx,
646
+ );
647
+ await this.repo.createVariantOptionValues(
648
+ combo.map((c) => ({ variantId: variant.id, optionValueId: c.id })),
649
+ ctx,
650
+ );
651
+ const seeded = await this.seedInventoryLevel(entityId, variant.id, actor, ctx);
652
+ if (!seeded.ok) return seeded;
653
+ existing.set(key, variant.id);
654
+ created.push(variant);
655
+ }
656
+ return Ok({ created, skipped });
657
+ }
493
658
  }
@@ -25,6 +25,9 @@ export const sellableEntities = pgTable(
25
25
  .notNull()
26
26
  .default("draft"),
27
27
  isVisible: boolean("is_visible").notNull().default(false),
28
+ // Tax class name (issue #57) — maps to tax_classes.name at checkout;
29
+ // a variant-level taxClass overrides this. Null = the org's default class.
30
+ taxClass: text("tax_class"),
28
31
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
29
32
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
30
33
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
@@ -183,6 +186,8 @@ export const variants = pgTable("variants", {
183
186
  .notNull(),
184
187
  sku: text("sku").unique(),
185
188
  barcode: text("barcode"),
189
+ // Overrides the entity's taxClass when set (issue #57).
190
+ taxClass: text("tax_class"),
186
191
  status: text("status", { enum: ["active", "discontinued"] })
187
192
  .notNull()
188
193
  .default("active"),
@@ -6,6 +6,7 @@ export const CreateEntityBodySchema = z.object({
6
6
  type: z.string().openapi({ example: "physicalGood" }),
7
7
  slug: z.string().openapi({ example: "my-product" }),
8
8
  status: z.string().optional().openapi({ example: "draft" }),
9
+ taxClass: z.string().optional().openapi({ example: "standard", description: "Product tax class name (issue #57)." }),
9
10
  basePrice: z.number().optional().openapi({ example: 29.99 }),
10
11
  currency: z.string().optional().openapi({ example: "USD" }),
11
12
  metadata: z.record(z.string(), z.unknown()).optional().openapi({ example: { title: "My Product" } }),
@@ -24,6 +25,7 @@ export const CreateEntityBodySchema = z.object({
24
25
  export const UpdateEntityBodySchema = z.object({
25
26
  slug: z.string().optional(),
26
27
  status: z.string().optional(),
28
+ taxClass: z.string().nullable().optional(),
27
29
  metadata: z.record(z.string(), z.unknown()).optional(),
28
30
  isVisible: z.boolean().optional(),
29
31
  }).openapi("UpdateEntityBody");
@@ -69,6 +71,7 @@ export const CreateVariantBodySchema = z.object({
69
71
  sku: z.string().optional().openapi({ example: "SKU-001" }),
70
72
  options: z.record(z.string(), z.string()).openapi({ example: { Color: "Red" } }),
71
73
  price: z.number().optional().openapi({ example: 34.99 }),
74
+ taxClass: z.string().optional().openapi({ example: "zero", description: "Overrides the entity's tax class (issue #57)." }),
72
75
  }).openapi("CreateVariantBody");
73
76
 
74
77
  const VariantMatrixRuleSchema = z.object({
@@ -259,6 +259,18 @@ export interface CatalogService {
259
259
  actor: Actor | null,
260
260
  ctx?: TxContext,
261
261
  ): Promise<Result<Variant[]>>;
262
+ quickCreateVariant(
263
+ entityId: string,
264
+ input: { options: Record<string, string>; sku?: string | undefined; barcode?: string | undefined },
265
+ actor: Actor | null,
266
+ ctx?: TxContext,
267
+ ): Promise<Result<{ variant: Variant; created: boolean }>>;
268
+ bulkCreateVariants(
269
+ entityId: string,
270
+ input: { axes: Array<{ name: string; values: string[] }>; skuPrefix?: string | undefined },
271
+ actor: Actor | null,
272
+ ctx?: TxContext,
273
+ ): Promise<Result<{ created: Variant[]; skipped: number }>>;
262
274
  }
263
275
 
264
276
  export interface CatalogServiceDeps {
@@ -411,4 +423,22 @@ export class CatalogServiceImpl implements CatalogService {
411
423
  generateVariants(entityId: string, strategy: VariantGenerationStrategy, actor: Actor | null, ctx?: TxContext): Promise<Result<Variant[]>> {
412
424
  return this.entities.generateVariants(entityId, strategy, actor, ctx);
413
425
  }
426
+
427
+ quickCreateVariant(
428
+ entityId: string,
429
+ input: { options: Record<string, string>; sku?: string | undefined; barcode?: string | undefined },
430
+ actor: Actor | null,
431
+ ctx?: TxContext,
432
+ ): Promise<Result<{ variant: Variant; created: boolean }>> {
433
+ return this.entities.quickCreateVariant(entityId, input, actor, ctx);
434
+ }
435
+
436
+ bulkCreateVariants(
437
+ entityId: string,
438
+ input: { axes: Array<{ name: string; values: string[] }>; skuPrefix?: string | undefined },
439
+ actor: Actor | null,
440
+ ctx?: TxContext,
441
+ ): Promise<Result<{ created: Variant[]; skipped: number }>> {
442
+ return this.entities.bulkCreateVariants(entityId, input, actor, ctx);
443
+ }
414
444
  }
@@ -0,0 +1,19 @@
1
+ import type { DrizzleDatabase } from "../../kernel/database/drizzle-db.js";
2
+ import { defineModule } from "../../kernel/module/index.js";
3
+ import { DocumentsRepository } from "./repository/index.js";
4
+ import { invoiceSequences, orderDocuments } from "./schema.js";
5
+ import { DocumentsService } from "./service.js";
6
+
7
+ export const documentsModule = defineModule<
8
+ { invoiceSequences: typeof invoiceSequences; orderDocuments: typeof orderDocuments },
9
+ DocumentsService,
10
+ Record<string, never>
11
+ >({
12
+ id: "documents",
13
+ schema: () => ({ invoiceSequences, orderDocuments }),
14
+ service: (deps) =>
15
+ new DocumentsService({
16
+ repository: new DocumentsRepository(deps.db.db as DrizzleDatabase),
17
+ services: deps.services as Record<string, unknown>,
18
+ }),
19
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Minimal dependency-free PDF writer (issue #47).
3
+ *
4
+ * Emits PDF 1.4 with Helvetica / Helvetica-Bold (built-in base-14 fonts, no
5
+ * embedding) and uncompressed content streams. Pure string/Uint8Array work —
6
+ * no Node-only APIs, so it runs on Cloudflare Workers and other serverless
7
+ * runtimes. Enough for text documents like invoices; not a general renderer.
8
+ */
9
+
10
+ export interface PdfTextOp {
11
+ x: number;
12
+ y: number;
13
+ size: number;
14
+ bold?: boolean | undefined;
15
+ text: string;
16
+ }
17
+
18
+ /** A4 in PDF points. */
19
+ export const A4 = { width: 595.28, height: 841.89 } as const;
20
+
21
+ // Escape to a PDF literal string: backslash, parens, and non-ASCII bytes as
22
+ // octal escapes (Latin-1 / WinAnsi range; anything beyond becomes "?").
23
+ function escapePdfText(text: string): string {
24
+ let out = "";
25
+ for (const ch of text) {
26
+ const code = ch.codePointAt(0)!;
27
+ if (ch === "\\" || ch === "(" || ch === ")") out += `\\${ch}`;
28
+ else if (code >= 32 && code <= 126) out += ch;
29
+ else if (code > 126 && code <= 255) out += `\\${code.toString(8).padStart(3, "0")}`;
30
+ else out += "?";
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function contentStream(ops: PdfTextOp[]): string {
36
+ const parts: string[] = [];
37
+ for (const op of ops) {
38
+ const font = op.bold ? "/F2" : "/F1";
39
+ parts.push(
40
+ `BT ${font} ${op.size} Tf ${op.x.toFixed(2)} ${op.y.toFixed(2)} Td (${escapePdfText(op.text)}) Tj ET`,
41
+ );
42
+ }
43
+ return parts.join("\n");
44
+ }
45
+
46
+ /**
47
+ * Builds a complete PDF document; one inner array of text ops per page.
48
+ */
49
+ export function buildPdf(pages: PdfTextOp[][]): Uint8Array {
50
+ // Object numbering: 1 Catalog, 2 Pages, 3 F1, 4 F2, then per page: Page, Contents.
51
+ const objects: string[] = [];
52
+ const pageObjectNumbers: number[] = [];
53
+ const firstPageObj = 5;
54
+
55
+ for (let i = 0; i < pages.length; i++) {
56
+ pageObjectNumbers.push(firstPageObj + i * 2);
57
+ }
58
+
59
+ objects.push(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`);
60
+ objects.push(
61
+ `2 0 obj\n<< /Type /Pages /Kids [${pageObjectNumbers.map((n) => `${n} 0 R`).join(" ")}] /Count ${pages.length} >>\nendobj\n`,
62
+ );
63
+ objects.push(`3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n`);
64
+ objects.push(`4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n`);
65
+
66
+ for (let i = 0; i < pages.length; i++) {
67
+ const pageNum = pageObjectNumbers[i]!;
68
+ const contentNum = pageNum + 1;
69
+ const stream = contentStream(pages[i]!);
70
+ objects.push(
71
+ `${pageNum} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${A4.width} ${A4.height}] ` +
72
+ `/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${contentNum} 0 R >>\nendobj\n`,
73
+ );
74
+ objects.push(
75
+ `${contentNum} 0 obj\n<< /Length ${stream.length} >>\nstream\n${stream}\nendstream\nendobj\n`,
76
+ );
77
+ }
78
+
79
+ const header = "%PDF-1.4\n";
80
+ let body = header;
81
+ const offsets: number[] = [];
82
+ for (const obj of objects) {
83
+ offsets.push(body.length);
84
+ body += obj;
85
+ }
86
+
87
+ const xrefOffset = body.length;
88
+ const count = objects.length + 1;
89
+ let xref = `xref\n0 ${count}\n0000000000 65535 f \n`;
90
+ for (const offset of offsets) {
91
+ xref += `${offset.toString().padStart(10, "0")} 00000 n \n`;
92
+ }
93
+ const trailer = `trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
94
+
95
+ const full = body + xref + trailer;
96
+ // All bytes are ASCII/Latin-1 by construction (escapePdfText), so a
97
+ // byte-per-char encoding is exact.
98
+ const bytes = new Uint8Array(full.length);
99
+ for (let i = 0; i < full.length; i++) bytes[i] = full.charCodeAt(i) & 0xff;
100
+ return bytes;
101
+ }
@@ -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("&", "&amp;")
25
+ .replaceAll("<", "&lt;")
26
+ .replaceAll(">", "&gt;")
27
+ .replaceAll('"', "&quot;")
28
+ .replaceAll("'", "&#39;");
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
+ }