@endora-commerce/mod-carts 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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/admin/components/CartApprovalPolicyPanel.d.ts +51 -0
  4. package/dist/admin/components/CartApprovalPolicyPanel.d.ts.map +1 -0
  5. package/dist/admin/components/CartApprovalPolicyPanel.js +59 -0
  6. package/dist/admin/components/CartApprovalPolicyPanel.js.map +1 -0
  7. package/dist/admin/index.d.ts +38 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +73 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/admin/pages/CartDetail.d.ts +3 -0
  12. package/dist/admin/pages/CartDetail.d.ts.map +1 -0
  13. package/dist/admin/pages/CartDetail.js +74 -0
  14. package/dist/admin/pages/CartDetail.js.map +1 -0
  15. package/dist/admin/pages/CartsList.d.ts +3 -0
  16. package/dist/admin/pages/CartsList.d.ts.map +1 -0
  17. package/dist/admin/pages/CartsList.js +90 -0
  18. package/dist/admin/pages/CartsList.js.map +1 -0
  19. package/dist/admin/zones/CartApprovalPolicy.d.ts +29 -0
  20. package/dist/admin/zones/CartApprovalPolicy.d.ts.map +1 -0
  21. package/dist/admin/zones/CartApprovalPolicy.js +11 -0
  22. package/dist/admin/zones/CartApprovalPolicy.js.map +1 -0
  23. package/dist/backend/cli/abandonment-sweep.d.ts +64 -0
  24. package/dist/backend/cli/abandonment-sweep.d.ts.map +1 -0
  25. package/dist/backend/cli/abandonment-sweep.js +17 -0
  26. package/dist/backend/cli/abandonment-sweep.js.map +1 -0
  27. package/dist/backend/entities/cart-audit-entry.entity.d.ts +29 -0
  28. package/dist/backend/entities/cart-audit-entry.entity.d.ts.map +1 -0
  29. package/dist/backend/entities/cart-audit-entry.entity.js +109 -0
  30. package/dist/backend/entities/cart-audit-entry.entity.js.map +1 -0
  31. package/dist/backend/entities/cart-item.entity.d.ts +42 -0
  32. package/dist/backend/entities/cart-item.entity.d.ts.map +1 -0
  33. package/dist/backend/entities/cart-item.entity.js +119 -0
  34. package/dist/backend/entities/cart-item.entity.js.map +1 -0
  35. package/dist/backend/entities/cart.entity.d.ts +96 -0
  36. package/dist/backend/entities/cart.entity.d.ts.map +1 -0
  37. package/dist/backend/entities/cart.entity.js +207 -0
  38. package/dist/backend/entities/cart.entity.js.map +1 -0
  39. package/dist/backend/index.d.ts +122 -0
  40. package/dist/backend/index.d.ts.map +1 -0
  41. package/dist/backend/index.js +184 -0
  42. package/dist/backend/index.js.map +1 -0
  43. package/dist/backend/routes.admin.d.ts +31 -0
  44. package/dist/backend/routes.admin.d.ts.map +1 -0
  45. package/dist/backend/routes.admin.js +95 -0
  46. package/dist/backend/routes.admin.js.map +1 -0
  47. package/dist/backend/routes.d.ts +101 -0
  48. package/dist/backend/routes.d.ts.map +1 -0
  49. package/dist/backend/routes.js +620 -0
  50. package/dist/backend/routes.js.map +1 -0
  51. package/dist/backend/routes.organization.d.ts +29 -0
  52. package/dist/backend/routes.organization.d.ts.map +1 -0
  53. package/dist/backend/routes.organization.js +150 -0
  54. package/dist/backend/routes.organization.js.map +1 -0
  55. package/dist/backend/services/cart-abandonment-settings.d.ts +34 -0
  56. package/dist/backend/services/cart-abandonment-settings.d.ts.map +1 -0
  57. package/dist/backend/services/cart-abandonment-settings.js +55 -0
  58. package/dist/backend/services/cart-abandonment-settings.js.map +1 -0
  59. package/dist/backend/services/cart-abandonment-worker.d.ts +110 -0
  60. package/dist/backend/services/cart-abandonment-worker.d.ts.map +1 -0
  61. package/dist/backend/services/cart-abandonment-worker.js +185 -0
  62. package/dist/backend/services/cart-abandonment-worker.js.map +1 -0
  63. package/dist/backend/services/cart-admin-service.d.ts +58 -0
  64. package/dist/backend/services/cart-admin-service.d.ts.map +1 -0
  65. package/dist/backend/services/cart-admin-service.js +397 -0
  66. package/dist/backend/services/cart-admin-service.js.map +1 -0
  67. package/dist/backend/services/cart-approval-service.d.ts +147 -0
  68. package/dist/backend/services/cart-approval-service.d.ts.map +1 -0
  69. package/dist/backend/services/cart-approval-service.js +242 -0
  70. package/dist/backend/services/cart-approval-service.js.map +1 -0
  71. package/dist/backend/services/cart-audit-service.d.ts +75 -0
  72. package/dist/backend/services/cart-audit-service.d.ts.map +1 -0
  73. package/dist/backend/services/cart-audit-service.js +70 -0
  74. package/dist/backend/services/cart-audit-service.js.map +1 -0
  75. package/dist/backend/services/cart-conversion-service.d.ts +104 -0
  76. package/dist/backend/services/cart-conversion-service.d.ts.map +1 -0
  77. package/dist/backend/services/cart-conversion-service.js +175 -0
  78. package/dist/backend/services/cart-conversion-service.js.map +1 -0
  79. package/dist/backend/services/cart-coupon-service.d.ts +129 -0
  80. package/dist/backend/services/cart-coupon-service.d.ts.map +1 -0
  81. package/dist/backend/services/cart-coupon-service.js +244 -0
  82. package/dist/backend/services/cart-coupon-service.js.map +1 -0
  83. package/dist/backend/services/cart-organization-visibility-service.d.ts +43 -0
  84. package/dist/backend/services/cart-organization-visibility-service.d.ts.map +1 -0
  85. package/dist/backend/services/cart-organization-visibility-service.js +98 -0
  86. package/dist/backend/services/cart-organization-visibility-service.js.map +1 -0
  87. package/dist/backend/services/cart-placement-apply-port.d.ts +38 -0
  88. package/dist/backend/services/cart-placement-apply-port.d.ts.map +1 -0
  89. package/dist/backend/services/cart-placement-apply-port.js +71 -0
  90. package/dist/backend/services/cart-placement-apply-port.js.map +1 -0
  91. package/dist/backend/services/cart-pricing-recompute.d.ts +62 -0
  92. package/dist/backend/services/cart-pricing-recompute.d.ts.map +1 -0
  93. package/dist/backend/services/cart-pricing-recompute.js +131 -0
  94. package/dist/backend/services/cart-pricing-recompute.js.map +1 -0
  95. package/dist/backend/services/cart-query-service.d.ts +29 -0
  96. package/dist/backend/services/cart-query-service.d.ts.map +1 -0
  97. package/dist/backend/services/cart-query-service.js +40 -0
  98. package/dist/backend/services/cart-query-service.js.map +1 -0
  99. package/dist/backend/services/cart-read-port.d.ts +40 -0
  100. package/dist/backend/services/cart-read-port.d.ts.map +1 -0
  101. package/dist/backend/services/cart-read-port.js +144 -0
  102. package/dist/backend/services/cart-read-port.js.map +1 -0
  103. package/dist/backend/services/cart-recompute-cache.d.ts +49 -0
  104. package/dist/backend/services/cart-recompute-cache.d.ts.map +1 -0
  105. package/dist/backend/services/cart-recompute-cache.js +56 -0
  106. package/dist/backend/services/cart-recompute-cache.js.map +1 -0
  107. package/dist/backend/services/cart-service.d.ts +165 -0
  108. package/dist/backend/services/cart-service.d.ts.map +1 -0
  109. package/dist/backend/services/cart-service.js +556 -0
  110. package/dist/backend/services/cart-service.js.map +1 -0
  111. package/dist/backend/services/cart-state-machine.d.ts +51 -0
  112. package/dist/backend/services/cart-state-machine.d.ts.map +1 -0
  113. package/dist/backend/services/cart-state-machine.js +74 -0
  114. package/dist/backend/services/cart-state-machine.js.map +1 -0
  115. package/dist/backend/services/cart-upsell-service.d.ts +57 -0
  116. package/dist/backend/services/cart-upsell-service.d.ts.map +1 -0
  117. package/dist/backend/services/cart-upsell-service.js +100 -0
  118. package/dist/backend/services/cart-upsell-service.js.map +1 -0
  119. package/dist/backend/services/customer-group-resolver.d.ts +27 -0
  120. package/dist/backend/services/customer-group-resolver.d.ts.map +1 -0
  121. package/dist/backend/services/customer-group-resolver.js +32 -0
  122. package/dist/backend/services/customer-group-resolver.js.map +1 -0
  123. package/dist/manifest.d.ts +221 -0
  124. package/dist/manifest.d.ts.map +1 -0
  125. package/dist/manifest.js +224 -0
  126. package/dist/manifest.js.map +1 -0
  127. package/dist/migrations/20260611T140352_carts_consolidation.d.ts +43 -0
  128. package/dist/migrations/20260611T140352_carts_consolidation.d.ts.map +1 -0
  129. package/dist/migrations/20260611T140352_carts_consolidation.js +237 -0
  130. package/dist/migrations/20260611T140352_carts_consolidation.js.map +1 -0
  131. package/dist/migrations/20260611T140413_carts_cart_item_packaging.d.ts +12 -0
  132. package/dist/migrations/20260611T140413_carts_cart_item_packaging.d.ts.map +1 -0
  133. package/dist/migrations/20260611T140413_carts_cart_item_packaging.js +20 -0
  134. package/dist/migrations/20260611T140413_carts_cart_item_packaging.js.map +1 -0
  135. package/dist/migrations/20260818T081253_carts_cart_completed_order_fk.d.ts +50 -0
  136. package/dist/migrations/20260818T081253_carts_cart_completed_order_fk.d.ts.map +1 -0
  137. package/dist/migrations/20260818T081253_carts_cart_completed_order_fk.js +62 -0
  138. package/dist/migrations/20260818T081253_carts_cart_completed_order_fk.js.map +1 -0
  139. package/dist/migrations/20260830T112911_carts_organization_attribution_check.d.ts +67 -0
  140. package/dist/migrations/20260830T112911_carts_organization_attribution_check.d.ts.map +1 -0
  141. package/dist/migrations/20260830T112911_carts_organization_attribution_check.js +121 -0
  142. package/dist/migrations/20260830T112911_carts_organization_attribution_check.js.map +1 -0
  143. package/dist/migrations/index.d.ts +30 -0
  144. package/dist/migrations/index.d.ts.map +1 -0
  145. package/dist/migrations/index.js +35 -0
  146. package/dist/migrations/index.js.map +1 -0
  147. package/dist/ports/index.d.ts +106 -0
  148. package/dist/ports/index.d.ts.map +1 -0
  149. package/dist/ports/index.js +2 -0
  150. package/dist/ports/index.js.map +1 -0
  151. package/docs/carts.md +232 -0
  152. package/i18n/en.json +96 -0
  153. package/i18n/pl.json +96 -0
  154. package/package.json +107 -0
  155. package/tailwind.css +14 -0
@@ -0,0 +1,29 @@
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify';
2
+ import { type CustomerAccountReadPort } from '@endora-commerce/contracts';
3
+ import type { EntityManager } from '@mikro-orm/postgresql';
4
+ import type { CartService } from './services/cart-service.js';
5
+ import type { CartApprovalService } from './services/cart-approval-service.js';
6
+ import type { CartOrganizationVisibilityService } from './services/cart-organization-visibility-service.js';
7
+ export interface CartsOrganizationRoutesDeps {
8
+ cartService: CartService;
9
+ /**
10
+ * `customer_accounts`' read model (feature 075, Phase C). Two reads: the
11
+ * caller's own role — which is the org-admin gate on every route here — and
12
+ * the owner's e-mail on the cart detail. Both were `em.findOne` against
13
+ * `customer_accounts`' table, so the gate went on answering out of rows a
14
+ * deactivation leaves in place.
15
+ */
16
+ customerAccounts: CustomerAccountReadPort;
17
+ cartApprovalService: CartApprovalService;
18
+ visibilityService: CartOrganizationVisibilityService;
19
+ emFactory: () => EntityManager;
20
+ resolveCartActor: (request: FastifyRequest) => {
21
+ customer?: {
22
+ customerAccountId: string;
23
+ organizationId: string | null;
24
+ };
25
+ anonymousToken?: string;
26
+ };
27
+ }
28
+ export declare function registerCartsOrganizationRoutes(app: FastifyInstance, deps: CartsOrganizationRoutesDeps): Promise<void>;
29
+ //# sourceMappingURL=routes.organization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.organization.d.ts","sourceRoot":"","sources":["../../src/backend/routes.organization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC/D,OAAO,EAQL,KAAK,uBAAuB,EAC7B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC9D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC/E,OAAO,KAAK,EAAE,iCAAiC,EAAE,MAAM,oDAAoD,CAAC;AAsC5G,MAAM,WAAW,2BAA2B;IAC1C,WAAW,EAAE,WAAW,CAAC;IACzB;;;;;;OAMG;IACH,gBAAgB,EAAE,uBAAuB,CAAC;IAC1C,mBAAmB,EAAE,mBAAmB,CAAC;IACzC,iBAAiB,EAAE,iCAAiC,CAAC;IACrD,SAAS,EAAE,MAAM,aAAa,CAAC;IAC/B,gBAAgB,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK;QAC7C,QAAQ,CAAC,EAAE;YAAE,iBAAiB,EAAE,MAAM,CAAC;YAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,CAAC;QACxE,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAED,wBAAsB,+BAA+B,CACnD,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,2BAA2B,GAChC,OAAO,CAAC,IAAI,CAAC,CAoJf"}
@@ -0,0 +1,150 @@
1
+ import { rejectOrgCartSchema, setCartApprovalPolicySchema, cartStatusSchema, cartApprovalStatusSchema, ERROR_CODES, } from '@endora-commerce/contracts';
2
+ import { z } from 'zod';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ /**
5
+ * Storefront routes scoped to an Organization Admin (feature 027 US4).
6
+ *
7
+ * - GET /api/v1/organization/carts org-admin
8
+ * - GET /api/v1/organization/carts/:id org-admin
9
+ * - PATCH /api/v1/organization/policies/cart-approval org-admin
10
+ * - POST /api/v1/cart/submit-for-approval ordinary member
11
+ * - POST /api/v1/organization/carts/:id/approve org-admin
12
+ * - POST /api/v1/organization/carts/:id/reject org-admin
13
+ *
14
+ * Role check is uniform: the caller's CustomerAccount.role must be
15
+ * `organization_admin` for every endpoint marked "org-admin" above.
16
+ * Anti-enumeration: a foreign organization's carts return 404, not 403.
17
+ */
18
+ const orgCartsListQuerySchema = z.object({
19
+ status: z.union([
20
+ z.array(cartStatusSchema),
21
+ z.string().transform((s) => s.split(',').filter(Boolean)),
22
+ ]).optional(),
23
+ approvalStatus: z.union([
24
+ z.array(cartApprovalStatusSchema),
25
+ z.string().transform((s) => s.split(',').filter(Boolean)),
26
+ ]).optional(),
27
+ page: z.coerce.number().int().positive().optional(),
28
+ pageSize: z.coerce.number().int().positive().max(100).optional(),
29
+ sort: z.enum([
30
+ 'last_activity_desc',
31
+ 'last_activity_asc',
32
+ 'total_desc',
33
+ 'total_asc',
34
+ 'created_desc',
35
+ ]).optional(),
36
+ });
37
+ export async function registerCartsOrganizationRoutes(app, deps) {
38
+ const { cartService, cartApprovalService, visibilityService, customerAccounts, resolveCartActor, } = deps;
39
+ /** Resolves the caller as an Organization Administrator (404 if not). */
40
+ const requireOrgAdmin = async (request) => {
41
+ const actor = resolveCartActor(request);
42
+ if (!actor.customer) {
43
+ throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'Sign-in required.');
44
+ }
45
+ if (!actor.customer.organizationId) {
46
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'No organization in session.');
47
+ }
48
+ const me = await customerAccounts.findById(actor.customer.customerAccountId);
49
+ if (!me || me.role !== 'organization_admin') {
50
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'Organization Admin role required.');
51
+ }
52
+ return {
53
+ customerAccountId: actor.customer.customerAccountId,
54
+ organizationId: actor.customer.organizationId,
55
+ };
56
+ };
57
+ app.get('/api/v1/organization/carts', async (request) => {
58
+ const orgAdmin = await requireOrgAdmin(request);
59
+ const parsed = orgCartsListQuerySchema.parse(request.query ?? {});
60
+ const query = {};
61
+ if (parsed.status !== undefined)
62
+ query.status = parsed.status;
63
+ if (parsed.approvalStatus !== undefined)
64
+ query.approvalStatus = parsed.approvalStatus;
65
+ if (parsed.page !== undefined)
66
+ query.page = parsed.page;
67
+ if (parsed.pageSize !== undefined)
68
+ query.pageSize = parsed.pageSize;
69
+ if (parsed.sort !== undefined)
70
+ query.sort = parsed.sort;
71
+ return visibilityService.list(orgAdmin.organizationId, query);
72
+ });
73
+ app.get('/api/v1/organization/carts/:id', async (request) => {
74
+ const orgAdmin = await requireOrgAdmin(request);
75
+ const cart = await visibilityService.getInOrganization(request.params.id, orgAdmin.organizationId);
76
+ const items = await cartService.getItems(cart.id);
77
+ const total = items.reduce((acc, it) => acc + Number(it.unitPrice) * it.quantity, 0);
78
+ const currency = items[0]?.currency ?? 'PLN';
79
+ const owner = cart.customerAccountId
80
+ ? await customerAccounts.findById(cart.customerAccountId)
81
+ : null;
82
+ return {
83
+ data: {
84
+ id: cart.id,
85
+ ownerCustomerAccountId: cart.customerAccountId,
86
+ ownerDisplayName: owner?.email ?? cart.customerAccountId,
87
+ status: cart.status,
88
+ approvalStatus: cart.approvalStatus,
89
+ itemCount: items.length,
90
+ total: { amount: total, currency },
91
+ lastActivityAt: cart.lastActivityAt.toISOString(),
92
+ submittedForApprovalAt: cart.submittedForApprovalAt?.toISOString() ?? null,
93
+ approvedAt: cart.approvedAt?.toISOString() ?? null,
94
+ rejectedAt: cart.rejectedAt?.toISOString() ?? null,
95
+ rejectedReason: cart.rejectedReason ?? null,
96
+ },
97
+ };
98
+ });
99
+ app.patch('/api/v1/organization/policies/cart-approval', { schema: { body: setCartApprovalPolicySchema } }, async (request) => {
100
+ const orgAdmin = await requireOrgAdmin(request);
101
+ const body = setCartApprovalPolicySchema.parse(request.body);
102
+ const org = await cartApprovalService.setPolicyForOrganization(orgAdmin.organizationId, orgAdmin, body.requiresCartApproval);
103
+ return {
104
+ data: {
105
+ organizationId: org.id,
106
+ requiresCartApproval: org.requiresCartApproval,
107
+ updatedAt: org.updatedAt.toISOString(),
108
+ },
109
+ };
110
+ });
111
+ app.post('/api/v1/cart/submit-for-approval', async (request) => {
112
+ const actor = resolveCartActor(request);
113
+ if (!actor.customer) {
114
+ throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'Sign-in required.');
115
+ }
116
+ // D-178 — an invariant, not a business state: every customer account has an
117
+ // Organization (`customer_accounts.organization_id` is `NOT NULL`), so
118
+ // reaching this is a broken platform and not a buyer who needs to attach
119
+ // one. Kept rather than deleted — the actor's field is optional at this
120
+ // seam and the call below takes a `string`.
121
+ if (!actor.customer.organizationId) {
122
+ throw new HttpError(500, ERROR_CODES.INTERNAL, 'Invariant violated: a customer account has no Organization (Principle XI).');
123
+ }
124
+ const cart = await cartService.getOrCreateForCustomer(actor.customer);
125
+ await cartApprovalService.submitForApproval(cart, {
126
+ customerAccountId: actor.customer.customerAccountId,
127
+ organizationId: actor.customer.organizationId,
128
+ });
129
+ return { data: { ok: true, approvalStatus: cart.approvalStatus } };
130
+ });
131
+ app.post('/api/v1/organization/carts/:id/approve', async (request) => {
132
+ const orgAdmin = await requireOrgAdmin(request);
133
+ const cart = await cartApprovalService.approve(request.params.id, orgAdmin);
134
+ return { data: { ok: true, cartId: cart.id, approvalStatus: cart.approvalStatus } };
135
+ });
136
+ app.post('/api/v1/organization/carts/:id/reject', { schema: { body: rejectOrgCartSchema } }, async (request) => {
137
+ const orgAdmin = await requireOrgAdmin(request);
138
+ const body = rejectOrgCartSchema.parse(request.body);
139
+ const cart = await cartApprovalService.reject(request.params.id, orgAdmin, body.reason);
140
+ return {
141
+ data: {
142
+ ok: true,
143
+ cartId: cart.id,
144
+ status: cart.status,
145
+ approvalStatus: cart.approvalStatus,
146
+ },
147
+ };
148
+ });
149
+ }
150
+ //# sourceMappingURL=routes.organization.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.organization.js","sourceRoot":"","sources":["../../src/backend/routes.organization.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import type { SettingsReadPort } from '@endora-commerce/platform/kernel';
2
+ /**
3
+ * The two `carts.abandonment.*` reads, in one place (issue #54).
4
+ *
5
+ * They stood twice: once in `backend.ts`, for the composed sweep, and once in
6
+ * the hand-built ops CLI, for the operator running it by hand. Two copies of
7
+ * "which channel does this read name, and what does it fall back to" is
8
+ * precisely the drift the composition checklist's item 6 is about, and this
9
+ * pair has drifted before — both copies once passed the literal `'default'` as
10
+ * a channel id against a `uuid` column and both fell back to `0`, which the
11
+ * sweep reads as "sweep nothing" (feature 072, D-41/D-43).
12
+ *
13
+ * There is one reader now and no second copy to drift: since feature 080's
14
+ * T042b the operator command resolves the composition's own
15
+ * `cartAbandonmentWorker`, which this file already supplies.
16
+ *
17
+ * The reads are **platform-wide** (`null` channel) on purpose: an abandonment
18
+ * threshold and an ops mailbox are properties of the platform, not of a
19
+ * storefront. What may be absorbed is enumerated, because a bare `catch` around
20
+ * a port call turns fail-closed into fail-open — a shape mismatch, a driver
21
+ * error or a `ModuleDisabledError` propagates.
22
+ */
23
+ /** Resolvers in the shape {@link CartAbandonmentWorkerDeps} asks for. */
24
+ export interface AbandonmentSettingsReaders {
25
+ resolveInactivityMinutes: () => Promise<number>;
26
+ resolveNotificationRecipient: () => Promise<string>;
27
+ }
28
+ /**
29
+ * `settings` is a thunk, not a value: in the composed module it resolves the
30
+ * settings port on every call, and a port captured once keeps answering after
31
+ * its owner is switched off.
32
+ */
33
+ export declare function abandonmentSettingsReaders(settings: () => SettingsReadPort): AbandonmentSettingsReaders;
34
+ //# sourceMappingURL=cart-abandonment-settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cart-abandonment-settings.d.ts","sourceRoot":"","sources":["../../../src/backend/services/cart-abandonment-settings.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAOzE;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,yEAAyE;AACzE,MAAM,WAAW,0BAA0B;IACzC,wBAAwB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD,4BAA4B,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CACrD;AAkDD;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,MAAM,gBAAgB,GAC/B,0BAA0B,CAiB5B"}
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ import { SettingNotRegistered, SettingOutOfScopeForChannel } from '@endora-commerce/platform/kernel';
3
+ import { CARTS_SETTING_CODES, DEFAULT_ABANDONMENT_INACTIVITY_MINUTES, DEFAULT_ABANDONMENT_NOTIFICATION_RECIPIENT, } from '../../manifest.js';
4
+ /**
5
+ * Conditions the process has already reported. Never reset, so the guard is per
6
+ * **process**: an out-of-scope setting is a deployment fact that holds for every
7
+ * subsequent read, and the sweep runs on a timer. One line per condition is what
8
+ * makes it findable; one line per read is what makes it invisible. A CLI run is
9
+ * one pass and then exits, so "once per process" is "once" there — and the warn
10
+ * is still worth emitting, because an operator running the sweep by hand is
11
+ * precisely the person who needs to be told the threshold did not come from the
12
+ * setting they configured.
13
+ */
14
+ const warnedConditions = new Set();
15
+ function warnOnce(condition, message) {
16
+ if (warnedConditions.has(condition))
17
+ return;
18
+ warnedConditions.add(condition);
19
+ console.warn(message);
20
+ }
21
+ /**
22
+ * One platform-wide read of one of this module's own settings, degrading to the
23
+ * manifest default under exactly the two conditions D-43 allows: an
24
+ * unregistered code (quiet — the normal state before the manifest reconciler's
25
+ * first run) and a setting the operator scoped to specific channels (warned
26
+ * once, because that scoping cannot be intentional for a platform-wide value).
27
+ */
28
+ async function readPlatformSetting(settings, code, schema, fallback) {
29
+ try {
30
+ return await settings.get(code, null, schema);
31
+ }
32
+ catch (error) {
33
+ if (error instanceof SettingNotRegistered)
34
+ return fallback;
35
+ if (error instanceof SettingOutOfScopeForChannel) {
36
+ warnOnce(`out-of-scope:${code}`, `[carts] setting "${code}" is scoped to specific sales channels, so it has ` +
37
+ `no platform-wide value — falling back to the manifest default ` +
38
+ `(logged once per process).`);
39
+ return fallback;
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+ /**
45
+ * `settings` is a thunk, not a value: in the composed module it resolves the
46
+ * settings port on every call, and a port captured once keeps answering after
47
+ * its owner is switched off.
48
+ */
49
+ export function abandonmentSettingsReaders(settings) {
50
+ return {
51
+ resolveInactivityMinutes: async () => readPlatformSetting(settings(), CARTS_SETTING_CODES.ABANDONMENT_INACTIVITY_MINUTES, z.number().int().nonnegative(), DEFAULT_ABANDONMENT_INACTIVITY_MINUTES),
52
+ resolveNotificationRecipient: async () => readPlatformSetting(settings(), CARTS_SETTING_CODES.ABANDONMENT_NOTIFICATION_RECIPIENT, z.string(), DEFAULT_ABANDONMENT_NOTIFICATION_RECIPIENT),
53
+ };
54
+ }
55
+ //# sourceMappingURL=cart-abandonment-settings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cart-abandonment-settings.js","sourceRoot":"","sources":["../../../src/backend/services/cart-abandonment-settings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,oBAAoB,EAAE,2BAA2B,EAAE,MAAM,kCAAkC,CAAC;AAErG,OAAO,EACL,mBAAmB,EACnB,sCAAsC,EACtC,0CAA0C,GAC3C,MAAM,mBAAmB,CAAC;AA8B3B;;;;;;;;;GASG;AACH,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAU,CAAC;AAE3C,SAAS,QAAQ,CAAC,SAAiB,EAAE,OAAe;IAClD,IAAI,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC;QAAE,OAAO;IAC5C,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAChC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACxB,CAAC;AAED;;;;;;GAMG;AACH,KAAK,UAAU,mBAAmB,CAChC,QAA0B,EAC1B,IAAY,EACZ,MAAoB,EACpB,QAAW;IAEX,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;IAChD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,oBAAoB;YAAE,OAAO,QAAQ,CAAC;QAC3D,IAAI,KAAK,YAAY,2BAA2B,EAAE,CAAC;YACjD,QAAQ,CACN,gBAAgB,IAAI,EAAE,EACtB,oBAAoB,IAAI,oDAAoD;gBAC1E,gEAAgE;gBAChE,4BAA4B,CAC/B,CAAC;YACF,OAAO,QAAQ,CAAC;QAClB,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CACxC,QAAgC;IAEhC,OAAO;QACL,wBAAwB,EAAE,KAAK,IAAI,EAAE,CACnC,mBAAmB,CACjB,QAAQ,EAAE,EACV,mBAAmB,CAAC,8BAA8B,EAClD,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE,EAC9B,sCAAsC,CACvC;QACH,4BAA4B,EAAE,KAAK,IAAI,EAAE,CACvC,mBAAmB,CACjB,QAAQ,EAAE,EACV,mBAAmB,CAAC,kCAAkC,EACtD,CAAC,CAAC,MAAM,EAAE,EACV,0CAA0C,CAC3C;KACJ,CAAC;AACJ,CAAC"}
@@ -0,0 +1,110 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { CartAuditService } from './cart-audit-service.js';
3
+ /**
4
+ * CartAbandonmentWorker — feature 027 US5.
5
+ *
6
+ * Periodic sweep that transitions `active` carts inactive past the
7
+ * configured threshold to `abandoned`. Mirrors the RfqExpiryWorker
8
+ * pattern from feature 008: public `sweep()` is a plain async function
9
+ * so tests can drive it directly without Redis/BullMQ.
10
+ *
11
+ * Idempotency: the eligibility filter excludes carts that already have
12
+ * `abandonment_notified_at` set. The sweep cycle thus fires the
13
+ * notification at most once per abandonment episode; reactivation
14
+ * (CartService.touch / any owner-driven write) clears the column so
15
+ * the next episode can re-notify.
16
+ *
17
+ * Empty-cart suppression: per the spec's edge case, empty carts still
18
+ * transition to `abandoned` by status, but the notification e-mail is
19
+ * suppressed (no buyer-side signal worth notifying anyone about).
20
+ *
21
+ * ## The sweep works in batches (issue #152)
22
+ *
23
+ * It used to load every eligible cart at once and then, per cart, run one
24
+ * `em.find(CartItem)` and one `CartAuditService.record()` — which flushed. Over
25
+ * an identity map that kept every cart and every audit row it had already
26
+ * touched, so each flush re-examined the whole run: 500 carts cost 8.4 s
27
+ * (16.8 ms each) and 2000 cost 132.0 s (66.0 ms each). Four times the carts,
28
+ * sixteen times the time. At the 50 000 the spec states its target over, the
29
+ * benchmark timed out rather than reporting a number, which is why it was
30
+ * excluded from the perf schedules.
31
+ *
32
+ * Three things changed, and only together do they make the cost linear:
33
+ *
34
+ * 1. **Eligible carts are read a batch at a time.** The flip removes a cart
35
+ * from the eligibility filter, so the same query paginates itself; nothing
36
+ * holds 50 000 entities.
37
+ * 2. **Line counts are one aggregate per batch**, not one query per cart.
38
+ * 3. **Audit rows land with the flip**, in one flush per batch, through
39
+ * {@link CartAuditService.recordWithin}.
40
+ *
41
+ * and `em.clear()` at the end of each batch keeps the identity map the size of
42
+ * one batch rather than the size of the run.
43
+ *
44
+ * ## What a failure mid-sweep now costs
45
+ *
46
+ * Each batch commits its status flip and its audit rows in the same flush, so
47
+ * a batch is atomic: never an `abandoned` cart whose audit row is missing.
48
+ * Before, the whole eligible set was flipped and committed **first** and the
49
+ * audit rows followed one at a time, so a crash left every cart the sweep had
50
+ * not reached yet flipped-but-unaudited — permanently, since
51
+ * `abandonment_notified_at` had already been set and the next tick skips those
52
+ * carts. The window that used to be "the rest of the run" is now "nothing".
53
+ *
54
+ * Batches before the failure are complete and durable; batches after it are
55
+ * untouched and eligible for the next tick. The notification e-mail is the one
56
+ * thing that stays best-effort — it is dispatched after the batch is durable,
57
+ * exactly as it was after the per-cart write before.
58
+ */
59
+ /**
60
+ * Eligible carts read, flipped, audited and flushed per round trip.
61
+ *
62
+ * Chosen for the flush rather than the query: 500 carts is 500 updates plus
63
+ * 1000 audit inserts in one transaction, which PostgreSQL takes in a few
64
+ * multi-row statements, while the memory held between two `em.clear()` calls
65
+ * stays bounded by this number rather than by the corpus. Overridable per
66
+ * instance so a test can cross a batch boundary with a handful of carts.
67
+ */
68
+ export declare const DEFAULT_SWEEP_BATCH_SIZE = 500;
69
+ export interface CartAbandonmentWorkerDeps {
70
+ emFactory: () => EntityManager;
71
+ cartAuditService: CartAuditService;
72
+ /** Resolves the current `carts.abandonment.inactivity_minutes` from settings.
73
+ * `0` disables the sweep entirely. */
74
+ resolveInactivityMinutes: () => Promise<number>;
75
+ /** Resolves the current `carts.abandonment.notification_recipient`
76
+ * from settings. Empty string = no notification e-mail. */
77
+ resolveNotificationRecipient: () => Promise<string>;
78
+ /** Outbound e-mail dispatch. Implementation lives in composition so
79
+ * the worker stays decoupled from the email module. */
80
+ dispatchNotification?: (input: {
81
+ recipientEmail: string;
82
+ cartId: string;
83
+ ownerDisplayName: string | null;
84
+ lineCount: number;
85
+ organizationId: string | null;
86
+ }) => Promise<void>;
87
+ /** Defaults to {@link DEFAULT_SWEEP_BATCH_SIZE}. */
88
+ batchSize?: number;
89
+ }
90
+ export interface CartAbandonmentSweepResult {
91
+ abandonedCount: number;
92
+ notifiedCount: number;
93
+ }
94
+ export declare class CartAbandonmentWorker {
95
+ private readonly deps;
96
+ constructor(deps: CartAbandonmentWorkerDeps);
97
+ sweep(now?: Date): Promise<CartAbandonmentSweepResult>;
98
+ /**
99
+ * Lines per cart for a whole batch, as one grouped count.
100
+ *
101
+ * Executed through `em.execute` rather than awaited on the knex builder, so
102
+ * it runs on the EntityManager's transaction context — a bare knex call would
103
+ * take its own connection and, inside a wrapping transaction, would not see
104
+ * the rows the caller has written but not committed.
105
+ */
106
+ private countLines;
107
+ /** Returns how many notifications were dispatched for this batch. */
108
+ private dispatchFor;
109
+ }
110
+ //# sourceMappingURL=cart-abandonment-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cart-abandonment-worker.d.ts","sourceRoot":"","sources":["../../../src/backend/services/cart-abandonment-worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAGhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,aAAa,CAAC;IAC/B,gBAAgB,EAAE,gBAAgB,CAAC;IACnC;0CACsC;IACtC,wBAAwB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD;+DAC2D;IAC3D,4BAA4B,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD;2DACuD;IACvD,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE;QAC7B,cAAc,EAAE,MAAM,CAAC;QACvB,MAAM,EAAE,MAAM,CAAC;QACf,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;QAChC,SAAS,EAAE,MAAM,CAAC;QAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;KAC/B,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,qBAAa,qBAAqB;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,yBAAyB;IAEtD,KAAK,CAAC,GAAG,GAAE,IAAiB,GAAG,OAAO,CAAC,0BAA0B,CAAC;IA4ExE;;;;;;;OAOG;YACW,UAAU;IAmBxB,qEAAqE;YACvD,WAAW;CA+B1B"}
@@ -0,0 +1,185 @@
1
+ import { Cart } from '../entities/cart.entity.js';
2
+ import { CartItem } from '../entities/cart-item.entity.js';
3
+ import { withSystemScope } from '@endora-commerce/platform/tenancy';
4
+ /**
5
+ * CartAbandonmentWorker — feature 027 US5.
6
+ *
7
+ * Periodic sweep that transitions `active` carts inactive past the
8
+ * configured threshold to `abandoned`. Mirrors the RfqExpiryWorker
9
+ * pattern from feature 008: public `sweep()` is a plain async function
10
+ * so tests can drive it directly without Redis/BullMQ.
11
+ *
12
+ * Idempotency: the eligibility filter excludes carts that already have
13
+ * `abandonment_notified_at` set. The sweep cycle thus fires the
14
+ * notification at most once per abandonment episode; reactivation
15
+ * (CartService.touch / any owner-driven write) clears the column so
16
+ * the next episode can re-notify.
17
+ *
18
+ * Empty-cart suppression: per the spec's edge case, empty carts still
19
+ * transition to `abandoned` by status, but the notification e-mail is
20
+ * suppressed (no buyer-side signal worth notifying anyone about).
21
+ *
22
+ * ## The sweep works in batches (issue #152)
23
+ *
24
+ * It used to load every eligible cart at once and then, per cart, run one
25
+ * `em.find(CartItem)` and one `CartAuditService.record()` — which flushed. Over
26
+ * an identity map that kept every cart and every audit row it had already
27
+ * touched, so each flush re-examined the whole run: 500 carts cost 8.4 s
28
+ * (16.8 ms each) and 2000 cost 132.0 s (66.0 ms each). Four times the carts,
29
+ * sixteen times the time. At the 50 000 the spec states its target over, the
30
+ * benchmark timed out rather than reporting a number, which is why it was
31
+ * excluded from the perf schedules.
32
+ *
33
+ * Three things changed, and only together do they make the cost linear:
34
+ *
35
+ * 1. **Eligible carts are read a batch at a time.** The flip removes a cart
36
+ * from the eligibility filter, so the same query paginates itself; nothing
37
+ * holds 50 000 entities.
38
+ * 2. **Line counts are one aggregate per batch**, not one query per cart.
39
+ * 3. **Audit rows land with the flip**, in one flush per batch, through
40
+ * {@link CartAuditService.recordWithin}.
41
+ *
42
+ * and `em.clear()` at the end of each batch keeps the identity map the size of
43
+ * one batch rather than the size of the run.
44
+ *
45
+ * ## What a failure mid-sweep now costs
46
+ *
47
+ * Each batch commits its status flip and its audit rows in the same flush, so
48
+ * a batch is atomic: never an `abandoned` cart whose audit row is missing.
49
+ * Before, the whole eligible set was flipped and committed **first** and the
50
+ * audit rows followed one at a time, so a crash left every cart the sweep had
51
+ * not reached yet flipped-but-unaudited — permanently, since
52
+ * `abandonment_notified_at` had already been set and the next tick skips those
53
+ * carts. The window that used to be "the rest of the run" is now "nothing".
54
+ *
55
+ * Batches before the failure are complete and durable; batches after it are
56
+ * untouched and eligible for the next tick. The notification e-mail is the one
57
+ * thing that stays best-effort — it is dispatched after the batch is durable,
58
+ * exactly as it was after the per-cart write before.
59
+ */
60
+ /**
61
+ * Eligible carts read, flipped, audited and flushed per round trip.
62
+ *
63
+ * Chosen for the flush rather than the query: 500 carts is 500 updates plus
64
+ * 1000 audit inserts in one transaction, which PostgreSQL takes in a few
65
+ * multi-row statements, while the memory held between two `em.clear()` calls
66
+ * stays bounded by this number rather than by the corpus. Overridable per
67
+ * instance so a test can cross a batch boundary with a handful of carts.
68
+ */
69
+ export const DEFAULT_SWEEP_BATCH_SIZE = 500;
70
+ export class CartAbandonmentWorker {
71
+ deps;
72
+ constructor(deps) {
73
+ this.deps = deps;
74
+ }
75
+ async sweep(now = new Date()) {
76
+ // Feature 050 — system-wide sweep across all customers; run under a system
77
+ // scope so the Cart reads/writes carry a tenant context (fail-closed guard).
78
+ return withSystemScope('cart-abandonment sweep', async () => {
79
+ const inactivityMinutes = await this.deps.resolveInactivityMinutes();
80
+ if (!Number.isFinite(inactivityMinutes) || inactivityMinutes <= 0) {
81
+ return { abandonedCount: 0, notifiedCount: 0 };
82
+ }
83
+ const em = this.deps.emFactory();
84
+ const cutoff = new Date(now.getTime() - inactivityMinutes * 60_000);
85
+ const batchSize = Math.max(1, this.deps.batchSize ?? DEFAULT_SWEEP_BATCH_SIZE);
86
+ let abandonedCount = 0;
87
+ let notifiedCount = 0;
88
+ /** Read once, on the first batch that has work — as it was per sweep. */
89
+ let recipient = null;
90
+ for (;;) {
91
+ const batch = await em.find(Cart, {
92
+ status: 'active',
93
+ lastActivityAt: { $lt: cutoff },
94
+ abandonmentNotifiedAt: null,
95
+ }, { limit: batchSize });
96
+ if (batch.length === 0)
97
+ break;
98
+ if (recipient === null)
99
+ recipient = await this.deps.resolveNotificationRecipient();
100
+ const shouldNotify = recipient.trim().length > 0 && Boolean(this.deps.dispatchNotification);
101
+ const lineCounts = await this.countLines(em, batch.map((c) => c.id));
102
+ for (const cart of batch) {
103
+ cart.status = 'abandoned';
104
+ cart.abandonmentNotifiedAt = now;
105
+ }
106
+ this.deps.cartAuditService.recordWithin(em, batch.map((cart) => ({
107
+ cartId: cart.id,
108
+ actorType: 'sweep',
109
+ action: 'abandonment_swept',
110
+ fromState: 'active',
111
+ toState: 'abandoned',
112
+ metadata: { lineCount: lineCounts.get(cart.id) ?? 0, inactivityMinutes },
113
+ })));
114
+ // One flush, one transaction: the flip and the rows auditing it.
115
+ await em.flush();
116
+ abandonedCount += batch.length;
117
+ // Notification fan-out happens AFTER the batch is durable so a failure
118
+ // in the e-mail dispatch cannot leave status in a transient state.
119
+ if (shouldNotify && this.deps.dispatchNotification) {
120
+ notifiedCount += await this.dispatchFor(batch, recipient, lineCounts);
121
+ }
122
+ // The identity map is the other half of the old superlinear cost: every
123
+ // later flush walked the carts and audit rows of every earlier batch.
124
+ // Nothing above this line survives the batch, so dropping it is free.
125
+ em.clear();
126
+ if (batch.length < batchSize)
127
+ break;
128
+ }
129
+ return { abandonedCount, notifiedCount };
130
+ });
131
+ }
132
+ /**
133
+ * Lines per cart for a whole batch, as one grouped count.
134
+ *
135
+ * Executed through `em.execute` rather than awaited on the knex builder, so
136
+ * it runs on the EntityManager's transaction context — a bare knex call would
137
+ * take its own connection and, inside a wrapping transaction, would not see
138
+ * the rows the caller has written but not committed.
139
+ */
140
+ async countLines(em, cartIds) {
141
+ if (cartIds.length === 0)
142
+ return new Map();
143
+ const knex = em.getKnex();
144
+ // The table name comes from the entity's own metadata rather than a
145
+ // literal, so a rename stays a one-file change.
146
+ const table = em.getMetadata().get(CartItem).tableName;
147
+ const rows = await em.execute(knex(table)
148
+ .select('cart_id')
149
+ .count({ line_count: '*' })
150
+ .whereIn('cart_id', [...cartIds])
151
+ .groupBy('cart_id'));
152
+ return new Map(rows.map((r) => [r.cart_id, Number(r.line_count)]));
153
+ }
154
+ /** Returns how many notifications were dispatched for this batch. */
155
+ async dispatchFor(batch, recipient, lineCounts) {
156
+ const dispatch = this.deps.dispatchNotification;
157
+ if (!dispatch)
158
+ return 0;
159
+ let sent = 0;
160
+ for (const cart of batch) {
161
+ const lineCount = lineCounts.get(cart.id) ?? 0;
162
+ // Empty-cart suppression — no buyer signal worth e-mailing.
163
+ if (lineCount === 0)
164
+ continue;
165
+ try {
166
+ await dispatch({
167
+ recipientEmail: recipient,
168
+ cartId: cart.id,
169
+ ownerDisplayName: cart.customerAccountId ?? null,
170
+ lineCount,
171
+ organizationId: cart.organizationId ?? null,
172
+ });
173
+ sent += 1;
174
+ }
175
+ catch (err) {
176
+ // Notification failure must not roll back the status flip.
177
+ // The audit trail records the abandonment; an oncall who
178
+ // notices the e-mail never arrived can replay manually.
179
+ console.error('[cart-abandonment-worker] notification failed', err);
180
+ }
181
+ }
182
+ return sent;
183
+ }
184
+ }
185
+ //# sourceMappingURL=cart-abandonment-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cart-abandonment-worker.js","sourceRoot":"","sources":["../../../src/backend/services/cart-abandonment-worker.ts"],"names":[],"mappings":"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"}