@endora-commerce/mod-shipments 0.100.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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/backend/email-templates/transactional-defaults.d.ts +6 -0
  4. package/dist/backend/email-templates/transactional-defaults.d.ts.map +1 -0
  5. package/dist/backend/email-templates/transactional-defaults.js +27 -0
  6. package/dist/backend/email-templates/transactional-defaults.js.map +1 -0
  7. package/dist/backend/entities/shipment.entity.d.ts +40 -0
  8. package/dist/backend/entities/shipment.entity.d.ts.map +1 -0
  9. package/dist/backend/entities/shipment.entity.js +98 -0
  10. package/dist/backend/entities/shipment.entity.js.map +1 -0
  11. package/dist/backend/index.d.ts +71 -0
  12. package/dist/backend/index.d.ts.map +1 -0
  13. package/dist/backend/index.js +151 -0
  14. package/dist/backend/index.js.map +1 -0
  15. package/dist/backend/routes.d.ts +29 -0
  16. package/dist/backend/routes.d.ts.map +1 -0
  17. package/dist/backend/routes.js +35 -0
  18. package/dist/backend/routes.js.map +1 -0
  19. package/dist/backend/services/auto-shipment-on-paid.d.ts +28 -0
  20. package/dist/backend/services/auto-shipment-on-paid.d.ts.map +1 -0
  21. package/dist/backend/services/auto-shipment-on-paid.js +58 -0
  22. package/dist/backend/services/auto-shipment-on-paid.js.map +1 -0
  23. package/dist/backend/services/events.d.ts +40 -0
  24. package/dist/backend/services/events.d.ts.map +1 -0
  25. package/dist/backend/services/events.js +2 -0
  26. package/dist/backend/services/events.js.map +1 -0
  27. package/dist/backend/services/receive-shipment-handler.d.ts +93 -0
  28. package/dist/backend/services/receive-shipment-handler.d.ts.map +1 -0
  29. package/dist/backend/services/receive-shipment-handler.js +203 -0
  30. package/dist/backend/services/receive-shipment-handler.js.map +1 -0
  31. package/dist/backend/services/shipment-email-notifier.d.ts +78 -0
  32. package/dist/backend/services/shipment-email-notifier.d.ts.map +1 -0
  33. package/dist/backend/services/shipment-email-notifier.js +76 -0
  34. package/dist/backend/services/shipment-email-notifier.js.map +1 -0
  35. package/dist/backend/services/shipment-read.service.d.ts +19 -0
  36. package/dist/backend/services/shipment-read.service.d.ts.map +1 -0
  37. package/dist/backend/services/shipment-read.service.js +38 -0
  38. package/dist/backend/services/shipment-read.service.js.map +1 -0
  39. package/dist/backend/services/shipment-service.d.ts +79 -0
  40. package/dist/backend/services/shipment-service.d.ts.map +1 -0
  41. package/dist/backend/services/shipment-service.js +203 -0
  42. package/dist/backend/services/shipment-service.js.map +1 -0
  43. package/dist/backend/services/shipment-usage.service.d.ts +23 -0
  44. package/dist/backend/services/shipment-usage.service.d.ts.map +1 -0
  45. package/dist/backend/services/shipment-usage.service.js +26 -0
  46. package/dist/backend/services/shipment-usage.service.js.map +1 -0
  47. package/dist/backend/services/shipping-email-renderer.d.ts +22 -0
  48. package/dist/backend/services/shipping-email-renderer.d.ts.map +1 -0
  49. package/dist/backend/services/shipping-email-renderer.js +14 -0
  50. package/dist/backend/services/shipping-email-renderer.js.map +1 -0
  51. package/dist/manifest.d.ts +200 -0
  52. package/dist/manifest.d.ts.map +1 -0
  53. package/dist/manifest.js +63 -0
  54. package/dist/manifest.js.map +1 -0
  55. package/dist/migrations/20260817T194652_shipments_order_fk.d.ts +32 -0
  56. package/dist/migrations/20260817T194652_shipments_order_fk.d.ts.map +1 -0
  57. package/dist/migrations/20260817T194652_shipments_order_fk.js +66 -0
  58. package/dist/migrations/20260817T194652_shipments_order_fk.js.map +1 -0
  59. package/dist/migrations/20260819T171006_shipments_status_pending_manual.d.ts +19 -0
  60. package/dist/migrations/20260819T171006_shipments_status_pending_manual.d.ts.map +1 -0
  61. package/dist/migrations/20260819T171006_shipments_status_pending_manual.js +32 -0
  62. package/dist/migrations/20260819T171006_shipments_status_pending_manual.js.map +1 -0
  63. package/dist/migrations/index.d.ts +35 -0
  64. package/dist/migrations/index.d.ts.map +1 -0
  65. package/dist/migrations/index.js +38 -0
  66. package/dist/migrations/index.js.map +1 -0
  67. package/docs/shipments.md +96 -0
  68. package/package.json +69 -0
@@ -0,0 +1,58 @@
1
+ import { rethrowIfModuleDisabled } from '@endora-commerce/platform/kernel';
2
+ import { Shipment } from '../entities/shipment.entity.js';
3
+ /**
4
+ * Creates a shipment when payment settles and the order's shipping adapter
5
+ * opts in via `shouldAutoCreateOnPaid()` (feature 068).
6
+ *
7
+ * Wired from `shipments/backend.ts` through `ctx.subscribe('payment.received.v1')`
8
+ * — never via a bare `eventBus.on` (issue #107). Best-effort: ordinary failures
9
+ * are swallowed so payment settlement is never blocked; `ModuleDisabledError`
10
+ * is re-thrown so fail-closed stays fail-closed.
11
+ *
12
+ * The order and the delivery method are read over their owners' ports, exactly
13
+ * as `ShipmentService` reads them (feature 075 Phase C): both rows are ones this
14
+ * operation does not modify, so nothing is lost by leaving them on the owner's
15
+ * EntityManager, and both fail closed when their owner is off.
16
+ */
17
+ export class AutoShipmentOnPaidNotifier {
18
+ emFactory;
19
+ registry;
20
+ orderRead;
21
+ deliveryMethodRead;
22
+ shipmentService;
23
+ constructor(emFactory, registry, orderRead, deliveryMethodRead, shipmentService) {
24
+ this.emFactory = emFactory;
25
+ this.registry = registry;
26
+ this.orderRead = orderRead;
27
+ this.deliveryMethodRead = deliveryMethodRead;
28
+ this.shipmentService = shipmentService;
29
+ }
30
+ /** Exposed for unit tests. */
31
+ async maybeCreate(orderId) {
32
+ try {
33
+ const order = await this.orderRead.findById(orderId);
34
+ if (!order)
35
+ return;
36
+ const method = await this.deliveryMethodRead.findById(order.deliveryMethodId);
37
+ const adapterKey = method?.adapter ?? order.deliveryMethodId;
38
+ const adapter = this.registry.get(adapterKey);
39
+ if (!adapter?.shouldAutoCreateOnPaid)
40
+ return;
41
+ if (!(await adapter.shouldAutoCreateOnPaid()))
42
+ return;
43
+ const em = this.emFactory();
44
+ const latest = await em.findOne(Shipment, { orderId }, { orderBy: { attemptNo: 'desc' } });
45
+ if (latest && (latest.status === 'pending' || latest.status === 'success'))
46
+ return;
47
+ await this.shipmentService.createShipment(orderId);
48
+ }
49
+ catch (error) {
50
+ // Narrow tolerance, on purpose: `payment.received.v1` must settle the
51
+ // payment whatever the carrier says, so an ordinary adapter failure is
52
+ // left to the operator's "Generate shipment" button. A switched-off
53
+ // module is not an ordinary failure and is re-thrown first.
54
+ rethrowIfModuleDisabled(error);
55
+ }
56
+ }
57
+ }
58
+ //# sourceMappingURL=auto-shipment-on-paid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-shipment-on-paid.js","sourceRoot":"","sources":["../../../src/backend/services/auto-shipment-on-paid.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAG1D;;;;;;;;;;;;;GAaG;AACH,MAAM,OAAO,0BAA0B;IAElB;IACA;IACA;IACA;IACA;IALnB,YACmB,SAA8B,EAC9B,QAAqC,EACrC,SAAwB,EACxB,kBAA0C,EAC1C,eAAgC;QAJhC,cAAS,GAAT,SAAS,CAAqB;QAC9B,aAAQ,GAAR,QAAQ,CAA6B;QACrC,cAAS,GAAT,SAAS,CAAe;QACxB,uBAAkB,GAAlB,kBAAkB,CAAwB;QAC1C,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,8BAA8B;IAC9B,KAAK,CAAC,WAAW,CAAC,OAAe;QAC/B,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACrD,IAAI,CAAC,KAAK;gBAAE,OAAO;YAEnB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;YAC9E,MAAM,UAAU,GAAG,MAAM,EAAE,OAAO,IAAI,KAAK,CAAC,gBAAgB,CAAC;YAC7D,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;YAC9C,IAAI,CAAC,OAAO,EAAE,sBAAsB;gBAAE,OAAO;YAC7C,IAAI,CAAC,CAAC,MAAM,OAAO,CAAC,sBAAsB,EAAE,CAAC;gBAAE,OAAO;YAEtD,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;YAC5B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAC7B,QAAQ,EACR,EAAE,OAAO,EAAE,EACX,EAAE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,CACnC,CAAC;YACF,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC;gBAAE,OAAO;YAEnF,MAAM,IAAI,CAAC,eAAe,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,sEAAsE;YACtE,uEAAuE;YACvE,oEAAoE;YACpE,4DAA4D;YAC5D,uBAAuB,CAAC,KAAK,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,40 @@
1
+ import type { ShipmentStatus } from '@endora-commerce/contracts';
2
+ import type { EventBase, EventBus } from '@endora-commerce/platform/events';
3
+ /**
4
+ * Shipping domain events (feature 035). Emitted on the in-process EventBus
5
+ * inside the handler's transactional scope, so a rolled-back transaction never
6
+ * dispatches. The delivery-side twin of the payment.* events.
7
+ */
8
+ export interface ShippingEvents extends Record<string, EventBase> {
9
+ 'shipment.created.v1': EventBase & {
10
+ orderId: string;
11
+ shipmentId: string;
12
+ deliveryMethodId: string;
13
+ adapter: string;
14
+ attemptNo: number;
15
+ /**
16
+ * The state the row was opened in (issue #250). A subscriber that acts as
17
+ * if the carrier had been asked — the customer's "your order has shipped"
18
+ * e-mail is the one in this module — has to be able to tell a
19
+ * `pending_manual` shipment from a `pending` one, and the event is where
20
+ * it can, without re-reading the row it was just told about.
21
+ */
22
+ status: ShipmentStatus;
23
+ };
24
+ 'shipment.received.v1': EventBase & {
25
+ orderId: string;
26
+ shipmentId: string;
27
+ adapter: string;
28
+ externalReference: string | null;
29
+ attemptNo: number;
30
+ };
31
+ 'shipment.failed.v1': EventBase & {
32
+ orderId: string;
33
+ shipmentId: string;
34
+ adapter: string;
35
+ failureReason: string | null;
36
+ attemptNo: number;
37
+ };
38
+ }
39
+ export type ShippingEventBus = EventBus<ShippingEvents>;
40
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../src/backend/services/events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAE5E;;;;GAIG;AACH,MAAM,WAAW,cAAe,SAAQ,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC;IAC/D,qBAAqB,EAAE,SAAS,GAAG;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,gBAAgB,EAAE,MAAM,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB;;;;;;WAMG;QACH,MAAM,EAAE,cAAc,CAAC;KACxB,CAAC;IACF,sBAAsB,EAAE,SAAS,GAAG;QAClC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;QACjC,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,oBAAoB,EAAE,SAAS,GAAG;QAChC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC,cAAc,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../../../src/backend/services/events.ts"],"names":[],"mappings":""}
@@ -0,0 +1,93 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import { type ReceiveShipment } from '@endora-commerce/contracts';
3
+ import type { DeliveryMethodReadPort, OrderTransitionPort } from '@endora-commerce/contracts';
4
+ import { Shipment } from '../entities/shipment.entity.js';
5
+ import type { ShippingEventBus } from './events.js';
6
+ export interface ReceiveShipmentResult {
7
+ shipmentId: string;
8
+ status: Shipment['status'];
9
+ /**
10
+ * Where the order stands once the ingress is done with it, which since
11
+ * feature 085 Phase D is the lifecycle's answer rather than this handler's:
12
+ * the target the method configured when the graph permitted it, the order's
13
+ * unchanged status when it did not, and `null` when the ingress asked for no
14
+ * move at all (a repeated callback, or a method with no status configured).
15
+ */
16
+ orderStatus: string | null;
17
+ idempotent: boolean;
18
+ }
19
+ /**
20
+ * The one message this handler logs, as little of a logger as it needs.
21
+ *
22
+ * `ctx.log` satisfies it structurally, so `shipments/backend.ts` passes the
23
+ * module's own logger and a test passes a recorder. `payments` declares the
24
+ * same shape at its twin seam; neither module imports the other's.
25
+ */
26
+ export interface CarrierCallbackLogger {
27
+ warn(details: object, message: string): void;
28
+ }
29
+ /**
30
+ * ReceiveShipmentHandler (feature 035, FR-022/FR-023/FR-025).
31
+ *
32
+ * Resolves a Shipment from the ingress payload, applies the outcome, and asks
33
+ * the order lifecycle for the move the method's `statusOnSuccess` /
34
+ * `statusOnFailure` names. Idempotent: a success after a terminal `success` is
35
+ * a no-op; a failure after `success` is rejected (no downgrade). Resolves a
36
+ * late event even when the adapter has since been de-registered (it keys on the
37
+ * persisted Shipment, not the live registry).
38
+ *
39
+ * **The lifecycle answers; this handler does not decide** (feature 085 Phase
40
+ * D). It used to assign `order.status` after asking an `OrderStatusRegistry`
41
+ * whether the code existed, which is a different question from whether the
42
+ * order may go there — so a carrier callback wrote transitions the configured
43
+ * graph forbids while an operator making the same move by hand was refused, and
44
+ * the change was audited nowhere. The order row is no longer read here at all:
45
+ * the port carries the move, the audit entry, the side-effects and the
46
+ * templated `order.status.*.after` announcement.
47
+ */
48
+ export declare class ReceiveShipmentHandler {
49
+ private readonly emFactory;
50
+ private readonly deliveryMethodRead;
51
+ /**
52
+ * The lifecycle write (feature 085 Phase D), **called after this handler's
53
+ * transaction has committed** and never inside it: the port takes its own
54
+ * `EntityManager`, so a call from inside would write the order on a
55
+ * different pooled connection whose commit the enclosing rollback cannot
56
+ * reach (issue #200). A crash in between leaves the shipment recorded and
57
+ * the order unmoved, which an operator can see and repeat.
58
+ */
59
+ private readonly orderTransition;
60
+ /** Where a refused transition is recorded; see {@link CarrierCallbackLogger}. */
61
+ private readonly log;
62
+ private readonly events?;
63
+ constructor(emFactory: () => EntityManager, deliveryMethodRead: DeliveryMethodReadPort,
64
+ /**
65
+ * The lifecycle write (feature 085 Phase D), **called after this handler's
66
+ * transaction has committed** and never inside it: the port takes its own
67
+ * `EntityManager`, so a call from inside would write the order on a
68
+ * different pooled connection whose commit the enclosing rollback cannot
69
+ * reach (issue #200). A crash in between leaves the shipment recorded and
70
+ * the order unmoved, which an operator can see and repeat.
71
+ */
72
+ orderTransition: OrderTransitionPort,
73
+ /** Where a refused transition is recorded; see {@link CarrierCallbackLogger}. */
74
+ log: CarrierCallbackLogger, events?: ShippingEventBus | undefined);
75
+ receive(input: ReceiveShipment): Promise<ReceiveShipmentResult>;
76
+ /**
77
+ * Ask the lifecycle for the move the configured setting names, and answer
78
+ * where the order ended up.
79
+ *
80
+ * Every refusal is a 200 for the carrier (contract §"What the caller does
81
+ * with each outcome"): a provider retries a non-2xx callback, and an order
82
+ * further along than the configured target must not be dragged backwards
83
+ * because a delivery attempt failed. So the refusal is recorded here and the
84
+ * shipment row stands.
85
+ *
86
+ * There is deliberately no `catch`. A `ModuleDisabledError` out of the port's
87
+ * gate is not a refusal — it is the owner being absent — and swallowing it
88
+ * would turn fail-closed into fail-open.
89
+ */
90
+ private moveOrder;
91
+ private resolveShipment;
92
+ }
93
+ //# sourceMappingURL=receive-shipment-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receive-shipment-handler.d.ts","sourceRoot":"","sources":["../../../src/backend/services/receive-shipment-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAe,KAAK,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC/E,OAAO,KAAK,EACV,sBAAsB,EAEtB,mBAAmB,EACpB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAC1D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC3B;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9C;AAKD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,sBAAsB;IAE/B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IACnC;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAbP,SAAS,EAAE,MAAM,aAAa,EAC9B,kBAAkB,EAAE,sBAAsB;IAC3D;;;;;;;OAOG;IACc,eAAe,EAAE,mBAAmB;IACrD,iFAAiF;IAChE,GAAG,EAAE,qBAAqB,EAC1B,MAAM,CAAC,EAAE,gBAAgB,YAAA;IAGtC,OAAO,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAyHrE;;;;;;;;;;;;;OAaG;YACW,SAAS;YA8BT,eAAe;CAkB9B"}
@@ -0,0 +1,203 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { ERROR_CODES } from '@endora-commerce/contracts';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { Shipment } from '../entities/shipment.entity.js';
5
+ /**
6
+ * ReceiveShipmentHandler (feature 035, FR-022/FR-023/FR-025).
7
+ *
8
+ * Resolves a Shipment from the ingress payload, applies the outcome, and asks
9
+ * the order lifecycle for the move the method's `statusOnSuccess` /
10
+ * `statusOnFailure` names. Idempotent: a success after a terminal `success` is
11
+ * a no-op; a failure after `success` is rejected (no downgrade). Resolves a
12
+ * late event even when the adapter has since been de-registered (it keys on the
13
+ * persisted Shipment, not the live registry).
14
+ *
15
+ * **The lifecycle answers; this handler does not decide** (feature 085 Phase
16
+ * D). It used to assign `order.status` after asking an `OrderStatusRegistry`
17
+ * whether the code existed, which is a different question from whether the
18
+ * order may go there — so a carrier callback wrote transitions the configured
19
+ * graph forbids while an operator making the same move by hand was refused, and
20
+ * the change was audited nowhere. The order row is no longer read here at all:
21
+ * the port carries the move, the audit entry, the side-effects and the
22
+ * templated `order.status.*.after` announcement.
23
+ */
24
+ export class ReceiveShipmentHandler {
25
+ emFactory;
26
+ deliveryMethodRead;
27
+ orderTransition;
28
+ log;
29
+ events;
30
+ constructor(emFactory, deliveryMethodRead,
31
+ /**
32
+ * The lifecycle write (feature 085 Phase D), **called after this handler's
33
+ * transaction has committed** and never inside it: the port takes its own
34
+ * `EntityManager`, so a call from inside would write the order on a
35
+ * different pooled connection whose commit the enclosing rollback cannot
36
+ * reach (issue #200). A crash in between leaves the shipment recorded and
37
+ * the order unmoved, which an operator can see and repeat.
38
+ */
39
+ orderTransition,
40
+ /** Where a refused transition is recorded; see {@link CarrierCallbackLogger}. */
41
+ log, events) {
42
+ this.emFactory = emFactory;
43
+ this.deliveryMethodRead = deliveryMethodRead;
44
+ this.orderTransition = orderTransition;
45
+ this.log = log;
46
+ this.events = events;
47
+ }
48
+ async receive(input) {
49
+ // command-coverage-ignore: provider shipment-event ingestion — stamps the
50
+ // attempt result on the Shipment row, then asks `orderTransitionPort` for
51
+ // the lifecycle move, which records the `order.status_transition` audit
52
+ // entry co-transactionally with the status write it performs. The reason on
53
+ // this marker used to claim that audit while the handler assigned
54
+ // `order.status` itself and reached the orders flow at no point (feature
55
+ // 085, R3); Phase D made the claim true.
56
+ const em = this.emFactory();
57
+ const result = await em.transactional(async (tx) => {
58
+ const shipment = await this.resolveShipment(tx, input);
59
+ if (!shipment) {
60
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Shipment not found for the given reference.');
61
+ }
62
+ // Idempotency / terminal-state guards (FR-025).
63
+ if (shipment.status === 'success') {
64
+ if (input.outcome === 'failure') {
65
+ throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, 'Shipment is already generated; it cannot be marked failed.');
66
+ }
67
+ return {
68
+ shipment,
69
+ idempotent: true,
70
+ emit: false,
71
+ transition: null,
72
+ };
73
+ }
74
+ const method = await this.deliveryMethodRead.findById(shipment.deliveryMethodId);
75
+ let transition = null;
76
+ if (input.outcome === 'success') {
77
+ shipment.status = 'success';
78
+ if (input.externalReference !== undefined) {
79
+ shipment.externalReference = input.externalReference ?? null;
80
+ }
81
+ if (input.providerDetails)
82
+ shipment.providerDetails = input.providerDetails;
83
+ if (method?.statusOnSuccess) {
84
+ transition = { to: method.statusOnSuccess, setting: 'status_on_success' };
85
+ }
86
+ }
87
+ else {
88
+ shipment.status = 'failure';
89
+ shipment.failureReason = input.failureReason ?? null;
90
+ if (input.providerDetails)
91
+ shipment.providerDetails = input.providerDetails;
92
+ if (method?.statusOnFailure) {
93
+ transition = { to: method.statusOnFailure, setting: 'status_on_failure' };
94
+ }
95
+ }
96
+ await tx.flush();
97
+ return {
98
+ shipment,
99
+ idempotent: false,
100
+ emit: true,
101
+ adapter: method?.adapter ?? shipment.deliveryMethodId,
102
+ transition,
103
+ };
104
+ });
105
+ /**
106
+ * After the commit, never inside the transaction above (feature 085 R4).
107
+ *
108
+ * The transaction holds the shipment row alone now. It used to hold the
109
+ * order's status with it, which was the ground D-90 gave for importing the
110
+ * `Order` entity here; the lifecycle move is a graph decision with guards
111
+ * and side-effects rather than a column, so it goes through the port and
112
+ * the entity import goes with it.
113
+ */
114
+ const orderStatus = result.transition
115
+ ? await this.moveOrder(result.shipment.orderId, result.transition.to, result.transition.setting, input.outcome === 'failure' ? (input.failureReason ?? null) : null)
116
+ : null;
117
+ if (result.emit && this.events) {
118
+ const base = { eventId: randomUUID(), occurredAt: new Date().toISOString() };
119
+ if (result.shipment.status === 'success') {
120
+ this.events.emit('shipment.received.v1', {
121
+ ...base,
122
+ orderId: result.shipment.orderId,
123
+ shipmentId: result.shipment.id,
124
+ adapter: result.adapter ?? '',
125
+ externalReference: result.shipment.externalReference ?? null,
126
+ attemptNo: result.shipment.attemptNo,
127
+ });
128
+ }
129
+ else {
130
+ this.events.emit('shipment.failed.v1', {
131
+ ...base,
132
+ orderId: result.shipment.orderId,
133
+ shipmentId: result.shipment.id,
134
+ adapter: result.adapter ?? '',
135
+ failureReason: result.shipment.failureReason ?? null,
136
+ attemptNo: result.shipment.attemptNo,
137
+ });
138
+ }
139
+ // The templated `order.status.*.after` events (feature 038, T026) are no
140
+ // longer emitted from here: `OrderTransitionService` emits them itself,
141
+ // for the transition it applied, with the same actor. Announcing again
142
+ // would double every subscriber's reaction to one carrier callback.
143
+ }
144
+ return {
145
+ shipmentId: result.shipment.id,
146
+ status: result.shipment.status,
147
+ orderStatus,
148
+ idempotent: result.idempotent,
149
+ };
150
+ }
151
+ /**
152
+ * Ask the lifecycle for the move the configured setting names, and answer
153
+ * where the order ended up.
154
+ *
155
+ * Every refusal is a 200 for the carrier (contract §"What the caller does
156
+ * with each outcome"): a provider retries a non-2xx callback, and an order
157
+ * further along than the configured target must not be dragged backwards
158
+ * because a delivery attempt failed. So the refusal is recorded here and the
159
+ * shipment row stands.
160
+ *
161
+ * There is deliberately no `catch`. A `ModuleDisabledError` out of the port's
162
+ * gate is not a refusal — it is the owner being absent — and swallowing it
163
+ * would turn fail-closed into fail-open.
164
+ */
165
+ async moveOrder(orderId, to, setting, reason) {
166
+ const outcome = await this.orderTransition.applyStatus({
167
+ orderId,
168
+ to,
169
+ actor: { kind: 'system', source: 'shipment' },
170
+ reason,
171
+ });
172
+ if (outcome.applied)
173
+ return outcome.to;
174
+ if (outcome.reason === 'already_there')
175
+ return outcome.from;
176
+ this.log.warn({
177
+ orderId,
178
+ from: outcome.from,
179
+ to,
180
+ setting,
181
+ refusal: outcome.reason,
182
+ detail: outcome.detail,
183
+ }, 'shipments: the requested order status was not applied; the order keeps the one it has');
184
+ return outcome.from;
185
+ }
186
+ async resolveShipment(tx, input) {
187
+ if (input.shipmentId) {
188
+ return tx.findOne(Shipment, { id: input.shipmentId });
189
+ }
190
+ if (input.orderId && input.externalReference) {
191
+ const byRef = await tx.findOne(Shipment, {
192
+ orderId: input.orderId,
193
+ externalReference: input.externalReference,
194
+ });
195
+ if (byRef)
196
+ return byRef;
197
+ // Fall back to the most recent open attempt for the order.
198
+ return tx.findOne(Shipment, { orderId: input.orderId }, { orderBy: { attemptNo: 'desc' } });
199
+ }
200
+ return null;
201
+ }
202
+ }
203
+ //# sourceMappingURL=receive-shipment-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receive-shipment-handler.js","sourceRoot":"","sources":["../../../src/backend/services/receive-shipment-handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { CustomerAccountReadPort, OrderReadPort, ShipmentStatus, TransactionalEmailSender } from '@endora-commerce/contracts';
3
+ /**
4
+ * Why the shipment-created e-mail did — or did not — go out (issue #78).
5
+ *
6
+ * `notify` answered `void`, so a parcel whose customer was never told read
7
+ * exactly like one whose customer was: no sender wired, no order behind the id,
8
+ * no address on the customer, the three outcomes `send` reports since issue
9
+ * #67, and anything the bare `catch` absorbed all produced that same `void`.
10
+ */
11
+ export type ShipmentEmailNotSentReason =
12
+ /**
13
+ * The shipment opened `pending_manual`: no carrier was asked for it, so
14
+ * there is no carrier, no label and no tracking number to tell the customer
15
+ * about (issue #250). Telling a buyer their order has shipped when nothing
16
+ * has been handed to anyone is worse than telling them nothing, and it is
17
+ * not recoverable — the correcting message is one nobody sends.
18
+ */
19
+ 'carrier_not_contacted'
20
+ /** No transactional sender is wired in this composition. */
21
+ | 'no_sender'
22
+ /** The event named an order this process cannot load. */
23
+ | 'order_not_found'
24
+ /** The customer account carries no address to send to. */
25
+ | 'no_recipient'
26
+ /** The operator switched the `shipment_created` e-mail off. */
27
+ | 'deactivated'
28
+ /** No mailer is wired behind the sender. */
29
+ | 'no_transport'
30
+ /** No `shipment_created` template exists yet. */
31
+ | 'no_definition'
32
+ /** The send raised, and the shipment stays created. */
33
+ | 'failed';
34
+ export type ShipmentEmailResult = {
35
+ sent: true;
36
+ } | {
37
+ sent: false;
38
+ reason: ShipmentEmailNotSentReason;
39
+ };
40
+ /**
41
+ * Where a send that did not happen is reported. Injectable so a test can read
42
+ * it; defaults to `console.warn`, which is what the rest of this layer uses.
43
+ */
44
+ export type ShipmentEmailLog = (message: string, context: Record<string, unknown>) => void;
45
+ export interface ShipmentEmailNotifierDeps {
46
+ emFactory: () => EntityManager;
47
+ /**
48
+ * Feature 075 Phase C — the order and its buyer come from the ports their
49
+ * owners publish, not from `Order` and `CustomerAccount`. Both fail closed
50
+ * when their owner is off, and that is the right answer for a notification:
51
+ * an e-mail addressed from data the platform will not read is worse than no
52
+ * e-mail. `notify`'s `catch` re-throws `ModuleDisabledError` first, so the
53
+ * refusal reaches the subscriber rather than being logged as `failed`.
54
+ */
55
+ orderRead: OrderReadPort;
56
+ customerAccountRead: CustomerAccountReadPort;
57
+ getTransactionalEmailSender: () => TransactionalEmailSender | undefined;
58
+ log?: ShipmentEmailLog;
59
+ }
60
+ export declare class ShipmentEmailNotifier {
61
+ private readonly deps;
62
+ private readonly log;
63
+ constructor(deps: ShipmentEmailNotifierDeps);
64
+ /**
65
+ * Public since feature 072 (T124), and `attach(eventBus)` is gone with it.
66
+ * That method subscribed to the raw bus, which is how a shipment-created
67
+ * e-mail went out while this module was switched off; the module now
68
+ * subscribes through `ctx.subscribe`, which stops with it.
69
+ *
70
+ * **Best-effort, and now audible.** The subscriber that calls this has no
71
+ * result to inspect, so every non-sent path is written to the log as well as
72
+ * named in the return value; a shipment must not be un-created because the
73
+ * notification failed.
74
+ */
75
+ notify(orderId: string, shipmentId: string, status: ShipmentStatus): Promise<ShipmentEmailResult>;
76
+ private notSent;
77
+ }
78
+ //# sourceMappingURL=shipment-email-notifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shipment-email-notifier.d.ts","sourceRoot":"","sources":["../../../src/backend/services/shipment-email-notifier.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,uBAAuB,EACvB,aAAa,EACb,cAAc,EACd,wBAAwB,EACzB,MAAM,4BAA4B,CAAC;AAIpC;;;;;;;GAOG;AACH,MAAM,MAAM,0BAA0B;AACpC;;;;;;GAMG;AACD,uBAAuB;AACzB,4DAA4D;GAC1D,WAAW;AACb,yDAAyD;GACvD,iBAAiB;AACnB,0DAA0D;GACxD,cAAc;AAChB,+DAA+D;GAC7D,aAAa;AACf,4CAA4C;GAC1C,cAAc;AAChB,iDAAiD;GAC/C,eAAe;AACjB,uDAAuD;GACrD,QAAQ,CAAC;AAEb,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,0BAA0B,CAAA;CAAE,CAAC;AAExD;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAE3F,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,aAAa,CAAC;IAC/B;;;;;;;OAOG;IACH,SAAS,EAAE,aAAa,CAAC;IACzB,mBAAmB,EAAE,uBAAuB,CAAC;IAC7C,2BAA2B,EAAE,MAAM,wBAAwB,GAAG,SAAS,CAAC;IACxE,GAAG,CAAC,EAAE,gBAAgB,CAAC;CACxB;AAED,qBAAa,qBAAqB;IAGpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAmB;gBAEV,IAAI,EAAE,yBAAyB;IAI5D;;;;;;;;;;OAUG;IACG,MAAM,CACV,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,mBAAmB,CAAC;IAwC/B,OAAO,CAAC,OAAO;CAchB"}
@@ -0,0 +1,76 @@
1
+ // Feature 047 — shipment_created transactional email. A net-new email: subscribes
2
+ // to shipment.created.v1 and sends the admin-editable template to the order's
3
+ // customer. Best-effort; event-bus dispatch isolates handler errors.
4
+ import { rethrowIfModuleDisabled } from '@endora-commerce/platform/kernel';
5
+ import { SalesChannel } from '@endora-commerce/platform/kernel';
6
+ export class ShipmentEmailNotifier {
7
+ deps;
8
+ log;
9
+ constructor(deps) {
10
+ this.deps = deps;
11
+ this.log = deps.log ?? ((message, context) => console.warn(message, context));
12
+ }
13
+ /**
14
+ * Public since feature 072 (T124), and `attach(eventBus)` is gone with it.
15
+ * That method subscribed to the raw bus, which is how a shipment-created
16
+ * e-mail went out while this module was switched off; the module now
17
+ * subscribes through `ctx.subscribe`, which stops with it.
18
+ *
19
+ * **Best-effort, and now audible.** The subscriber that calls this has no
20
+ * result to inspect, so every non-sent path is written to the log as well as
21
+ * named in the return value; a shipment must not be un-created because the
22
+ * notification failed.
23
+ */
24
+ async notify(orderId, shipmentId, status) {
25
+ // First, and before any plumbing is consulted: this is a decision about the
26
+ // shipment, not about the mail. A `pending_manual` row is one no carrier
27
+ // was ever asked for, so "your order has shipped" would be false.
28
+ if (status === 'pending_manual') {
29
+ return this.notSent(orderId, shipmentId, 'carrier_not_contacted');
30
+ }
31
+ const sender = this.deps.getTransactionalEmailSender();
32
+ if (!sender)
33
+ return this.notSent(orderId, shipmentId, 'no_sender');
34
+ try {
35
+ const em = this.deps.emFactory();
36
+ const order = await this.deps.orderRead.findById(orderId);
37
+ if (!order)
38
+ return this.notSent(orderId, shipmentId, 'order_not_found');
39
+ const customer = await this.deps.customerAccountRead.findById(order.placedByCustomerAccountId);
40
+ if (!customer?.email)
41
+ return this.notSent(orderId, shipmentId, 'no_recipient');
42
+ const channel = await em.findOne(SalesChannel, { id: order.salesChannelId });
43
+ const outcome = await sender.send({
44
+ code: 'shipment_created',
45
+ salesChannelId: order.salesChannelId,
46
+ language: channel?.defaultLanguage ?? 'en-US',
47
+ to: customer.email,
48
+ messageId: `shipment_created:${shipmentId}`,
49
+ variables: { order: { businessId: order.businessId } },
50
+ meta: { orderId: order.id, shipmentId, kind: 'shipment_created' },
51
+ });
52
+ if (outcome.status !== 'sent')
53
+ return this.notSent(orderId, shipmentId, outcome.status);
54
+ return { sent: true };
55
+ }
56
+ catch (error) {
57
+ // A switched-off module is a presence answer about the whole operation,
58
+ // not a message that failed to render; absorbing it would report "sent
59
+ // nothing" where the truthful answer is "this capability is off".
60
+ rethrowIfModuleDisabled(error);
61
+ // Everything else is contained: the shipment exists and must not be
62
+ // undone because the message did not go out. It is named, though.
63
+ return this.notSent(orderId, shipmentId, 'failed', error);
64
+ }
65
+ }
66
+ notSent(orderId, shipmentId, reason, error) {
67
+ this.log('[shipments] the shipment-created e-mail was not sent', {
68
+ orderId,
69
+ shipmentId,
70
+ reason,
71
+ ...(error === undefined ? {} : { error: error instanceof Error ? error.message : error }),
72
+ });
73
+ return { sent: false, reason };
74
+ }
75
+ }
76
+ //# sourceMappingURL=shipment-email-notifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shipment-email-notifier.js","sourceRoot":"","sources":["../../../src/backend/services/shipment-email-notifier.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { ShipmentReadPort, ShipmentRecord } from '@endora-commerce/contracts';
3
+ import { Shipment } from '../entities/shipment.entity.js';
4
+ /**
5
+ * The reads behind `shipmentReadPort` — see the port's contract for why a
6
+ * carrier module asks them and what happens when this module is off.
7
+ *
8
+ * Deliberately not methods on {@link ShipmentService}: that service opens
9
+ * attempts and calls carriers, and a lookup by id has no business resolving an
10
+ * adapter registry, an order port and a delivery-method port to answer.
11
+ */
12
+ export declare class ShipmentReadService implements ShipmentReadPort {
13
+ private readonly emFactory;
14
+ constructor(emFactory: () => EntityManager);
15
+ findById(id: string): Promise<ShipmentRecord | null>;
16
+ findByExternalReference(reference: string): Promise<ShipmentRecord | null>;
17
+ }
18
+ export declare function toShipmentRecord(shipment: Shipment): ShipmentRecord;
19
+ //# sourceMappingURL=shipment-read.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shipment-read.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/shipment-read.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAE1D;;;;;;;GAOG;AACH,qBAAa,mBAAoB,YAAW,gBAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM,aAAa;IAErD,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAKpD,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;CAQjF;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,cAAc,CAanE"}