@aglyn/plugins-commerce 1.0.0-beta.221 → 1.0.0-beta.222

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 (125) hide show
  1. package/README.md +4 -2
  2. package/package.json +14 -13
  3. package/src/lib/components/console/catalog-organization-card.component.js +9 -0
  4. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  5. package/src/lib/components/console/discounts-card.component.js +8 -0
  6. package/src/lib/components/console/discounts-card.component.js.map +1 -1
  7. package/src/lib/components/console/gift-cards-card.component.js +8 -0
  8. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  9. package/src/lib/components/console/host-coupons-card.component.js +8 -0
  10. package/src/lib/components/console/host-coupons-card.component.js.map +1 -1
  11. package/src/lib/components/console/host-orders-card.component.js +34 -70
  12. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  13. package/src/lib/components/console/product-zones.d.ts +7 -6
  14. package/src/lib/components/console/product-zones.js +5 -4
  15. package/src/lib/components/console/product-zones.js.map +1 -1
  16. package/src/lib/components/console/products-hub-card.component.js +86 -284
  17. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  18. package/src/lib/declarations.console-server.d.ts +38 -0
  19. package/src/lib/declarations.console-server.js +67 -0
  20. package/src/lib/declarations.console-server.js.map +1 -0
  21. package/src/lib/model/commerce-gift-cards.d.ts +26 -0
  22. package/src/lib/model/commerce-gift-cards.js +42 -0
  23. package/src/lib/model/commerce-gift-cards.js.map +1 -1
  24. package/src/lib/model/commerce-orders.d.ts +0 -57
  25. package/src/lib/model/commerce-orders.js +0 -80
  26. package/src/lib/model/commerce-orders.js.map +1 -1
  27. package/src/lib/model/index.d.ts +0 -1
  28. package/src/lib/model/index.js +0 -1
  29. package/src/lib/model/index.js.map +1 -1
  30. package/src/lib/plugin.js +5 -0
  31. package/src/lib/plugin.js.map +1 -1
  32. package/src/lib/server/api-v1/openapi.d.ts +27 -0
  33. package/src/lib/server/api-v1/openapi.js +219 -0
  34. package/src/lib/server/api-v1/openapi.js.map +1 -0
  35. package/src/lib/server/api-v1/orders-and-products.d.ts +19 -0
  36. package/src/lib/server/api-v1/orders-and-products.js +419 -0
  37. package/src/lib/server/api-v1/orders-and-products.js.map +1 -0
  38. package/src/lib/server/billing-webhook.js +10 -13
  39. package/src/lib/server/billing-webhook.js.map +1 -1
  40. package/src/lib/server/download.js +4 -1
  41. package/src/lib/server/download.js.map +1 -1
  42. package/src/lib/server/fulfill-order.d.ts +52 -11
  43. package/src/lib/server/fulfill-order.js +6 -9
  44. package/src/lib/server/fulfill-order.js.map +1 -1
  45. package/src/lib/server/gift-card-issue.d.ts +57 -0
  46. package/src/lib/server/gift-card-issue.js +155 -0
  47. package/src/lib/server/gift-card-issue.js.map +1 -0
  48. package/src/lib/server/gift-cards.js +25 -84
  49. package/src/lib/server/gift-cards.js.map +1 -1
  50. package/src/lib/server/membership-register.js +9 -8
  51. package/src/lib/server/membership-register.js.map +1 -1
  52. package/src/lib/server/newsletter.js +7 -5
  53. package/src/lib/server/newsletter.js.map +1 -1
  54. package/src/lib/server/paid-media-delivery.d.ts +21 -0
  55. package/src/lib/server/paid-media-delivery.js +23 -5
  56. package/src/lib/server/paid-media-delivery.js.map +1 -1
  57. package/src/lib/server/paid-media-serve-count.d.ts +28 -0
  58. package/src/lib/server/paid-media-serve-count.js +46 -0
  59. package/src/lib/server/paid-media-serve-count.js.map +1 -0
  60. package/src/lib/server/person-eraser.d.ts +45 -0
  61. package/src/lib/server/person-eraser.js +57 -0
  62. package/src/lib/server/person-eraser.js.map +1 -0
  63. package/src/lib/server/refund.js +15 -15
  64. package/src/lib/server/refund.js.map +1 -1
  65. package/src/lib/server/restock-flag.d.ts +2 -2
  66. package/src/lib/server/restock-flag.js +1 -1
  67. package/src/lib/server/restock-flag.js.map +1 -1
  68. package/src/lib/server/stream.js +4 -1
  69. package/src/lib/server/stream.js.map +1 -1
  70. package/src/lib/server.js +5 -21
  71. package/src/lib/server.js.map +1 -1
  72. package/src/lib/testing/stand-in-person-records.d.ts +43 -0
  73. package/src/lib/testing/stand-in-person-records.js +63 -0
  74. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  75. package/src/lib/transfer/gift-card-confirm-step.component.d.ts +22 -0
  76. package/src/lib/transfer/gift-card-confirm-step.component.js +259 -0
  77. package/src/lib/transfer/gift-card-confirm-step.component.js.map +1 -0
  78. package/src/lib/transfer/gift-cards-plan.d.ts +29 -0
  79. package/src/lib/transfer/gift-cards-plan.js +40 -0
  80. package/src/lib/transfer/gift-cards-plan.js.map +1 -0
  81. package/src/lib/transfer/gift-cards.server.d.ts +34 -0
  82. package/src/lib/transfer/gift-cards.server.js +296 -0
  83. package/src/lib/transfer/gift-cards.server.js.map +1 -0
  84. package/src/lib/transfer/list-filter.d.ts +29 -0
  85. package/src/lib/transfer/list-filter.js +112 -0
  86. package/src/lib/transfer/list-filter.js.map +1 -0
  87. package/src/lib/transfer/product-import-results.d.ts +29 -0
  88. package/src/lib/transfer/product-import-results.js +49 -0
  89. package/src/lib/transfer/product-import-results.js.map +1 -0
  90. package/src/lib/transfer/product-import-step.component.d.ts +30 -0
  91. package/src/lib/transfer/product-import-step.component.js +99 -0
  92. package/src/lib/transfer/product-import-step.component.js.map +1 -0
  93. package/src/lib/transfer/product-transfer.d.ts +211 -0
  94. package/src/lib/transfer/product-transfer.js +1594 -0
  95. package/src/lib/transfer/product-transfer.js.map +1 -0
  96. package/src/lib/transfer/products.server.d.ts +44 -0
  97. package/src/lib/transfer/products.server.js +550 -0
  98. package/src/lib/transfer/products.server.js.map +1 -0
  99. package/src/lib/transfer/records-transfer.d.ts +182 -0
  100. package/src/lib/transfer/records-transfer.js +1153 -0
  101. package/src/lib/transfer/records-transfer.js.map +1 -0
  102. package/src/lib/transfer/records.server.d.ts +68 -0
  103. package/src/lib/transfer/records.server.js +455 -0
  104. package/src/lib/transfer/records.server.js.map +1 -0
  105. package/src/lib/transfer/register-transfer-resources.d.ts +17 -0
  106. package/src/lib/transfer/register-transfer-resources.js +171 -0
  107. package/src/lib/transfer/register-transfer-resources.js.map +1 -0
  108. package/src/lib/transfer/register-transfer-ui.d.ts +23 -0
  109. package/src/lib/transfer/register-transfer-ui.js +78 -0
  110. package/src/lib/transfer/register-transfer-ui.js.map +1 -0
  111. package/src/lib/transfer/server-common.d.ts +76 -0
  112. package/src/lib/transfer/server-common.js +165 -0
  113. package/src/lib/transfer/server-common.js.map +1 -0
  114. package/src/lib/transfer/transfer-header-actions.component.d.ts +39 -0
  115. package/src/lib/transfer/transfer-header-actions.component.js +79 -0
  116. package/src/lib/transfer/transfer-header-actions.component.js.map +1 -0
  117. package/src/lib/transfer/transfer-keys.d.ts +31 -0
  118. package/src/lib/transfer/transfer-keys.js +56 -0
  119. package/src/lib/transfer/transfer-keys.js.map +1 -0
  120. package/src/lib/model/commerce-io.d.ts +0 -36
  121. package/src/lib/model/commerce-io.js +0 -245
  122. package/src/lib/model/commerce-io.js.map +0 -1
  123. package/src/lib/server/contact-refund.d.ts +0 -117
  124. package/src/lib/server/contact-refund.js +0 -196
  125. package/src/lib/server/contact-refund.js.map +0 -1
@@ -0,0 +1,419 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { checkEntitlement, isHostPluginEnabled } from "@aglyn/aglyn/server";
18
+ import { ApiErrors, apiJson, listResponse } from "@aglyn/tenant-data-admin";
19
+ import { paginate, readJsonBody, requireScope, serialize } from "@aglyn/tenant-data-admin/server/api-v1-kit";
20
+ import { BUNDLE_ID } from "../../constants/bundle-common.js";
21
+ import { recordOrderShipment } from "../fulfill-order.js";
22
+ /**
23
+ * A site's orders and products on the customer REST API,
24
+ * `/v1/sites/{siteId}/orders/…` and `/v1/sites/{siteId}/products/…`
25
+ * (AGL-1928, AGL-2461, AGL-3080). Registered from this plugin's console
26
+ * server declarations; the console's `/v1` router owns the pipeline in
27
+ * front — the key, the plan's API access, the request quota, the rate limit —
28
+ * and refuses a site the key's organization does not own before it hands a
29
+ * request here.
30
+ */ /**
31
+ * Commerce resources need the `commerce` entitlement as well as the scope
32
+ * (AGL-1928). `apiAccess` alone is the wrong gate here: it says the org may
33
+ * call the API at all, not that it may still use the store. AGL-1873 closed
34
+ * exactly this class on the write side — two money doors that asked the
35
+ * plugin switch (`org.enabledPlugins`) instead of the plan, so a lapsed org
36
+ * kept selling — and the same reasoning applies to a read. `enabledPlugins`
37
+ * is the customer's own on/off switch and survives a downgrade; the plan does
38
+ * not, and the published rule is that paid features stop at the door when the
39
+ * plan no longer includes them.
40
+ *
41
+ * Answered as `plan_required` rather than `not_found`, deliberately. Hiding a
42
+ * store that plainly exists behind a 404 sends an integrator hunting a wrong
43
+ * site id; the honest answer names the plan.
44
+ */ function requireCommerce(ctx) {
45
+ return checkEntitlement(ctx.org, 'commerce') ? null : ApiErrors.planRequired({
46
+ message: 'Commerce is not included in this organization’s plan',
47
+ code: 'commerce',
48
+ headers: ctx.headers
49
+ });
50
+ }
51
+ function hostRef(ctx, hostId) {
52
+ return ctx.firestore.collection('hosts').doc(hostId);
53
+ }
54
+ /**
55
+ * Orders carry a legacy Commerce Starter shape (AGL-90) alongside the modern
56
+ * one: `amountCents`/`feeCents` at the top level rather than a `totals` map.
57
+ * The console lifts them through `CommerceModel.liftLegacyOrder` and reads
58
+ * `lifted.totals?.totalCents ?? order.amountCents`. The API cannot publish two
59
+ * shapes for one object, so the legacy fields are folded into `totals` here
60
+ * and a client only ever sees the modern one. `channel` defaults to `online`
61
+ * for the same reason the console's list does — an absent channel is a
62
+ * pre-channel order, not an unknown one.
63
+ */ function orderView(doc) {
64
+ var _doc_data, _data_totals, _data_amountCents, _data_feeCents, _data_status, _data_channel, _data_customerEmail, _data_customerName, _data_lineItems, _totals_itemsCents, _totals_shippingCents, _totals_taxCents, _totals_discountCents, _data_refundedCents, _serialize, _data_couponCode, _serialize1;
65
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
66
+ const totals = (_data_totals = data.totals) != null ? _data_totals : {};
67
+ const legacyTotal = Number((_data_amountCents = data.amountCents) != null ? _data_amountCents : NaN);
68
+ const totalCents = Number.isFinite(Number(totals.totalCents)) ? Number(totals.totalCents) : Number.isFinite(legacyTotal) ? legacyTotal : null;
69
+ const legacyFee = Number((_data_feeCents = data.feeCents) != null ? _data_feeCents : NaN);
70
+ return {
71
+ id: doc.id,
72
+ object: 'order',
73
+ number: typeof data.number === 'number' ? data.number : null,
74
+ status: (_data_status = data.status) != null ? _data_status : null,
75
+ channel: (_data_channel = data.channel) != null ? _data_channel : 'online',
76
+ currency: 'usd',
77
+ customerEmail: (_data_customerEmail = data.customerEmail) != null ? _data_customerEmail : null,
78
+ customerName: (_data_customerName = data.customerName) != null ? _data_customerName : null,
79
+ lineItems: serialize((_data_lineItems = data.lineItems) != null ? _data_lineItems : []),
80
+ totals: {
81
+ itemsCents: Number((_totals_itemsCents = totals.itemsCents) != null ? _totals_itemsCents : 0),
82
+ shippingCents: Number((_totals_shippingCents = totals.shippingCents) != null ? _totals_shippingCents : 0),
83
+ taxCents: Number((_totals_taxCents = totals.taxCents) != null ? _totals_taxCents : 0),
84
+ discountCents: Number((_totals_discountCents = totals.discountCents) != null ? _totals_discountCents : 0),
85
+ totalCents,
86
+ // The Connect application fee. NOT subtracted from `totalCents` — it is
87
+ // Aglyn's cut of a total the shopper already paid in full, so a client
88
+ // that nets it out of revenue would understate what it collected.
89
+ feeCents: Number.isFinite(Number(totals.feeCents)) ? Number(totals.feeCents) : Number.isFinite(legacyFee) ? legacyFee : 0
90
+ },
91
+ // Money already handed back, for any reason. A chargeback lands here too,
92
+ // so `refundedCents > 0` does not by itself mean the merchant chose it.
93
+ refundedCents: Number((_data_refundedCents = data.refundedCents) != null ? _data_refundedCents : 0),
94
+ disputed: Boolean(data.dispute),
95
+ shippingAddress: (_serialize = serialize(data.shippingAddress)) != null ? _serialize : null,
96
+ couponCode: (_data_couponCode = data.couponCode) != null ? _data_couponCode : null,
97
+ // Shipment records — the half of fulfilment an integration can use
98
+ // without a write (AGL-2460). `status` says an order is `fulfilled`; it
99
+ // does not say which carrier took it or under what tracking number, so a
100
+ // 3PL or accounting reconcile could see THAT an order shipped and never
101
+ // WHICH shipment it was. The console's order dialog shows both, and an
102
+ // order that has been shipped twice (a split shipment) is indistinguish-
103
+ // able from one shipped once when only the status is published.
104
+ //
105
+ // `atMs` is a number of milliseconds, not a Firestore Timestamp, so
106
+ // `serialize` passes it through untouched. It is republished as `at` in
107
+ // ISO 8601 to match `created` and every other time this API emits: one
108
+ // object publishing two time formats is a bug an integrator finds late,
109
+ // in their own timezone conversion, and blames on their own code.
110
+ fulfillments: (Array.isArray(data.fulfillments) ? data.fulfillments : []).map((entry)=>{
111
+ var _id, _carrier, _trackingNumber, _trackingUrl;
112
+ const atMs = Number((entry != null ? entry : {}).atMs);
113
+ return {
114
+ id: (_id = (entry != null ? entry : {}).id) != null ? _id : null,
115
+ lineItemIds: Array.isArray((entry != null ? entry : {}).lineItemIds) ? entry.lineItemIds : [],
116
+ carrier: (_carrier = (entry != null ? entry : {}).carrier) != null ? _carrier : null,
117
+ trackingNumber: (_trackingNumber = (entry != null ? entry : {}).trackingNumber) != null ? _trackingNumber : null,
118
+ trackingUrl: (_trackingUrl = (entry != null ? entry : {}).trackingUrl) != null ? _trackingUrl : null,
119
+ at: Number.isFinite(atMs) ? new Date(atMs).toISOString() : null
120
+ };
121
+ }),
122
+ created: (_serialize1 = serialize(data.createdAt)) != null ? _serialize1 : null
123
+ };
124
+ }
125
+ export async function handleOrders(request, ctx, segments, url) {
126
+ const [, hostId, , orderId] = segments;
127
+ // `orders:write` is checked BEFORE `orders:read`, matching `handleSites`
128
+ // and `handleScopedMedia` (AGL-900): a fulfilment key that only records
129
+ // shipments must not be told it lacks a read scope it was never meant to
130
+ // hold.
131
+ if (request.method === 'PATCH' && orderId) {
132
+ const deniedWrite = requireScope(ctx, 'orders:write');
133
+ if (deniedWrite) return deniedWrite;
134
+ const unentitledWrite = requireCommerce(ctx);
135
+ if (unentitledWrite) return unentitledWrite;
136
+ return updateOrder(request, ctx, hostId, orderId);
137
+ }
138
+ const denied = requireScope(ctx, 'orders:read');
139
+ if (denied) return denied;
140
+ const unentitled = requireCommerce(ctx);
141
+ if (unentitled) return unentitled;
142
+ if (request.method !== 'GET') {
143
+ return ApiErrors.methodNotAllowed({
144
+ headers: _extends({}, ctx.headers, {
145
+ Allow: orderId ? 'GET, PATCH' : 'GET'
146
+ })
147
+ });
148
+ }
149
+ const collection = hostRef(ctx, hostId).collection('orders');
150
+ if (orderId) {
151
+ const snap = await collection.doc(orderId).get();
152
+ if (!snap.exists) {
153
+ return ApiErrors.notFound({
154
+ message: 'No such order',
155
+ headers: ctx.headers
156
+ });
157
+ }
158
+ return apiJson(orderView(snap), {
159
+ headers: ctx.headers
160
+ });
161
+ }
162
+ let query = collection;
163
+ const status = url.searchParams.get('status');
164
+ if (status) query = query.where('status', '==', status);
165
+ const channel = url.searchParams.get('channel');
166
+ // `online` is the DEFAULT, not a stored value on older orders, so filtering
167
+ // for it in Firestore would silently drop every pre-channel order. Those are
168
+ // exactly the oldest orders an accounting backfill is reaching for, so this
169
+ // one value is filtered after the read instead. The page can therefore come
170
+ // back shorter than `limit` while `has_more` is still true — which the
171
+ // published pagination contract already tells clients to expect (check
172
+ // `has_more`, never a page's length).
173
+ if (channel && channel !== 'online') query = query.where('channel', '==', channel);
174
+ const { docs, nextCursor } = await paginate(query, url);
175
+ const data = docs.map(orderView).filter((order)=>channel === 'online' ? order.channel === 'online' : true);
176
+ return listResponse(data, nextCursor, ctx.headers);
177
+ }
178
+ /**
179
+ * Statuses this endpoint will move an order TO, and the two it names as
180
+ * refused. Kept as data so the 400 can list them and the docs can be checked
181
+ * against the same source the handler branches on.
182
+ */ const ORDER_WRITE_TARGETS = [
183
+ 'fulfilled',
184
+ 'delivered'
185
+ ];
186
+ /**
187
+ * Transitions that exist in the commerce model and are DELIBERATELY not
188
+ * reachable here — refused by name with a 400 that says why, never silently
189
+ * ignored. `cancelled` releases held stock under its own transaction and
190
+ * `refunded` moves money under another; admitting either here would hand a
191
+ * caller a door around exactly the specifics those two routes exist to
192
+ * enforce, and an API key is the credential least able to answer the
193
+ * questions they ask.
194
+ */ const ORDER_WRITE_REFUSED = {
195
+ cancelled: 'Canceling an order releases held stock, so it is not part of this endpoint. Cancel it in the console.',
196
+ refunded: 'Refunding an order moves money, so it is not part of this endpoint. Refund it in the console.'
197
+ };
198
+ /**
199
+ * `PATCH /v1/sites/{siteId}/orders/{orderId}` — record a shipment (AGL-2461).
200
+ *
201
+ * ## The write is the console's, shared
202
+ *
203
+ * The transition rule, the transaction that re-asks it under the write, the
204
+ * fulfillment append and the timeline entry are `recordOrderShipment`'s,
205
+ * the one implementation the console's `commerce/fulfill-order` route calls
206
+ * too. A second copy of `ORDER_TRANSITIONS` here would be the bug rather
207
+ * than the fix: two tables drift, and drift here means the API writing an
208
+ * order status the console forbids — `paid → delivered` skipping
209
+ * fulfilment, or a write onto a `refunded` order. That is the class
210
+ * AGL-1818/AGL-1819 exist to close, and it is money-adjacent.
211
+ *
212
+ * ## What THIS function is responsible for: all of the authorization
213
+ *
214
+ * `recordOrderShipment` is pre-authorized by contract — it takes `hostId`
215
+ * on trust — so every gate is here, and all four are load-bearing:
216
+ *
217
+ * 1. `orders:write` on the key (checked by the caller, above).
218
+ * 2. The `commerce` PLAN entitlement (checked by the caller, above) — the
219
+ * plan, never the plugin switch, which is the AGL-1873 distinction.
220
+ * 3. **Org owns the site** — the console's `handleSites` refuses an unowned
221
+ * `hostId` with a 404 before it hands any site resource to its plugin,
222
+ * which is what keeps this from being a cross-tenant write primitive
223
+ * addressable by anyone who can guess a host id. Deliberately NOT
224
+ * re-checked here: a second copy would be a gate no test can redden
225
+ * (removing either one alone leaves the other answering), and an unproven
226
+ * guard on a cross-tenant write is worse than the one guard a test
227
+ * actually holds. `api-v1-order-fulfilment.spec.ts`'s "CANNOT move
228
+ * another org's order" case is that test, and it fails when
229
+ * `handleSites`' `orgOwnsHost` is removed.
230
+ * 4. **This plugin is switched on for this site.** The resource is
231
+ * registered for every organization the console serves, so its being here
232
+ * says nothing about one org's configuration; without this an org that
233
+ * switched commerce off for a site would still accept writes into it,
234
+ * which is the per-site enablement rule (AGL-1014) the plugin API
235
+ * dispatcher applies to every other commerce door.
236
+ *
237
+ * ## No `Idempotency-Key`
238
+ *
239
+ * None is needed and none is accepted: the write returns without writing when
240
+ * the order is already in the target status, so a retry lands the same state
241
+ * AND returns the same `200` with the same order body — the contract
242
+ * `updateRecord`, `updateContact` and `updateFormSubmission` are held to.
243
+ */ async function updateOrder(request, ctx, hostId, orderId) {
244
+ var _body_status, _body_carrier, _body_trackingNumber;
245
+ const body = await readJsonBody(request);
246
+ const unknown = Object.keys(body).filter((key)=>key !== 'status' && key !== 'carrier' && key !== 'trackingNumber');
247
+ if (unknown.length > 0) {
248
+ // Named, not dropped — the `updateFormSubmission` / `updateContact` rule.
249
+ // A silently ignored `trackingUrl` here reads as "we recorded your
250
+ // shipment as you described it" when half of it went nowhere, and the
251
+ // caller is a warehouse system that will never look again.
252
+ return ApiErrors.badRequest({
253
+ message: 'Only `status`, `carrier` and `trackingNumber` can be set on an order',
254
+ code: 'validation_failed',
255
+ fields: Object.fromEntries(unknown.map((key)=>[
256
+ key,
257
+ 'Not writable on an order'
258
+ ])),
259
+ headers: ctx.headers
260
+ });
261
+ }
262
+ const status = String((_body_status = body.status) != null ? _body_status : '');
263
+ const refusal = ORDER_WRITE_REFUSED[status];
264
+ if (refusal) {
265
+ return ApiErrors.badRequest({
266
+ message: refusal,
267
+ code: 'validation_failed',
268
+ fields: {
269
+ status: refusal
270
+ },
271
+ headers: ctx.headers
272
+ });
273
+ }
274
+ if (!ORDER_WRITE_TARGETS.includes(status)) {
275
+ return ApiErrors.badRequest({
276
+ message: 'Order failed validation',
277
+ code: 'validation_failed',
278
+ fields: {
279
+ status: `Must be one of: ${ORDER_WRITE_TARGETS.join(', ')}`
280
+ },
281
+ headers: ctx.headers
282
+ });
283
+ }
284
+ // Bounded exactly as the console route bounds them, so one field cannot be
285
+ // used to stuff an order document through a door the console keeps narrow.
286
+ const carrier = String((_body_carrier = body.carrier) != null ? _body_carrier : '').slice(0, 40);
287
+ const trackingNumber = String((_body_trackingNumber = body.trackingNumber) != null ? _body_trackingNumber : '').slice(0, 60);
288
+ // Gate 4: this plugin, on for this site.
289
+ const hostSnap = await hostRef(ctx, hostId).get();
290
+ if (!isHostPluginEnabled(ctx.org, hostSnap.data(), BUNDLE_ID)) {
291
+ return ApiErrors.notFound({
292
+ message: 'No such site',
293
+ headers: ctx.headers
294
+ });
295
+ }
296
+ const outcome = await recordOrderShipment({
297
+ hostId,
298
+ orderId,
299
+ to: status,
300
+ carrier,
301
+ trackingNumber
302
+ });
303
+ if (outcome.outcome === 'no_such_order') {
304
+ return ApiErrors.notFound({
305
+ message: 'No such order',
306
+ headers: ctx.headers
307
+ });
308
+ }
309
+ if (outcome.outcome === 'blocked') {
310
+ // A NEW 409 code (`order_transition`), because an integrator has to be
311
+ // able to tell "the order moved on without me" apart from every other
312
+ // conflict this API can raise — it is the one a fulfilment poller will
313
+ // actually hit, and the one it must not retry forever.
314
+ return ApiErrors.conflict({
315
+ message: `Orders in "${outcome.from}" cannot be marked ${status}`,
316
+ code: 'order_transition',
317
+ headers: ctx.headers
318
+ });
319
+ }
320
+ // The order object, on both `recorded` and `already` — a retry lands the
321
+ // same state and reads the same 200. Re-read after the write so the body
322
+ // shows the shipment that was just recorded rather than the one before it.
323
+ return apiJson(orderView(await hostRef(ctx, hostId).collection('orders').doc(orderId).get()), {
324
+ headers: ctx.headers
325
+ });
326
+ }
327
+ /**
328
+ * Price and stock live on VARIANTS, never on the product — a product-level
329
+ * `inventory` exists only as a denormalized sum the console rewrites on every
330
+ * decrement, and a product-level `priceUsd` is the legacy single-variant
331
+ * shape. Publishing either as the product's price would be wrong the moment a
332
+ * product has two variants, so the variant array is the contract and the
333
+ * product carries only the roll-up, clearly named.
334
+ *
335
+ * `inventory: null` on a variant means UNTRACKED and `0` means SOLD OUT.
336
+ * Collapsing them (the `?? 0` an integrator writes on the first day) turns
337
+ * every untracked product into an out-of-stock one, so the distinction is
338
+ * carried through verbatim rather than defaulted.
339
+ */ function variantView(variant) {
340
+ var _variant_id, _variant_sku, _variant_barcode, _variant_options;
341
+ const inventory = variant.inventory;
342
+ return {
343
+ id: (_variant_id = variant.id) != null ? _variant_id : null,
344
+ sku: (_variant_sku = variant.sku) != null ? _variant_sku : null,
345
+ barcode: (_variant_barcode = variant.barcode) != null ? _variant_barcode : null,
346
+ options: (_variant_options = variant.options) != null ? _variant_options : {},
347
+ priceUsd: typeof variant.priceUsd === 'number' ? variant.priceUsd : null,
348
+ compareAtPriceUsd: typeof variant.compareAtPriceUsd === 'number' ? variant.compareAtPriceUsd : null,
349
+ weightGrams: typeof variant.weightGrams === 'number' ? variant.weightGrams : null,
350
+ inventory: typeof inventory === 'number' ? inventory : null,
351
+ inventoryTracked: typeof inventory === 'number'
352
+ };
353
+ }
354
+ function productView(doc) {
355
+ var _doc_data, _data_name, _data_slug, _data_description, _data_type, _data_status, _data_tags, _data_categoryIds, _data_mediaUrls, _data_options, _serialize;
356
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
357
+ const variants = Array.isArray(data.variants) ? data.variants : [];
358
+ const tracked = variants.filter((v)=>typeof v.inventory === 'number');
359
+ return {
360
+ id: doc.id,
361
+ object: 'product',
362
+ name: (_data_name = data.name) != null ? _data_name : null,
363
+ slug: (_data_slug = data.slug) != null ? _data_slug : null,
364
+ description: (_data_description = data.description) != null ? _data_description : null,
365
+ type: (_data_type = data.type) != null ? _data_type : null,
366
+ status: (_data_status = data.status) != null ? _data_status : null,
367
+ tags: (_data_tags = data.tags) != null ? _data_tags : [],
368
+ categoryIds: (_data_categoryIds = data.categoryIds) != null ? _data_categoryIds : [],
369
+ mediaUrls: (_data_mediaUrls = data.mediaUrls) != null ? _data_mediaUrls : [],
370
+ options: (_data_options = data.options) != null ? _data_options : [],
371
+ variants: variants.map(variantView),
372
+ // The sum across TRACKED variants only, and `null` when none of them is
373
+ // tracked — so an untracked catalogue reads as "we don't count this"
374
+ // rather than as a store with nothing left to sell.
375
+ inventory: tracked.length ? tracked.reduce((sum, v)=>{
376
+ var _v_inventory;
377
+ return sum + Number((_v_inventory = v.inventory) != null ? _v_inventory : 0);
378
+ }, 0) : null,
379
+ subscription: (_serialize = serialize(data.subscription)) != null ? _serialize : null,
380
+ created: data.createdAtMs ? new Date(Number(data.createdAtMs)).toISOString() : null,
381
+ updated: data.updatedAtMs ? new Date(Number(data.updatedAtMs)).toISOString() : null
382
+ };
383
+ }
384
+ export async function handleProducts(request, ctx, segments, url) {
385
+ const [, hostId, , productId] = segments;
386
+ const denied = requireScope(ctx, 'products:read');
387
+ if (denied) return denied;
388
+ const unentitled = requireCommerce(ctx);
389
+ if (unentitled) return unentitled;
390
+ if (request.method !== 'GET') {
391
+ return ApiErrors.methodNotAllowed({
392
+ headers: ctx.headers
393
+ });
394
+ }
395
+ const collection = hostRef(ctx, hostId).collection('products');
396
+ if (productId) {
397
+ const snap = await collection.doc(productId).get();
398
+ // A soft-deleted product is gone as far as a customer is concerned. The
399
+ // console filters `deletedAt` client-side; the API must not hand back a
400
+ // product the merchant deleted just because the document survives.
401
+ if (!snap.exists || snap.get('deletedAt')) {
402
+ return ApiErrors.notFound({
403
+ message: 'No such product',
404
+ headers: ctx.headers
405
+ });
406
+ }
407
+ return apiJson(productView(snap), {
408
+ headers: ctx.headers
409
+ });
410
+ }
411
+ let query = collection;
412
+ const status = url.searchParams.get('status');
413
+ if (status) query = query.where('status', '==', status);
414
+ const { docs, nextCursor } = await paginate(query, url);
415
+ const data = docs.filter((doc)=>!doc.get('deletedAt')).map(productView);
416
+ return listResponse(data, nextCursor, ctx.headers);
417
+ }
418
+
419
+ //# sourceMappingURL=orders-and-products.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/server/api-v1/orders-and-products.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { checkEntitlement, isHostPluginEnabled } from '@aglyn/aglyn/server'\nimport {\n ApiErrors,\n apiJson,\n listResponse,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n paginate,\n readJsonBody,\n requireScope,\n serialize,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport { BUNDLE_ID } from '../../constants/bundle-common'\nimport { type OrderFulfilmentTarget, recordOrderShipment } from '../fulfill-order'\n\n/**\n * A site's orders and products on the customer REST API,\n * `/v1/sites/{siteId}/orders/…` and `/v1/sites/{siteId}/products/…`\n * (AGL-1928, AGL-2461, AGL-3080). Registered from this plugin's console\n * server declarations; the console's `/v1` router owns the pipeline in\n * front — the key, the plan's API access, the request quota, the rate limit —\n * and refuses a site the key's organization does not own before it hands a\n * request here.\n */\n\n/**\n * Commerce resources need the `commerce` entitlement as well as the scope\n * (AGL-1928). `apiAccess` alone is the wrong gate here: it says the org may\n * call the API at all, not that it may still use the store. AGL-1873 closed\n * exactly this class on the write side — two money doors that asked the\n * plugin switch (`org.enabledPlugins`) instead of the plan, so a lapsed org\n * kept selling — and the same reasoning applies to a read. `enabledPlugins`\n * is the customer's own on/off switch and survives a downgrade; the plan does\n * not, and the published rule is that paid features stop at the door when the\n * plan no longer includes them.\n *\n * Answered as `plan_required` rather than `not_found`, deliberately. Hiding a\n * store that plainly exists behind a 404 sends an integrator hunting a wrong\n * site id; the honest answer names the plan.\n */\nfunction requireCommerce(ctx: ApiV1Context): Response | null {\n return checkEntitlement(ctx.org, 'commerce')\n ? null\n : ApiErrors.planRequired({\n message: 'Commerce is not included in this organization’s plan',\n code: 'commerce',\n headers: ctx.headers,\n })\n}\n\nfunction hostRef(ctx: ApiV1Context, hostId: string) {\n return ctx.firestore.collection('hosts').doc(hostId)\n}\n\n/**\n * Orders carry a legacy Commerce Starter shape (AGL-90) alongside the modern\n * one: `amountCents`/`feeCents` at the top level rather than a `totals` map.\n * The console lifts them through `CommerceModel.liftLegacyOrder` and reads\n * `lifted.totals?.totalCents ?? order.amountCents`. The API cannot publish two\n * shapes for one object, so the legacy fields are folded into `totals` here\n * and a client only ever sees the modern one. `channel` defaults to `online`\n * for the same reason the console's list does — an absent channel is a\n * pre-channel order, not an unknown one.\n */\nfunction orderView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = doc.data() ?? {}\n const totals = (data.totals ?? {}) as Record<string, unknown>\n const legacyTotal = Number(data.amountCents ?? NaN)\n const totalCents = Number.isFinite(Number(totals.totalCents))\n ? Number(totals.totalCents)\n : Number.isFinite(legacyTotal)\n ? legacyTotal\n : null\n const legacyFee = Number(data.feeCents ?? NaN)\n return {\n id: doc.id,\n object: 'order',\n number: typeof data.number === 'number' ? data.number : null,\n status: (data.status as string) ?? null,\n channel: (data.channel as string) ?? 'online',\n currency: 'usd',\n customerEmail: data.customerEmail ?? null,\n customerName: data.customerName ?? null,\n lineItems: serialize(data.lineItems ?? []),\n totals: {\n itemsCents: Number(totals.itemsCents ?? 0),\n shippingCents: Number(totals.shippingCents ?? 0),\n taxCents: Number(totals.taxCents ?? 0),\n discountCents: Number(totals.discountCents ?? 0),\n totalCents,\n // The Connect application fee. NOT subtracted from `totalCents` — it is\n // Aglyn's cut of a total the shopper already paid in full, so a client\n // that nets it out of revenue would understate what it collected.\n feeCents: Number.isFinite(Number(totals.feeCents))\n ? Number(totals.feeCents)\n : Number.isFinite(legacyFee)\n ? legacyFee\n : 0,\n },\n // Money already handed back, for any reason. A chargeback lands here too,\n // so `refundedCents > 0` does not by itself mean the merchant chose it.\n refundedCents: Number(data.refundedCents ?? 0),\n disputed: Boolean(data.dispute),\n shippingAddress: serialize(data.shippingAddress) ?? null,\n couponCode: data.couponCode ?? null,\n // Shipment records — the half of fulfilment an integration can use\n // without a write (AGL-2460). `status` says an order is `fulfilled`; it\n // does not say which carrier took it or under what tracking number, so a\n // 3PL or accounting reconcile could see THAT an order shipped and never\n // WHICH shipment it was. The console's order dialog shows both, and an\n // order that has been shipped twice (a split shipment) is indistinguish-\n // able from one shipped once when only the status is published.\n //\n // `atMs` is a number of milliseconds, not a Firestore Timestamp, so\n // `serialize` passes it through untouched. It is republished as `at` in\n // ISO 8601 to match `created` and every other time this API emits: one\n // object publishing two time formats is a bug an integrator finds late,\n // in their own timezone conversion, and blames on their own code.\n fulfillments: (Array.isArray(data.fulfillments) ? data.fulfillments : []).map(\n (entry: Record<string, unknown>) => {\n const atMs = Number((entry ?? {}).atMs)\n return {\n id: (entry ?? {}).id ?? null,\n lineItemIds: Array.isArray((entry ?? {}).lineItemIds)\n ? (entry as { lineItemIds: unknown[] }).lineItemIds\n : [],\n carrier: (entry ?? {}).carrier ?? null,\n trackingNumber: (entry ?? {}).trackingNumber ?? null,\n trackingUrl: (entry ?? {}).trackingUrl ?? null,\n at: Number.isFinite(atMs) ? new Date(atMs).toISOString() : null,\n }\n },\n ),\n created: serialize(data.createdAt) ?? null,\n }\n}\n\nexport async function handleOrders(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const [, hostId, , orderId] = segments\n // `orders:write` is checked BEFORE `orders:read`, matching `handleSites`\n // and `handleScopedMedia` (AGL-900): a fulfilment key that only records\n // shipments must not be told it lacks a read scope it was never meant to\n // hold.\n if (request.method === 'PATCH' && orderId) {\n const deniedWrite = requireScope(ctx, 'orders:write')\n if (deniedWrite) return deniedWrite\n const unentitledWrite = requireCommerce(ctx)\n if (unentitledWrite) return unentitledWrite\n return updateOrder(request, ctx, hostId, orderId)\n }\n const denied = requireScope(ctx, 'orders:read')\n if (denied) return denied\n const unentitled = requireCommerce(ctx)\n if (unentitled) return unentitled\n if (request.method !== 'GET') {\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: orderId ? 'GET, PATCH' : 'GET' },\n })\n }\n const collection = hostRef(ctx, hostId).collection('orders')\n\n if (orderId) {\n const snap = await collection.doc(orderId).get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such order', headers: ctx.headers })\n }\n return apiJson(orderView(snap), { headers: ctx.headers })\n }\n\n let query: FirebaseFirestore.Query = collection\n const status = url.searchParams.get('status')\n if (status) query = query.where('status', '==', status)\n const channel = url.searchParams.get('channel')\n // `online` is the DEFAULT, not a stored value on older orders, so filtering\n // for it in Firestore would silently drop every pre-channel order. Those are\n // exactly the oldest orders an accounting backfill is reaching for, so this\n // one value is filtered after the read instead. The page can therefore come\n // back shorter than `limit` while `has_more` is still true — which the\n // published pagination contract already tells clients to expect (check\n // `has_more`, never a page's length).\n if (channel && channel !== 'online') query = query.where('channel', '==', channel)\n const { docs, nextCursor } = await paginate(query, url)\n const data = docs\n .map(orderView)\n .filter((order) => (channel === 'online' ? order.channel === 'online' : true))\n return listResponse(data, nextCursor, ctx.headers)\n}\n\n/**\n * Statuses this endpoint will move an order TO, and the two it names as\n * refused. Kept as data so the 400 can list them and the docs can be checked\n * against the same source the handler branches on.\n */\nconst ORDER_WRITE_TARGETS: OrderFulfilmentTarget[] = ['fulfilled', 'delivered']\n\n/**\n * Transitions that exist in the commerce model and are DELIBERATELY not\n * reachable here — refused by name with a 400 that says why, never silently\n * ignored. `cancelled` releases held stock under its own transaction and\n * `refunded` moves money under another; admitting either here would hand a\n * caller a door around exactly the specifics those two routes exist to\n * enforce, and an API key is the credential least able to answer the\n * questions they ask.\n */\nconst ORDER_WRITE_REFUSED: Record<string, string> = {\n cancelled:\n 'Canceling an order releases held stock, so it is not part of this endpoint. Cancel it in the console.',\n refunded:\n 'Refunding an order moves money, so it is not part of this endpoint. Refund it in the console.',\n}\n\n/**\n * `PATCH /v1/sites/{siteId}/orders/{orderId}` — record a shipment (AGL-2461).\n *\n * ## The write is the console's, shared\n *\n * The transition rule, the transaction that re-asks it under the write, the\n * fulfillment append and the timeline entry are `recordOrderShipment`'s,\n * the one implementation the console's `commerce/fulfill-order` route calls\n * too. A second copy of `ORDER_TRANSITIONS` here would be the bug rather\n * than the fix: two tables drift, and drift here means the API writing an\n * order status the console forbids — `paid → delivered` skipping\n * fulfilment, or a write onto a `refunded` order. That is the class\n * AGL-1818/AGL-1819 exist to close, and it is money-adjacent.\n *\n * ## What THIS function is responsible for: all of the authorization\n *\n * `recordOrderShipment` is pre-authorized by contract — it takes `hostId`\n * on trust — so every gate is here, and all four are load-bearing:\n *\n * 1. `orders:write` on the key (checked by the caller, above).\n * 2. The `commerce` PLAN entitlement (checked by the caller, above) — the\n * plan, never the plugin switch, which is the AGL-1873 distinction.\n * 3. **Org owns the site** — the console's `handleSites` refuses an unowned\n * `hostId` with a 404 before it hands any site resource to its plugin,\n * which is what keeps this from being a cross-tenant write primitive\n * addressable by anyone who can guess a host id. Deliberately NOT\n * re-checked here: a second copy would be a gate no test can redden\n * (removing either one alone leaves the other answering), and an unproven\n * guard on a cross-tenant write is worse than the one guard a test\n * actually holds. `api-v1-order-fulfilment.spec.ts`'s \"CANNOT move\n * another org's order\" case is that test, and it fails when\n * `handleSites`' `orgOwnsHost` is removed.\n * 4. **This plugin is switched on for this site.** The resource is\n * registered for every organization the console serves, so its being here\n * says nothing about one org's configuration; without this an org that\n * switched commerce off for a site would still accept writes into it,\n * which is the per-site enablement rule (AGL-1014) the plugin API\n * dispatcher applies to every other commerce door.\n *\n * ## No `Idempotency-Key`\n *\n * None is needed and none is accepted: the write returns without writing when\n * the order is already in the target status, so a retry lands the same state\n * AND returns the same `200` with the same order body — the contract\n * `updateRecord`, `updateContact` and `updateFormSubmission` are held to.\n */\nasync function updateOrder(\n request: Request,\n ctx: ApiV1Context,\n hostId: string,\n orderId: string,\n): Promise<Response> {\n const body = await readJsonBody(request)\n const unknown = Object.keys(body).filter(\n (key) => key !== 'status' && key !== 'carrier' && key !== 'trackingNumber',\n )\n if (unknown.length > 0) {\n // Named, not dropped — the `updateFormSubmission` / `updateContact` rule.\n // A silently ignored `trackingUrl` here reads as \"we recorded your\n // shipment as you described it\" when half of it went nowhere, and the\n // caller is a warehouse system that will never look again.\n return ApiErrors.badRequest({\n message:\n 'Only `status`, `carrier` and `trackingNumber` can be set on an order',\n code: 'validation_failed',\n fields: Object.fromEntries(\n unknown.map((key) => [key, 'Not writable on an order']),\n ),\n headers: ctx.headers,\n })\n }\n\n const status = String(body.status ?? '')\n const refusal = ORDER_WRITE_REFUSED[status]\n if (refusal) {\n return ApiErrors.badRequest({\n message: refusal,\n code: 'validation_failed',\n fields: { status: refusal },\n headers: ctx.headers,\n })\n }\n if (!(ORDER_WRITE_TARGETS as string[]).includes(status)) {\n return ApiErrors.badRequest({\n message: 'Order failed validation',\n code: 'validation_failed',\n fields: {\n status: `Must be one of: ${ORDER_WRITE_TARGETS.join(', ')}`,\n },\n headers: ctx.headers,\n })\n }\n // Bounded exactly as the console route bounds them, so one field cannot be\n // used to stuff an order document through a door the console keeps narrow.\n const carrier = String(body.carrier ?? '').slice(0, 40)\n const trackingNumber = String(body.trackingNumber ?? '').slice(0, 60)\n\n // Gate 4: this plugin, on for this site.\n const hostSnap = await hostRef(ctx, hostId).get()\n if (!isHostPluginEnabled(ctx.org, hostSnap.data(), BUNDLE_ID)) {\n return ApiErrors.notFound({\n message: 'No such site',\n headers: ctx.headers,\n })\n }\n\n const outcome = await recordOrderShipment({\n hostId,\n orderId,\n to: status as OrderFulfilmentTarget,\n carrier,\n trackingNumber,\n })\n if (outcome.outcome === 'no_such_order') {\n return ApiErrors.notFound({\n message: 'No such order',\n headers: ctx.headers,\n })\n }\n if (outcome.outcome === 'blocked') {\n // A NEW 409 code (`order_transition`), because an integrator has to be\n // able to tell \"the order moved on without me\" apart from every other\n // conflict this API can raise — it is the one a fulfilment poller will\n // actually hit, and the one it must not retry forever.\n return ApiErrors.conflict({\n message: `Orders in \"${outcome.from}\" cannot be marked ${status}`,\n code: 'order_transition',\n headers: ctx.headers,\n })\n }\n // The order object, on both `recorded` and `already` — a retry lands the\n // same state and reads the same 200. Re-read after the write so the body\n // shows the shipment that was just recorded rather than the one before it.\n return apiJson(orderView(await hostRef(ctx, hostId).collection('orders').doc(orderId).get()), {\n headers: ctx.headers,\n })\n}\n\n/**\n * Price and stock live on VARIANTS, never on the product — a product-level\n * `inventory` exists only as a denormalized sum the console rewrites on every\n * decrement, and a product-level `priceUsd` is the legacy single-variant\n * shape. Publishing either as the product's price would be wrong the moment a\n * product has two variants, so the variant array is the contract and the\n * product carries only the roll-up, clearly named.\n *\n * `inventory: null` on a variant means UNTRACKED and `0` means SOLD OUT.\n * Collapsing them (the `?? 0` an integrator writes on the first day) turns\n * every untracked product into an out-of-stock one, so the distinction is\n * carried through verbatim rather than defaulted.\n */\nfunction variantView(variant: Record<string, unknown>) {\n const inventory = variant.inventory\n return {\n id: variant.id ?? null,\n sku: variant.sku ?? null,\n barcode: variant.barcode ?? null,\n options: variant.options ?? {},\n priceUsd: typeof variant.priceUsd === 'number' ? variant.priceUsd : null,\n compareAtPriceUsd:\n typeof variant.compareAtPriceUsd === 'number'\n ? variant.compareAtPriceUsd\n : null,\n weightGrams:\n typeof variant.weightGrams === 'number' ? variant.weightGrams : null,\n inventory: typeof inventory === 'number' ? inventory : null,\n inventoryTracked: typeof inventory === 'number',\n }\n}\n\nfunction productView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = doc.data() ?? {}\n const variants = Array.isArray(data.variants)\n ? (data.variants as Array<Record<string, unknown>>)\n : []\n const tracked = variants.filter((v) => typeof v.inventory === 'number')\n return {\n id: doc.id,\n object: 'product',\n name: data.name ?? null,\n slug: data.slug ?? null,\n description: data.description ?? null,\n type: data.type ?? null,\n status: data.status ?? null,\n tags: data.tags ?? [],\n categoryIds: data.categoryIds ?? [],\n mediaUrls: data.mediaUrls ?? [],\n options: data.options ?? [],\n variants: variants.map(variantView),\n // The sum across TRACKED variants only, and `null` when none of them is\n // tracked — so an untracked catalogue reads as \"we don't count this\"\n // rather than as a store with nothing left to sell.\n inventory: tracked.length\n ? tracked.reduce((sum, v) => sum + Number(v.inventory ?? 0), 0)\n : null,\n subscription: serialize(data.subscription) ?? null,\n created: data.createdAtMs ? new Date(Number(data.createdAtMs)).toISOString() : null,\n updated: data.updatedAtMs ? new Date(Number(data.updatedAtMs)).toISOString() : null,\n }\n}\n\nexport async function handleProducts(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const [, hostId, , productId] = segments\n const denied = requireScope(ctx, 'products:read')\n if (denied) return denied\n const unentitled = requireCommerce(ctx)\n if (unentitled) return unentitled\n if (request.method !== 'GET') {\n return ApiErrors.methodNotAllowed({ headers: ctx.headers })\n }\n const collection = hostRef(ctx, hostId).collection('products')\n\n if (productId) {\n const snap = await collection.doc(productId).get()\n // A soft-deleted product is gone as far as a customer is concerned. The\n // console filters `deletedAt` client-side; the API must not hand back a\n // product the merchant deleted just because the document survives.\n if (!snap.exists || snap.get('deletedAt')) {\n return ApiErrors.notFound({ message: 'No such product', headers: ctx.headers })\n }\n return apiJson(productView(snap), { headers: ctx.headers })\n }\n\n let query: FirebaseFirestore.Query = collection\n const status = url.searchParams.get('status')\n if (status) query = query.where('status', '==', status)\n const { docs, nextCursor } = await paginate(query, url)\n const data = docs.filter((doc) => !doc.get('deletedAt')).map(productView)\n return listResponse(data, nextCursor, ctx.headers)\n}\n\n"],"names":["checkEntitlement","isHostPluginEnabled","ApiErrors","apiJson","listResponse","paginate","readJsonBody","requireScope","serialize","BUNDLE_ID","recordOrderShipment","requireCommerce","ctx","org","planRequired","message","code","headers","hostRef","hostId","firestore","collection","doc","orderView","data","totals","legacyTotal","Number","amountCents","NaN","totalCents","isFinite","legacyFee","feeCents","id","object","number","status","channel","currency","customerEmail","customerName","lineItems","itemsCents","shippingCents","taxCents","discountCents","refundedCents","disputed","Boolean","dispute","shippingAddress","couponCode","fulfillments","Array","isArray","map","entry","atMs","lineItemIds","carrier","trackingNumber","trackingUrl","at","Date","toISOString","created","createdAt","handleOrders","request","segments","url","orderId","method","deniedWrite","unentitledWrite","updateOrder","denied","unentitled","methodNotAllowed","Allow","snap","get","exists","notFound","query","searchParams","where","docs","nextCursor","filter","order","ORDER_WRITE_TARGETS","ORDER_WRITE_REFUSED","cancelled","refunded","body","unknown","Object","keys","key","length","badRequest","fields","fromEntries","String","refusal","includes","join","slice","hostSnap","outcome","to","conflict","from","variantView","variant","inventory","sku","barcode","options","priceUsd","compareAtPriceUsd","weightGrams","inventoryTracked","productView","variants","tracked","v","name","slug","description","type","tags","categoryIds","mediaUrls","reduce","sum","subscription","createdAtMs","updated","updatedAtMs","handleProducts","productId"],"mappings":"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"}
@@ -25,11 +25,8 @@ import { COMMERCE_CHARGEBACK_UNROUTABLE, COMMERCE_FEE_REPRICE_REFUSED, COMMERCE_
25
25
  import { createHmac } from "crypto";
26
26
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
27
27
  import * as CommerceModel from "../model/index.js";
28
- import { recordContactRefund } from "./contact-refund.js";
29
- // Leaf import, not the barrel, for the reason `contact-refund.ts` records: the
30
- // specs in this library mock `@aglyn/tenant-data-admin` wholesale, and a
31
- // permissive stub would turn a reversal that never happened green.
32
- import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/email-revenue-attribution";
28
+ import { recordPluginPersonRefund } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
29
+ import { reverseOrderConversion } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
33
30
  import { paymentRiskEventFrom } from "@aglyn/aglyn/app-utils/payment-risk";
34
31
  import { recordPaymentRiskOnRecord } from "@aglyn/tenant-data-admin/server/payment-risk-record";
35
32
  import { isTransientStripeStatus, reverseDestinationTransfer } from "@aglyn/tenant-data-admin/server/stripe-transfer-reversal";
@@ -418,7 +415,7 @@ async function findOrderForDispute(paymentIntentId) {
418
415
  * `charge.dispute.created`: FLAG the order, reverse nothing (AGL-1787).
419
416
  *
420
417
  * A dispute can be WON, and this is the whole reason the reversal waits for
421
- * `closed`. Nothing here un-writes: `recordContactRefund` is monotonic by
418
+ * `closed`. Nothing here un-writes: a person's refunds are monotonic by
422
419
  * construction (AGL-1754 chose counters over decrements precisely so a reader
423
420
  * can never be handed a number that went backwards), so a reversal written on
424
421
  * `created` and reversed again on a win would need a decrement the contact
@@ -4371,18 +4368,18 @@ async function findOrderForDispute(paymentIntentId) {
4371
4368
  link: `/${hostId}/orders`
4372
4369
  });
4373
4370
  // The customer's side of the ledger (AGL-1754), through the same
4374
- // writer a refund uses and with `kind` set so the contact's timeline
4375
- // says "charged back". Skipped when nothing was reversed: a won
4371
+ // seam a refund uses and with the reason set so the person's
4372
+ // timeline says "charged back". Skipped when nothing was reversed: a won
4376
4373
  // dispute moved no money, and a lost one that found nothing left to
4377
4374
  // reverse would otherwise record a $0 entry against the buyer.
4378
4375
  if (settled.reversedCents > 0) {
4379
- await recordContactRefund({
4376
+ await recordPluginPersonRefund({
4380
4377
  hostId,
4381
- orderId: snapshot.id,
4378
+ refId: snapshot.id,
4382
4379
  email: settled.customerEmail,
4383
4380
  amountCents: settled.reversedCents,
4384
- closedTheOrder: settled.closedTheOrder,
4385
- kind: 'chargeback'
4381
+ closedTheSale: settled.closedTheOrder,
4382
+ reason: 'chargeback'
4386
4383
  });
4387
4384
  // The shelf's side (AGL-1797), the SAME door the admin-initiated
4388
4385
  // refund writes so the two cannot diverge the way the contact
@@ -4404,7 +4401,7 @@ async function findOrderForDispute(paymentIntentId) {
4404
4401
  // for it — and the reversal is recorded beside the credit rather
4405
4402
  // than subtracted from it, exactly as the contact ledger above
4406
4403
  // records `refundedCents` beside `ltvCents`.
4407
- await reverseEmailAttributedRevenue({
4404
+ await reverseOrderConversion({
4408
4405
  hostId,
4409
4406
  orderId: snapshot.id,
4410
4407
  amountCents: settled.reversedCents,