@aglyn/plugins-commerce 1.0.0-beta.290 → 1.0.0-beta.291

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 (111) hide show
  1. package/README.md +4 -2
  2. package/package.json +13 -13
  3. package/src/lib/components/commerce-console-page.js +2 -1
  4. package/src/lib/components/commerce-console-page.js.map +1 -1
  5. package/src/lib/components/console/host-orders-card.component.d.ts +6 -0
  6. package/src/lib/components/console/host-orders-card.component.js +3 -2
  7. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  8. package/src/lib/components/console/invoice-send.component.d.ts +44 -0
  9. package/src/lib/components/console/invoice-send.component.js +237 -0
  10. package/src/lib/components/console/invoice-send.component.js.map +1 -0
  11. package/src/lib/components/console/order-detail-dialog.component.d.ts +5 -0
  12. package/src/lib/components/console/order-detail-dialog.component.js +9 -2
  13. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  14. package/src/lib/components/console/order-invoice.component.d.ts +9 -0
  15. package/src/lib/components/console/order-invoice.component.js +27 -2
  16. package/src/lib/components/console/order-invoice.component.js.map +1 -1
  17. package/src/lib/components/console/quote-editor-dialog.component.d.ts +29 -0
  18. package/src/lib/components/console/quote-editor-dialog.component.js +102 -4
  19. package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -1
  20. package/src/lib/components/console/quote-proofs.component.d.ts +11 -0
  21. package/src/lib/components/console/quote-proofs.component.js +68 -0
  22. package/src/lib/components/console/quote-proofs.component.js.map +1 -1
  23. package/src/lib/components/console/quote-send.component.d.ts +42 -0
  24. package/src/lib/components/console/quote-send.component.js +307 -0
  25. package/src/lib/components/console/quote-send.component.js.map +1 -0
  26. package/src/lib/components/console/quote-start.component.d.ts +45 -0
  27. package/src/lib/components/console/quote-start.component.js +182 -0
  28. package/src/lib/components/console/quote-start.component.js.map +1 -0
  29. package/src/lib/components/product-options-field-body.d.ts +44 -0
  30. package/src/lib/components/product-options-field-body.js +260 -0
  31. package/src/lib/components/product-options-field-body.js.map +1 -0
  32. package/src/lib/components/product-options-field.d.ts +32 -0
  33. package/src/lib/components/product-options-field.js +128 -0
  34. package/src/lib/components/product-options-field.js.map +1 -0
  35. package/src/lib/components/quote-page-body.js +39 -20
  36. package/src/lib/components/quote-page-body.js.map +1 -1
  37. package/src/lib/model/commerce-invoices.d.ts +132 -0
  38. package/src/lib/model/commerce-invoices.js +332 -0
  39. package/src/lib/model/commerce-invoices.js.map +1 -0
  40. package/src/lib/model/commerce-orders.d.ts +29 -2
  41. package/src/lib/model/commerce-orders.js.map +1 -1
  42. package/src/lib/model/commerce-product-field.d.ts +23 -0
  43. package/src/lib/model/commerce-product-field.js +26 -0
  44. package/src/lib/model/commerce-product-field.js.map +1 -0
  45. package/src/lib/model/commerce-quote-email.d.ts +67 -0
  46. package/src/lib/model/commerce-quote-email.js +154 -0
  47. package/src/lib/model/commerce-quote-email.js.map +1 -0
  48. package/src/lib/model/commerce-quote-pay.d.ts +49 -6
  49. package/src/lib/model/commerce-quote-pay.js +69 -36
  50. package/src/lib/model/commerce-quote-pay.js.map +1 -1
  51. package/src/lib/model/commerce-quote-request.d.ts +93 -0
  52. package/src/lib/model/commerce-quote-request.js +207 -0
  53. package/src/lib/model/commerce-quote-request.js.map +1 -0
  54. package/src/lib/model/commerce-quotes.d.ts +81 -0
  55. package/src/lib/model/commerce-quotes.js.map +1 -1
  56. package/src/lib/model/index.d.ts +3 -0
  57. package/src/lib/model/index.js +3 -0
  58. package/src/lib/model/index.js.map +1 -1
  59. package/src/lib/plugin.js +19 -0
  60. package/src/lib/plugin.js.map +1 -1
  61. package/src/lib/server/billing-webhook.js +115 -70
  62. package/src/lib/server/billing-webhook.js.map +1 -1
  63. package/src/lib/server/commerce-pdf.d.ts +5 -1
  64. package/src/lib/server/commerce-pdf.js +37 -10
  65. package/src/lib/server/commerce-pdf.js.map +1 -1
  66. package/src/lib/server/draft-order.js +23 -3
  67. package/src/lib/server/draft-order.js.map +1 -1
  68. package/src/lib/server/invoice-email.d.ts +76 -0
  69. package/src/lib/server/invoice-email.js +183 -0
  70. package/src/lib/server/invoice-email.js.map +1 -0
  71. package/src/lib/server/order-invoice-send.d.ts +43 -0
  72. package/src/lib/server/order-invoice-send.js +218 -0
  73. package/src/lib/server/order-invoice-send.js.map +1 -0
  74. package/src/lib/server/order-status.d.ts +11 -0
  75. package/src/lib/server/order-status.js +44 -8
  76. package/src/lib/server/order-status.js.map +1 -1
  77. package/src/lib/server/person-eraser.d.ts +3 -2
  78. package/src/lib/server/person-eraser.js +25 -6
  79. package/src/lib/server/person-eraser.js.map +1 -1
  80. package/src/lib/server/quote-email.d.ts +85 -0
  81. package/src/lib/server/quote-email.js +211 -0
  82. package/src/lib/server/quote-email.js.map +1 -0
  83. package/src/lib/server/quote-link-token.d.ts +11 -0
  84. package/src/lib/server/quote-link-token.js +21 -0
  85. package/src/lib/server/quote-link-token.js.map +1 -1
  86. package/src/lib/server/quote-pay.d.ts +8 -3
  87. package/src/lib/server/quote-pay.js +105 -43
  88. package/src/lib/server/quote-pay.js.map +1 -1
  89. package/src/lib/server/quote.d.ts +11 -0
  90. package/src/lib/server/quote.js +309 -12
  91. package/src/lib/server/quote.js.map +1 -1
  92. package/src/lib/server/site-page-resolver.js +2 -1
  93. package/src/lib/server/site-page-resolver.js.map +1 -1
  94. package/src/lib/server.js +5 -0
  95. package/src/lib/server.js.map +1 -1
  96. package/src/lib/site.js +8 -0
  97. package/src/lib/site.js.map +1 -1
  98. package/src/lib/tenant-emails.js +243 -0
  99. package/src/lib/tenant-emails.js.map +1 -1
  100. package/src/lib/utils/commerce-document-pdf.d.ts +5 -0
  101. package/src/lib/utils/commerce-document-pdf.js +6 -1
  102. package/src/lib/utils/commerce-document-pdf.js.map +1 -1
  103. package/src/lib/utils/invoice-email-content.d.ts +45 -0
  104. package/src/lib/utils/invoice-email-content.js +126 -0
  105. package/src/lib/utils/invoice-email-content.js.map +1 -0
  106. package/src/lib/utils/order-invoice.d.ts +1 -1
  107. package/src/lib/utils/order-invoice.js +14 -6
  108. package/src/lib/utils/order-invoice.js.map +1 -1
  109. package/src/lib/utils/quote-email-content.d.ts +62 -0
  110. package/src/lib/utils/quote-email-content.js +154 -0
  111. package/src/lib/utils/quote-email-content.js.map +1 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginApiHandler, type PluginApiRequest } from '@aglyn/aglyn/server';
18
+ import type { InvoiceMailer } from './invoice-email';
19
+ export interface OrderInvoiceSendDeps {
20
+ verifyIdToken(token: string): Promise<{
21
+ uid: string;
22
+ email?: string | null;
23
+ }>;
24
+ firestore(): FirebaseFirestore.Firestore;
25
+ orgForHost(hostId: string): Promise<{
26
+ id?: string;
27
+ org?: unknown;
28
+ } | null>;
29
+ /** The order's pay link at a link version, or null when the site has no address or no signing secret. */
30
+ payLinkUrl(host: Record<string, unknown>, hostId: string, orderId: string, version: number): string | null;
31
+ /** The invoice email: the renderer, the PDF and sharp, loaded only by a send. */
32
+ mailer(): Promise<InvoiceMailer>;
33
+ now(): number;
34
+ }
35
+ export declare function defaultOrderInvoiceSendDeps(): OrderInvoiceSendDeps;
36
+ type Outcome = {
37
+ status: number;
38
+ body: Record<string, unknown>;
39
+ };
40
+ export declare function handleOrderInvoiceSend(deps: OrderInvoiceSendDeps, req: PluginApiRequest): Promise<Outcome>;
41
+ /** `POST /api/commerce/order-invoice-send`. */
42
+ export declare const orderInvoiceSendHandler: PluginApiHandler;
43
+ export {};
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { checkEntitlement, claimAttempt } from "@aglyn/aglyn/server";
17
+ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
18
+ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
19
+ import * as CommerceModel from "../model/index.js";
20
+ import { commerceSitePermissionRefusal } from "../model/plugin-permissions.js";
21
+ import { holdsCommerceSiteKey } from "./commerce-site-keys.js";
22
+ import { quotePayLinkUrl } from "./quote-link-token.js";
23
+ export function defaultOrderInvoiceSendDeps() {
24
+ return {
25
+ verifyIdToken: (token)=>firebaseAdmin.app().auth().verifyIdToken(token),
26
+ firestore: ()=>firebaseAdmin.app().firestore(),
27
+ orgForHost: async (hostId)=>{
28
+ const found = await getOrgForHost(hostId);
29
+ return found ? {
30
+ id: found.orgId,
31
+ org: found.org
32
+ } : null;
33
+ },
34
+ payLinkUrl: (host, hostId, orderId, version)=>quotePayLinkUrl(host, hostId, orderId, version),
35
+ mailer: async ()=>(await import("./invoice-email.js")).invoiceMailer(),
36
+ now: ()=>Date.now()
37
+ };
38
+ }
39
+ const refuse = (status, error)=>({
40
+ status,
41
+ body: {
42
+ error
43
+ }
44
+ });
45
+ const DOC_ID = /^(?!__.*__$)[^/]{1,200}$/;
46
+ function bodyOf(req) {
47
+ const raw = req.body;
48
+ if (typeof raw === 'string') {
49
+ try {
50
+ const parsed = JSON.parse(raw);
51
+ return parsed && typeof parsed === 'object' ? parsed : {};
52
+ } catch (unused) {
53
+ return {};
54
+ }
55
+ }
56
+ return raw && typeof raw === 'object' ? raw : {};
57
+ }
58
+ function headerOf(req, name) {
59
+ var _req_headers, _headers_name_toLowerCase, _value_;
60
+ const headers = (_req_headers = req.headers) != null ? _req_headers : {};
61
+ const value = (_headers_name_toLowerCase = headers[name.toLowerCase()]) != null ? _headers_name_toLowerCase : headers[name];
62
+ return String(Array.isArray(value) ? (_value_ = value[0]) != null ? _value_ : '' : value != null ? value : '').trim();
63
+ }
64
+ const NO_ADDRESS = 'This site has no web address yet, so its invoices have no pay link. Publish the site first.';
65
+ export async function handleOrderInvoiceSend(deps, req) {
66
+ var _body_hostId, _body_orderId, _hostSnapshot_data, _ref;
67
+ if (req.method !== 'POST') return refuse(405, 'Method not allowed');
68
+ const authorization = headerOf(req, 'authorization');
69
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
70
+ if (!idToken) return refuse(401, 'Unauthenticated');
71
+ const body = bodyOf(req);
72
+ const hostId = String((_body_hostId = body['hostId']) != null ? _body_hostId : '');
73
+ const orderId = String((_body_orderId = body['orderId']) != null ? _body_orderId : '');
74
+ if (!DOC_ID.test(hostId)) return refuse(400, 'Missing hostId');
75
+ if (!DOC_ID.test(orderId)) return refuse(400, 'Missing orderId');
76
+ let decoded;
77
+ try {
78
+ decoded = await deps.verifyIdToken(idToken);
79
+ } catch (error) {
80
+ if (!isRefusedIdToken(error)) throw error;
81
+ return refuse(401, 'Unauthenticated');
82
+ }
83
+ const firestore = deps.firestore();
84
+ const hostRef = firestore.collection('hosts').doc(hostId);
85
+ const hostSnapshot = await hostRef.get();
86
+ if (!hostSnapshot.exists) return refuse(404, 'Unknown site');
87
+ if (!holdsCommerceSiteKey(hostSnapshot, decoded.uid, 'commerce.quotes.send')) {
88
+ return refuse(403, commerceSitePermissionRefusal('commerce.quotes.send'));
89
+ }
90
+ const host = (_hostSnapshot_data = hostSnapshot.data()) != null ? _hostSnapshot_data : {};
91
+ const actor = String(decoded.email || decoded.uid);
92
+ const parsed = CommerceModel.parseQuoteSendInput(body);
93
+ if ('error' in parsed) return refuse(400, parsed.error);
94
+ const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200);
95
+ if (!idempotencyKey) return refuse(400, 'Sending an invoice needs an Idempotency-Key header.');
96
+ const ref = hostRef.collection('orders').doc(orderId);
97
+ const [snapshot, store] = await Promise.all([
98
+ ref.get(),
99
+ hostRef.collection('settings').doc('store').get()
100
+ ]);
101
+ const order = snapshot.exists ? snapshot.data() : null;
102
+ const to = CommerceModel.invoiceSendRecipient(order, parsed.input.to);
103
+ const refused = CommerceModel.invoiceSendRefusal(order, orderId, to);
104
+ if (refused) {
105
+ return refuse(CommerceModel.invoiceSendRefusalStatus(refused), CommerceModel.invoiceSendRefusalMessage(refused));
106
+ }
107
+ const current = order;
108
+ const paid = CommerceModel.invoiceIsPaid(current);
109
+ const linkVersion = paid ? null : CommerceModel.orderPayLinkVersion(current);
110
+ const payUrl = linkVersion === null ? null : deps.payLinkUrl(host, hostId, orderId, linkVersion);
111
+ if (linkVersion !== null && !payUrl) return refuse(409, NO_ADDRESS);
112
+ const owner = await deps.orgForHost(hostId);
113
+ if (!checkEntitlement(owner == null ? void 0 : owner.org, 'commerce')) return refuse(403, 'Selling is not enabled');
114
+ const mailer = await deps.mailer();
115
+ if (!mailer.configured()) {
116
+ return refuse(503, 'Email isn’t set up here, so invoices can’t be emailed.');
117
+ }
118
+ // The bounce and complaint list only: an invoice is transactional, so an
119
+ // unsubscribe from the site's newsletter does not stop it, as it does not
120
+ // stop a receipt.
121
+ if (await mailer.suppression(to)) {
122
+ return refuse(409, `${to} bounced or reported an email as spam, so it can’t be emailed. Send the invoice another way.`);
123
+ }
124
+ const claimed = await claimAttempt(firestore, {
125
+ kind: 'commerce-order-invoice-send',
126
+ scopeId: `${hostId}:${orderId}`,
127
+ orgId: (_ref = owner == null ? void 0 : owner.id) != null ? _ref : '',
128
+ key: idempotencyKey,
129
+ busyMessage: 'This invoice is already being sent'
130
+ });
131
+ if ('replay' in claimed) {
132
+ return {
133
+ status: claimed.replay.status,
134
+ body: claimed.replay.body
135
+ };
136
+ }
137
+ const claim = claimed.claim;
138
+ const now = deps.now();
139
+ let delivered;
140
+ try {
141
+ var _store_data;
142
+ delivered = await mailer.deliver({
143
+ hostId,
144
+ orderId,
145
+ order: current,
146
+ host,
147
+ store: (_store_data = store.data()) != null ? _store_data : null,
148
+ owner: owner ? {
149
+ id: owner.id,
150
+ org: owner.org
151
+ } : null,
152
+ to,
153
+ subject: parsed.input.subject,
154
+ message: parsed.input.message,
155
+ payUrl,
156
+ senderEmail: decoded.email ? String(decoded.email) : null,
157
+ atMs: now
158
+ });
159
+ } catch (error) {
160
+ console.error('commerce invoice send failed', error);
161
+ await claim.release();
162
+ return refuse(502, 'The invoice email could not be sent. Try again.');
163
+ }
164
+ // `strictNullChecks` is off, so the result does not narrow on `sent`.
165
+ if ('error' in delivered) {
166
+ await claim.release();
167
+ return refuse(delivered.status, delivered.error);
168
+ }
169
+ const done = delivered;
170
+ // The email is out: from here a failure is logged, never answered as one,
171
+ // so a retry does not send it twice.
172
+ const send = {
173
+ to,
174
+ subject: done.subject,
175
+ atMs: now,
176
+ by: actor,
177
+ messageId: done.messageId,
178
+ providerId: done.providerId,
179
+ paid: done.paid,
180
+ linkVersion: done.paid ? null : linkVersion,
181
+ totalCents: done.totalCents
182
+ };
183
+ try {
184
+ await firestore.runTransaction(async (transaction)=>{
185
+ const latest = await transaction.get(ref);
186
+ if (!latest.exists) return;
187
+ const fresh = latest.data();
188
+ transaction.set(ref, CommerceModel.invoiceSendPatch(fresh, send, done.thread), {
189
+ merge: true
190
+ });
191
+ });
192
+ } catch (error) {
193
+ console.error('commerce invoice sent but not recorded', {
194
+ hostId,
195
+ orderId,
196
+ messageId: done.messageId,
197
+ error
198
+ });
199
+ }
200
+ const outcome = {
201
+ status: 200,
202
+ body: {
203
+ sent: true,
204
+ to,
205
+ atMs: now,
206
+ paid: done.paid,
207
+ linkVersion: send.linkVersion
208
+ }
209
+ };
210
+ await claim.record(outcome.status, outcome.body);
211
+ return outcome;
212
+ }
213
+ /** `POST /api/commerce/order-invoice-send`. */ export const orderInvoiceSendHandler = async (req, res)=>{
214
+ const outcome = await handleOrderInvoiceSend(defaultOrderInvoiceSendDeps(), req);
215
+ return res.status(outcome.status).json(outcome.body);
216
+ };
217
+
218
+ //# sourceMappingURL=order-invoice-send.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/order-invoice-send.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, claimAttempt, type PluginApiHandler, type PluginApiRequest } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport * as CommerceModel from '../model'\nimport { commerceSitePermissionRefusal } from '../model/plugin-permissions'\nimport { holdsCommerceSiteKey } from './commerce-site-keys'\nimport type { InvoiceMailer } from './invoice-email'\nimport { quotePayLinkUrl } from './quote-link-token'\n\n/*==========================================\n * SEND INVOICE (AGL-4099): `POST /api/commerce/order-invoice-send`.\n *\n * { hostId, orderId, to?, subject?, message? } + Idempotency-Key\n * → { sent: true, to, atMs, paid, linkVersion }\n *\n * Emails the invoice for an order or a quote, under \"Send quotes and\n * invoices to customers\" (`commerce.quotes.send`), the key a quote's send\n * already asks: its label names invoices, and an invoice for an order is the\n * same act toward the same customer.\n *\n * - UNPAID (a draft order, or a quote), the email asks for payment with one\n * button to the order's lasting pay link (`/pay-quote`, its version on\n * `quote.linkVersion` for a quote and `payLink.version` otherwise); the\n * link opens a fresh payment from the stored order whenever it is pressed.\n * - PAID, the email is the record: no button.\n *\n * Refused, in the console's words: an unknown order, a canceled or refunded\n * one, one with nothing on it, an unpaid one its link cannot take payment\n * for (a register sale still open, a quote waiting on the proof's approval,\n * tax not set up, under 50 cents, a draft older than invoices), no address\n * or a bad one, a site with no web address for the link, selling not on,\n * email not set up, and an address that bounced or complained.\n *\n * The send is recorded on the order (`invoiceSends`), on its timeline, and as\n * the conversation it moved on: the quote's for a quote, the order's own\n * otherwise. One attempt per Idempotency-Key, so a retried press is answered\n * from the first and the customer never gets two.\n *=========================================*/\n\nexport interface OrderInvoiceSendDeps {\n verifyIdToken(token: string): Promise<{ uid: string; email?: string | null }>\n firestore(): FirebaseFirestore.Firestore\n orgForHost(hostId: string): Promise<{ id?: string; org?: unknown } | null>\n /** The order's pay link at a link version, or null when the site has no address or no signing secret. */\n payLinkUrl(host: Record<string, unknown>, hostId: string, orderId: string, version: number): string | null\n /** The invoice email: the renderer, the PDF and sharp, loaded only by a send. */\n mailer(): Promise<InvoiceMailer>\n now(): number\n}\n\nexport function defaultOrderInvoiceSendDeps(): OrderInvoiceSendDeps {\n return {\n verifyIdToken: (token) => firebaseAdmin.app().auth().verifyIdToken(token),\n firestore: () => firebaseAdmin.app().firestore(),\n orgForHost: async (hostId) => {\n const found = await getOrgForHost(hostId)\n return found ? { id: found.orgId, org: found.org } : null\n },\n payLinkUrl: (host, hostId, orderId, version) => quotePayLinkUrl(host, hostId, orderId, version),\n mailer: async () => (await import('./invoice-email')).invoiceMailer(),\n now: () => Date.now(),\n }\n}\n\ntype Outcome = { status: number; body: Record<string, unknown> }\n\nconst refuse = (status: number, error: string): Outcome => ({ status, body: { error } })\n\nconst DOC_ID = /^(?!__.*__$)[^/]{1,200}$/\n\nfunction bodyOf(req: PluginApiRequest): Record<string, unknown> {\n const raw = req.body\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return parsed && typeof parsed === 'object' ? parsed : {}\n } catch {\n return {}\n }\n }\n return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}\n}\n\nfunction headerOf(req: PluginApiRequest, name: string): string {\n const headers = (req.headers ?? {}) as Record<string, unknown>\n const value = headers[name.toLowerCase()] ?? headers[name]\n return String(Array.isArray(value) ? (value[0] ?? '') : (value ?? '')).trim()\n}\n\nconst NO_ADDRESS = 'This site has no web address yet, so its invoices have no pay link. Publish the site first.'\n\nexport async function handleOrderInvoiceSend(deps: OrderInvoiceSendDeps, req: PluginApiRequest): Promise<Outcome> {\n if (req.method !== 'POST') return refuse(405, 'Method not allowed')\n const authorization = headerOf(req, 'authorization')\n const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''\n if (!idToken) return refuse(401, 'Unauthenticated')\n const body = bodyOf(req)\n const hostId = String(body['hostId'] ?? '')\n const orderId = String(body['orderId'] ?? '')\n if (!DOC_ID.test(hostId)) return refuse(400, 'Missing hostId')\n if (!DOC_ID.test(orderId)) return refuse(400, 'Missing orderId')\n\n let decoded: { uid: string; email?: string | null }\n try {\n decoded = await deps.verifyIdToken(idToken)\n } catch (error) {\n if (!isRefusedIdToken(error)) throw error\n return refuse(401, 'Unauthenticated')\n }\n const firestore = deps.firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) return refuse(404, 'Unknown site')\n if (!holdsCommerceSiteKey(hostSnapshot, decoded.uid, 'commerce.quotes.send')) {\n return refuse(403, commerceSitePermissionRefusal('commerce.quotes.send'))\n }\n const host = (hostSnapshot.data() ?? {}) as Record<string, unknown>\n const actor = String(decoded.email || decoded.uid)\n\n const parsed = CommerceModel.parseQuoteSendInput(body)\n if ('error' in parsed) return refuse(400, parsed.error)\n const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200)\n if (!idempotencyKey) return refuse(400, 'Sending an invoice needs an Idempotency-Key header.')\n\n const ref = hostRef.collection('orders').doc(orderId)\n const [snapshot, store] = await Promise.all([ref.get(), hostRef.collection('settings').doc('store').get()])\n const order = snapshot.exists ? (snapshot.data() as CommerceModel.HostOrder) : null\n const to = CommerceModel.invoiceSendRecipient(order, parsed.input.to)\n const refused = CommerceModel.invoiceSendRefusal(order, orderId, to)\n if (refused) {\n return refuse(CommerceModel.invoiceSendRefusalStatus(refused), CommerceModel.invoiceSendRefusalMessage(refused))\n }\n const current = order as CommerceModel.HostOrder\n const paid = CommerceModel.invoiceIsPaid(current)\n const linkVersion = paid ? null : CommerceModel.orderPayLinkVersion(current)\n const payUrl = linkVersion === null ? null : deps.payLinkUrl(host, hostId, orderId, linkVersion)\n if (linkVersion !== null && !payUrl) return refuse(409, NO_ADDRESS)\n\n const owner = await deps.orgForHost(hostId)\n if (!checkEntitlement(owner?.org as never, 'commerce')) return refuse(403, 'Selling is not enabled')\n const mailer = await deps.mailer()\n if (!mailer.configured()) {\n return refuse(503, 'Email isn’t set up here, so invoices can’t be emailed.')\n }\n // The bounce and complaint list only: an invoice is transactional, so an\n // unsubscribe from the site's newsletter does not stop it, as it does not\n // stop a receipt.\n if (await mailer.suppression(to)) {\n return refuse(409, `${to} bounced or reported an email as spam, so it can’t be emailed. Send the invoice another way.`)\n }\n\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-order-invoice-send',\n scopeId: `${hostId}:${orderId}`,\n orgId: owner?.id ?? '',\n key: idempotencyKey,\n busyMessage: 'This invoice is already being sent',\n })\n if ('replay' in claimed) {\n return { status: claimed.replay.status, body: claimed.replay.body as Record<string, unknown> }\n }\n const claim = claimed.claim\n const now = deps.now()\n\n let delivered: Awaited<ReturnType<InvoiceMailer['deliver']>>\n try {\n delivered = await mailer.deliver({\n hostId,\n orderId,\n order: current,\n host,\n store: (store.data() ?? null) as Record<string, unknown> | null,\n owner: owner ? { id: owner.id, org: owner.org } : null,\n to,\n subject: parsed.input.subject,\n message: parsed.input.message,\n payUrl,\n senderEmail: decoded.email ? String(decoded.email) : null,\n atMs: now,\n })\n } catch (error) {\n console.error('commerce invoice send failed', error)\n await claim.release()\n return refuse(502, 'The invoice email could not be sent. Try again.')\n }\n // `strictNullChecks` is off, so the result does not narrow on `sent`.\n if ('error' in delivered) {\n await claim.release()\n return refuse(delivered.status, delivered.error)\n }\n const done = delivered as Extract<typeof delivered, { sent: true }>\n\n // The email is out: from here a failure is logged, never answered as one,\n // so a retry does not send it twice.\n const send: CommerceModel.OrderInvoiceSendRecord = {\n to,\n subject: done.subject,\n atMs: now,\n by: actor,\n messageId: done.messageId,\n providerId: done.providerId,\n paid: done.paid,\n linkVersion: done.paid ? null : linkVersion,\n totalCents: done.totalCents,\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const latest = await transaction.get(ref)\n if (!latest.exists) return\n const fresh = latest.data() as CommerceModel.HostOrder\n transaction.set(ref, CommerceModel.invoiceSendPatch(fresh, send, done.thread), { merge: true })\n })\n } catch (error) {\n console.error('commerce invoice sent but not recorded', { hostId, orderId, messageId: done.messageId, error })\n }\n const outcome: Outcome = {\n status: 200,\n body: { sent: true, to, atMs: now, paid: done.paid, linkVersion: send.linkVersion },\n }\n await claim.record(outcome.status, outcome.body)\n return outcome\n}\n\n/** `POST /api/commerce/order-invoice-send`. */\nexport const orderInvoiceSendHandler: PluginApiHandler = async (req, res) => {\n const outcome = await handleOrderInvoiceSend(defaultOrderInvoiceSendDeps(), req)\n return res.status(outcome.status).json(outcome.body)\n}\n"],"names":["checkEntitlement","claimAttempt","firebaseAdmin","getOrgForHost","isRefusedIdToken","CommerceModel","commerceSitePermissionRefusal","holdsCommerceSiteKey","quotePayLinkUrl","defaultOrderInvoiceSendDeps","verifyIdToken","token","app","auth","firestore","orgForHost","hostId","found","id","orgId","org","payLinkUrl","host","orderId","version","mailer","invoiceMailer","now","Date","refuse","status","error","body","DOC_ID","bodyOf","req","raw","parsed","JSON","parse","headerOf","name","headers","value","toLowerCase","String","Array","isArray","trim","NO_ADDRESS","handleOrderInvoiceSend","deps","hostSnapshot","method","authorization","idToken","startsWith","slice","length","test","decoded","hostRef","collection","doc","get","exists","uid","data","actor","email","parseQuoteSendInput","idempotencyKey","ref","snapshot","store","Promise","all","order","to","invoiceSendRecipient","input","refused","invoiceSendRefusal","invoiceSendRefusalStatus","invoiceSendRefusalMessage","current","paid","invoiceIsPaid","linkVersion","orderPayLinkVersion","payUrl","owner","configured","suppression","claimed","kind","scopeId","key","busyMessage","replay","claim","delivered","deliver","subject","message","senderEmail","atMs","console","release","done","send","by","messageId","providerId","totalCents","runTransaction","transaction","latest","fresh","set","invoiceSendPatch","thread","merge","outcome","sent","record","orderInvoiceSendHandler","res","json"],"mappings":"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"}
@@ -39,6 +39,17 @@ export type OrderStatusActionsProvider = (input: {
39
39
  export declare function registerOrderStatusActions(provider: OrderStatusActionsProvider): void;
40
40
  /** Specs only. */
41
41
  export declare function clearOrderStatusActions(): void;
42
+ /**
43
+ * Pay, on the status page of an order still to be paid that its lasting pay
44
+ * link can take payment for (AGL-4099): a draft order or a quote, opening the
45
+ * same `/pay-quote` page its invoice links to. None for anything else.
46
+ */
47
+ export declare function orderStatusPayAction(input: {
48
+ hostId: string;
49
+ orderId: string;
50
+ order: CommerceModel.HostOrder;
51
+ currency: string;
52
+ }): CommerceModel.OrderStatusAction | null;
42
53
  /**
43
54
  * The guest order-status page's data (AGL-3610): `GET
44
55
  * /api/commerce/order-status?hostId=…&o=<orderId>&t=<token>` on the store.
@@ -18,6 +18,8 @@ import { readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
18
18
  import { consumeRateLimit, firebaseAdmin } from "@aglyn/tenant-data-admin";
19
19
  import * as CommerceModel from "../model/index.js";
20
20
  import { verifyOrderStatusToken } from "./order-status-token.js";
21
+ import { mintQuoteLinkToken } from "./quote-link-token.js";
22
+ import { quotePageHref } from "../constants/quote-page.js";
21
23
  import { resolvePluginTrackingPages } from "@aglyn/aglyn/plugin-manager/plugin-tracking-pages";
22
24
  /** How long the page waits for a tracking service's own page link (AGL-3635). */ export const TRACKING_PAGE_TIMEOUT_MS = 1500;
23
25
  /** Status lookups one address may make per site, per ten minutes. */ export const ORDER_STATUS_LOOKUPS_PER_WINDOW = 60;
@@ -41,6 +43,28 @@ async function actionsFor(input) {
41
43
  }));
42
44
  return answers.flat().filter((action)=>action && action.id && action.label && /^(https:\/\/|\/)/.test(String(action.url)));
43
45
  }
46
+ /**
47
+ * Pay, on the status page of an order still to be paid that its lasting pay
48
+ * link can take payment for (AGL-4099): a draft order or a quote, opening the
49
+ * same `/pay-quote` page its invoice links to. None for anything else.
50
+ */ export function orderStatusPayAction(input) {
51
+ const { hostId, orderId, order } = input;
52
+ if (order.status !== 'pending' || !CommerceModel.orderPayLinkKind(order)) return null;
53
+ if (CommerceModel.invoicePayRefusal(order, orderId)) return null;
54
+ const token = mintQuoteLinkToken(hostId, orderId, CommerceModel.orderPayLinkVersion(order));
55
+ if (!token) return null;
56
+ const charge = CommerceModel.invoiceCheckoutCharge(order, orderId);
57
+ const later = [
58
+ charge.shippingOptions.length > 0 ? 'shipping' : '',
59
+ charge.taxAtPayment ? 'tax' : ''
60
+ ].filter(Boolean).join(' and ');
61
+ const amount = CommerceModel.formatOrderMoney(charge.totalCents, input.currency);
62
+ return {
63
+ id: 'pay',
64
+ label: later ? `Pay ${amount} plus ${later}` : `Pay ${amount}`,
65
+ url: quotePageHref(orderId, token)
66
+ };
67
+ }
44
68
  /**
45
69
  * The guest order-status page's data (AGL-3610): `GET
46
70
  * /api/commerce/order-status?hostId=…&o=<orderId>&t=<token>` on the store.
@@ -92,7 +116,7 @@ async function actionsFor(input) {
92
116
  });
93
117
  if (!verifyOrderStatusToken(hostId, orderId, token)) return notFound();
94
118
  try {
95
- var _orderSnapshot_data, _resolveHostToken, _settingsSnapshot_get;
119
+ var _orderSnapshot_data, _settingsSnapshot_get, _resolveHostToken;
96
120
  const firestore = firebaseAdmin.app().firestore();
97
121
  const hostRef = firestore.collection('hosts').doc(hostId);
98
122
  const [hostSnapshot, settingsSnapshot, orderSnapshot] = await Promise.all([
@@ -102,18 +126,30 @@ async function actionsFor(input) {
102
126
  ]);
103
127
  if (!hostSnapshot.exists || !orderSnapshot.exists) return notFound();
104
128
  const order = CommerceModel.liftLegacyOrder((_orderSnapshot_data = orderSnapshot.data()) != null ? _orderSnapshot_data : {});
129
+ const currency = String((_settingsSnapshot_get = settingsSnapshot.get('currency')) != null ? _settingsSnapshot_get : 'USD');
130
+ const pay = orderStatusPayAction({
131
+ hostId,
132
+ orderId,
133
+ order,
134
+ currency
135
+ });
105
136
  const view = CommerceModel.buildOrderStatusView({
106
137
  order,
107
138
  orderId,
108
139
  storeName: String((_resolveHostToken = resolveHostToken('businessName', hostSnapshot.data())) != null ? _resolveHostToken : ''),
109
- currency: String((_settingsSnapshot_get = settingsSnapshot.get('currency')) != null ? _settingsSnapshot_get : 'USD'),
140
+ currency,
110
141
  number: CommerceModel.formatOrderNumber(order, orderId),
111
- actions: await actionsFor({
112
- hostId,
113
- orderId,
114
- order,
115
- token
116
- })
142
+ actions: [
143
+ ...pay ? [
144
+ pay
145
+ ] : [],
146
+ ...await actionsFor({
147
+ hostId,
148
+ orderId,
149
+ order,
150
+ token
151
+ })
152
+ ]
117
153
  });
118
154
  // A tracking service's own page for each parcel, where the merchant
119
155
  // follows them through one (AGL-3635); the carrier's link otherwise.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/order-status.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 { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\nimport { consumeRateLimit, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { verifyOrderStatusToken } from './order-status-token'\nimport { resolvePluginTrackingPages } from '@aglyn/aglyn/plugin-manager/plugin-tracking-pages'\n\n/** How long the page waits for a tracking service's own page link (AGL-3635). */\nexport const TRACKING_PAGE_TIMEOUT_MS = 1_500\n\n/** Status lookups one address may make per site, per ten minutes. */\nexport const ORDER_STATUS_LOOKUPS_PER_WINDOW = 60\nexport const ORDER_STATUS_WINDOW_MS = 10 * 60_000\n\n/**\n * Adds buyer actions to the status page (AGL-3610) — the typed extension\n * point for \"Request a return\", which the order-processing work (AGL-3611)\n * owns. A provider sees the verified order and answers the actions that apply\n * to it (none, usually); the page draws each as a button to its `url`. Kept\n * inside commerce: returns are this plugin's, so no cross-plugin seam is\n * needed, and a provider that throws costs the page its buttons, not the page.\n */\nexport type OrderStatusActionsProvider = (input: {\n hostId: string\n orderId: string\n order: CommerceModel.HostOrder\n /** The verified status token, for a provider building a signed follow-up link. */\n token: string\n}) => CommerceModel.OrderStatusAction[] | Promise<CommerceModel.OrderStatusAction[]>\n\nconst actionProviders: OrderStatusActionsProvider[] = []\n\nexport function registerOrderStatusActions(provider: OrderStatusActionsProvider): void {\n if (!actionProviders.includes(provider)) actionProviders.push(provider)\n}\n\n/** Specs only. */\nexport function clearOrderStatusActions(): void {\n actionProviders.length = 0\n}\n\nasync function actionsFor(\n input: Parameters<OrderStatusActionsProvider>[0],\n): Promise<CommerceModel.OrderStatusAction[]> {\n const answers = await Promise.all(\n actionProviders.map(async (provider) => {\n try {\n return (await provider(input)) ?? []\n } catch (error) {\n console.error('[order-status] actions provider failed', error)\n return []\n }\n }),\n )\n return answers\n .flat()\n .filter(\n (action) =>\n action && action.id && action.label && /^(https:\\/\\/|\\/)/.test(String(action.url)),\n )\n}\n\n/**\n * The guest order-status page's data (AGL-3610): `GET\n * /api/commerce/order-status?hostId=…&o=<orderId>&t=<token>` on the store.\n *\n * The token is the whole credential — `verifyOrderStatusToken`, constant\n * time — and a wrong one answers exactly as a missing order does (404), so\n * the route cannot be used to learn which order ids exist. Rate-limited per\n * address per site, durable, BEFORE the token check so guessing costs the\n * same as looking. The answer is `buildOrderStatusView`, an allow-listed\n * projection, never the order document; `Cache-Control: private, no-store`\n * and `X-Robots-Tag: noindex` keep it out of shared caches and search.\n *\n * Registered as a `recipientLink` route: an emailed link has to keep working\n * for the buyer it was sent to, so the per-site enablement gates do not\n * stand between them and their order.\n */\nexport const orderStatusHandler: PluginApiHandler = async (req, res) => {\n res.setHeader('Cache-Control', 'private, no-store')\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n if (req.method !== 'GET') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.query.hostId ?? '')\n const orderId = String(req.query.o ?? '').slice(0, 200)\n const token = String(req.query.t ?? '').slice(0, 200)\n if (!hostId || !orderId || !token || /^__.*__$/.test(hostId) || /^__.*__$/.test(orderId) || orderId.includes('/')) {\n return res.status(400).json({ error: 'This link is incomplete.' })\n }\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n 'no-address'\n const rate = await consumeRateLimit(`commerce-order-status:${hostId}:${ip}`, {\n limit: ORDER_STATUS_LOOKUPS_PER_WINDOW,\n windowMs: ORDER_STATUS_WINDOW_MS,\n })\n if (!rate.allowed) {\n res.setHeader(\n 'Retry-After',\n String(Math.max(1, Math.ceil((rate.resetMs - Date.now()) / 1000))),\n )\n return res.status(429).json({ error: 'Too many lookups. Try again in a few minutes.' })\n }\n const notFound = () =>\n res.status(404).json({ error: 'We could not find that order. Check the link in your email.' })\n if (!verifyOrderStatusToken(hostId, orderId, token)) return notFound()\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, settingsSnapshot, orderSnapshot] = await Promise.all([\n hostRef.get(),\n hostRef.collection('settings').doc('store').get(),\n hostRef.collection('orders').doc(orderId).get(),\n ])\n if (!hostSnapshot.exists || !orderSnapshot.exists) return notFound()\n const order = CommerceModel.liftLegacyOrder((orderSnapshot.data() ?? {}) as never)\n const view = CommerceModel.buildOrderStatusView({\n order,\n orderId,\n storeName: String(\n resolveHostToken('businessName', hostSnapshot.data() as HostTokenSource) ?? '',\n ),\n currency: String(settingsSnapshot.get('currency') ?? 'USD'),\n number: CommerceModel.formatOrderNumber(order, orderId),\n actions: await actionsFor({ hostId, orderId, order, token }),\n })\n // A tracking service's own page for each parcel, where the merchant\n // follows them through one (AGL-3635); the carrier's link otherwise.\n const pages = await resolvePluginTrackingPages(\n view.shipments\n .filter((shipment) => shipment.trackingNumber)\n .map((shipment) => ({\n hostId,\n recordId: orderId,\n carrier: shipment.carrier,\n trackingNumber: String(shipment.trackingNumber),\n })),\n { timeoutMs: TRACKING_PAGE_TIMEOUT_MS },\n )\n for (const shipment of view.shipments) {\n const page = shipment.trackingNumber ? pages.get(shipment.trackingNumber) : undefined\n if (page) shipment.trackingUrl = page\n }\n return res.status(200).json(view)\n } catch (error) {\n console.error('orderStatus failed', error)\n return res.status(500).json({ error: 'Your order could not be loaded. Try again.' })\n }\n}\n"],"names":["resolveHostToken","readClientIp","consumeRateLimit","firebaseAdmin","CommerceModel","verifyOrderStatusToken","resolvePluginTrackingPages","TRACKING_PAGE_TIMEOUT_MS","ORDER_STATUS_LOOKUPS_PER_WINDOW","ORDER_STATUS_WINDOW_MS","actionProviders","registerOrderStatusActions","provider","includes","push","clearOrderStatusActions","length","actionsFor","input","answers","Promise","all","map","error","console","flat","filter","action","id","label","test","String","url","orderStatusHandler","req","res","setHeader","method","status","json","hostId","query","orderId","o","slice","token","t","ip","headers","remoteAddress","socket","rate","limit","windowMs","allowed","Math","max","ceil","resetMs","Date","now","notFound","orderSnapshot","settingsSnapshot","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","order","liftLegacyOrder","data","view","buildOrderStatusView","storeName","currency","number","formatOrderNumber","actions","pages","shipments","shipment","trackingNumber","recordId","carrier","timeoutMs","page","undefined","trackingUrl"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/order-status.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 { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\nimport { consumeRateLimit, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { verifyOrderStatusToken } from './order-status-token'\nimport { mintQuoteLinkToken } from './quote-link-token'\nimport { quotePageHref } from '../constants/quote-page'\nimport { resolvePluginTrackingPages } from '@aglyn/aglyn/plugin-manager/plugin-tracking-pages'\n\n/** How long the page waits for a tracking service's own page link (AGL-3635). */\nexport const TRACKING_PAGE_TIMEOUT_MS = 1_500\n\n/** Status lookups one address may make per site, per ten minutes. */\nexport const ORDER_STATUS_LOOKUPS_PER_WINDOW = 60\nexport const ORDER_STATUS_WINDOW_MS = 10 * 60_000\n\n/**\n * Adds buyer actions to the status page (AGL-3610) — the typed extension\n * point for \"Request a return\", which the order-processing work (AGL-3611)\n * owns. A provider sees the verified order and answers the actions that apply\n * to it (none, usually); the page draws each as a button to its `url`. Kept\n * inside commerce: returns are this plugin's, so no cross-plugin seam is\n * needed, and a provider that throws costs the page its buttons, not the page.\n */\nexport type OrderStatusActionsProvider = (input: {\n hostId: string\n orderId: string\n order: CommerceModel.HostOrder\n /** The verified status token, for a provider building a signed follow-up link. */\n token: string\n}) => CommerceModel.OrderStatusAction[] | Promise<CommerceModel.OrderStatusAction[]>\n\nconst actionProviders: OrderStatusActionsProvider[] = []\n\nexport function registerOrderStatusActions(provider: OrderStatusActionsProvider): void {\n if (!actionProviders.includes(provider)) actionProviders.push(provider)\n}\n\n/** Specs only. */\nexport function clearOrderStatusActions(): void {\n actionProviders.length = 0\n}\n\nasync function actionsFor(\n input: Parameters<OrderStatusActionsProvider>[0],\n): Promise<CommerceModel.OrderStatusAction[]> {\n const answers = await Promise.all(\n actionProviders.map(async (provider) => {\n try {\n return (await provider(input)) ?? []\n } catch (error) {\n console.error('[order-status] actions provider failed', error)\n return []\n }\n }),\n )\n return answers\n .flat()\n .filter(\n (action) =>\n action && action.id && action.label && /^(https:\\/\\/|\\/)/.test(String(action.url)),\n )\n}\n\n/**\n * Pay, on the status page of an order still to be paid that its lasting pay\n * link can take payment for (AGL-4099): a draft order or a quote, opening the\n * same `/pay-quote` page its invoice links to. None for anything else.\n */\nexport function orderStatusPayAction(input: {\n hostId: string\n orderId: string\n order: CommerceModel.HostOrder\n currency: string\n}): CommerceModel.OrderStatusAction | null {\n const { hostId, orderId, order } = input\n if (order.status !== 'pending' || !CommerceModel.orderPayLinkKind(order)) return null\n if (CommerceModel.invoicePayRefusal(order, orderId)) return null\n const token = mintQuoteLinkToken(hostId, orderId, CommerceModel.orderPayLinkVersion(order))\n if (!token) return null\n const charge = CommerceModel.invoiceCheckoutCharge(order, orderId)\n const later = [charge.shippingOptions.length > 0 ? 'shipping' : '', charge.taxAtPayment ? 'tax' : '']\n .filter(Boolean)\n .join(' and ')\n const amount = CommerceModel.formatOrderMoney(charge.totalCents, input.currency)\n return { id: 'pay', label: later ? `Pay ${amount} plus ${later}` : `Pay ${amount}`, url: quotePageHref(orderId, token) }\n}\n\n/**\n * The guest order-status page's data (AGL-3610): `GET\n * /api/commerce/order-status?hostId=…&o=<orderId>&t=<token>` on the store.\n *\n * The token is the whole credential — `verifyOrderStatusToken`, constant\n * time — and a wrong one answers exactly as a missing order does (404), so\n * the route cannot be used to learn which order ids exist. Rate-limited per\n * address per site, durable, BEFORE the token check so guessing costs the\n * same as looking. The answer is `buildOrderStatusView`, an allow-listed\n * projection, never the order document; `Cache-Control: private, no-store`\n * and `X-Robots-Tag: noindex` keep it out of shared caches and search.\n *\n * Registered as a `recipientLink` route: an emailed link has to keep working\n * for the buyer it was sent to, so the per-site enablement gates do not\n * stand between them and their order.\n */\nexport const orderStatusHandler: PluginApiHandler = async (req, res) => {\n res.setHeader('Cache-Control', 'private, no-store')\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n if (req.method !== 'GET') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.query.hostId ?? '')\n const orderId = String(req.query.o ?? '').slice(0, 200)\n const token = String(req.query.t ?? '').slice(0, 200)\n if (!hostId || !orderId || !token || /^__.*__$/.test(hostId) || /^__.*__$/.test(orderId) || orderId.includes('/')) {\n return res.status(400).json({ error: 'This link is incomplete.' })\n }\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n 'no-address'\n const rate = await consumeRateLimit(`commerce-order-status:${hostId}:${ip}`, {\n limit: ORDER_STATUS_LOOKUPS_PER_WINDOW,\n windowMs: ORDER_STATUS_WINDOW_MS,\n })\n if (!rate.allowed) {\n res.setHeader(\n 'Retry-After',\n String(Math.max(1, Math.ceil((rate.resetMs - Date.now()) / 1000))),\n )\n return res.status(429).json({ error: 'Too many lookups. Try again in a few minutes.' })\n }\n const notFound = () =>\n res.status(404).json({ error: 'We could not find that order. Check the link in your email.' })\n if (!verifyOrderStatusToken(hostId, orderId, token)) return notFound()\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, settingsSnapshot, orderSnapshot] = await Promise.all([\n hostRef.get(),\n hostRef.collection('settings').doc('store').get(),\n hostRef.collection('orders').doc(orderId).get(),\n ])\n if (!hostSnapshot.exists || !orderSnapshot.exists) return notFound()\n const order = CommerceModel.liftLegacyOrder((orderSnapshot.data() ?? {}) as never)\n const currency = String(settingsSnapshot.get('currency') ?? 'USD')\n const pay = orderStatusPayAction({ hostId, orderId, order, currency })\n const view = CommerceModel.buildOrderStatusView({\n order,\n orderId,\n storeName: String(\n resolveHostToken('businessName', hostSnapshot.data() as HostTokenSource) ?? '',\n ),\n currency,\n number: CommerceModel.formatOrderNumber(order, orderId),\n actions: [...(pay ? [pay] : []), ...(await actionsFor({ hostId, orderId, order, token }))],\n })\n // A tracking service's own page for each parcel, where the merchant\n // follows them through one (AGL-3635); the carrier's link otherwise.\n const pages = await resolvePluginTrackingPages(\n view.shipments\n .filter((shipment) => shipment.trackingNumber)\n .map((shipment) => ({\n hostId,\n recordId: orderId,\n carrier: shipment.carrier,\n trackingNumber: String(shipment.trackingNumber),\n })),\n { timeoutMs: TRACKING_PAGE_TIMEOUT_MS },\n )\n for (const shipment of view.shipments) {\n const page = shipment.trackingNumber ? pages.get(shipment.trackingNumber) : undefined\n if (page) shipment.trackingUrl = page\n }\n return res.status(200).json(view)\n } catch (error) {\n console.error('orderStatus failed', error)\n return res.status(500).json({ error: 'Your order could not be loaded. Try again.' })\n }\n}\n"],"names":["resolveHostToken","readClientIp","consumeRateLimit","firebaseAdmin","CommerceModel","verifyOrderStatusToken","mintQuoteLinkToken","quotePageHref","resolvePluginTrackingPages","TRACKING_PAGE_TIMEOUT_MS","ORDER_STATUS_LOOKUPS_PER_WINDOW","ORDER_STATUS_WINDOW_MS","actionProviders","registerOrderStatusActions","provider","includes","push","clearOrderStatusActions","length","actionsFor","input","answers","Promise","all","map","error","console","flat","filter","action","id","label","test","String","url","orderStatusPayAction","hostId","orderId","order","status","orderPayLinkKind","invoicePayRefusal","token","orderPayLinkVersion","charge","invoiceCheckoutCharge","later","shippingOptions","taxAtPayment","Boolean","join","amount","formatOrderMoney","totalCents","currency","orderStatusHandler","req","res","setHeader","method","json","query","o","slice","t","ip","headers","remoteAddress","socket","rate","limit","windowMs","allowed","Math","max","ceil","resetMs","Date","now","notFound","orderSnapshot","settingsSnapshot","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","liftLegacyOrder","data","pay","view","buildOrderStatusView","storeName","number","formatOrderNumber","actions","pages","shipments","shipment","trackingNumber","recordId","carrier","timeoutMs","page","undefined","trackingUrl"],"mappings":"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"}
@@ -48,8 +48,9 @@ export declare function erasedOrderPatch(data: FirebaseFirestore.DocumentData |
48
48
  /**
49
49
  * A quote's customer, erased (AGL-4093). An unpaid quote keeps its customer
50
50
  * in its `quote` section rather than on the order's own fields, so it is
51
- * found by `quote.customer.emailLower` and loses the person and both
52
- * addresses; its lines and totals stay, as an order's do.
51
+ * found by `quote.customer.emailLower` and loses the person, both
52
+ * addresses and the address each email went to; its lines and totals stay,
53
+ * as an order's do.
53
54
  */
54
55
  export declare function erasedQuotePatch(data: FirebaseFirestore.DocumentData | undefined, now: number): Record<string, unknown>;
55
56
  export declare function erasedCheckInPatch(data: FirebaseFirestore.DocumentData | undefined, now: number): Record<string, unknown>;
@@ -19,9 +19,19 @@ import { FieldValue } from "firebase-admin/firestore";
19
19
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
20
20
  const LABEL = 'commerce person eraser';
21
21
  /** Where a quote keeps its customer's address (`commerce-quotes.ts`). */ const QUOTE_EMAIL_FIELD = 'quote.customer.emailLower';
22
+ /**
23
+ * Each time the order's invoice was emailed names where it went (AGL-4099);
24
+ * the record of the send stays, without the address.
25
+ */ function erasedInvoiceSends(data) {
26
+ return Array.isArray(data == null ? void 0 : data['invoiceSends']) ? {
27
+ invoiceSends: data['invoiceSends'].map((send)=>_extends({}, send, {
28
+ to: ''
29
+ }))
30
+ } : {};
31
+ }
22
32
  /** The patch that takes the buyer off one order, as it stands. */ export function erasedOrderPatch(data, now) {
23
33
  const addressTokens = new Set(Array.isArray(data == null ? void 0 : data['customerEmailTokens']) ? data['customerEmailTokens'] : []);
24
- return _extends({
34
+ return _extends({}, erasedInvoiceSends(data), {
25
35
  customerEmail: null,
26
36
  customerName: null,
27
37
  shippingAddress: FieldValue.delete(),
@@ -36,19 +46,28 @@ const LABEL = 'commerce person eraser';
36
46
  /** The patch that takes the customer off one check-in at the window (AGL-4043). */ /**
37
47
  * A quote's customer, erased (AGL-4093). An unpaid quote keeps its customer
38
48
  * in its `quote` section rather than on the order's own fields, so it is
39
- * found by `quote.customer.emailLower` and loses the person and both
40
- * addresses; its lines and totals stay, as an order's do.
49
+ * found by `quote.customer.emailLower` and loses the person, both
50
+ * addresses and the address each email went to; its lines and totals stay,
51
+ * as an order's do.
41
52
  */ export function erasedQuotePatch(data, now) {
42
53
  var _ref;
43
54
  const quote = (_ref = data == null ? void 0 : data['quote']) != null ? _ref : {};
44
- return {
55
+ // Each time it was emailed names where it went (AGL-4097); the record of
56
+ // the send stays, without the address.
57
+ const sends = Array.isArray(quote['sends']) ? {
58
+ sends: quote['sends'].map((send)=>_extends({}, send, {
59
+ to: ''
60
+ }))
61
+ } : {};
62
+ return _extends({
45
63
  quote: _extends({}, quote, {
46
64
  customer: {},
47
65
  billTo: null,
48
66
  shipTo: null
49
- }),
67
+ }, sends)
68
+ }, erasedInvoiceSends(data), {
50
69
  customerErasedAtMs: now
51
- };
70
+ });
52
71
  }
53
72
  export function erasedCheckInPatch(data, now) {
54
73
  var _ref, _ref1;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/person-eraser.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 type {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { updateWhereEquals } from '@aglyn/tenant-data-admin/server/paged-sweeps'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\n\n/**\n * COMMERCE'S SHARE OF A PERSON ERASURE (AGL-2623, AGL-3080).\n *\n * An order is the merchant's record of a sale: the amounts, the line items\n * and the tax are theirs to keep and the law expects them kept. So the person\n * is taken OFF every order they placed on a site of the workspace — name,\n * email, both addresses — and a stamp says when; the order itself stays.\n *\n * The address also lives in what the orders list queries by (AGL-3321): its\n * lower-cased key, its search prefixes, and the quick search's token array,\n * which holds those prefixes beside the order number's and the item names'.\n * The first two go; the prefixes are taken back out of the third. A prefix\n * the number or an item name shares with the address goes with it — a search\n * for that prefix misses this order until the orders backfill restamps it —\n * and nothing left on the order answers to the address.\n *\n * A check-in at the window (AGL-4043) copies the buyer's name, address and\n * phone, so the same person is taken off each of theirs, by the stored\n * lower-cased address; the check-in itself — who was due, who checked them\n * in, the wristband — stays, as the order does.\n *\n * A DRY RUN counts the orders and check-ins and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CommercePersonEraserDeps {\n firestore(): Firestore\n}\n\nconst LABEL = 'commerce person eraser'\n\n/** Where a quote keeps its customer's address (`commerce-quotes.ts`). */\nconst QUOTE_EMAIL_FIELD = 'quote.customer.emailLower'\n\n/** The patch that takes the buyer off one order, as it stands. */\nexport function erasedOrderPatch(\n data: FirebaseFirestore.DocumentData | undefined,\n now: number,\n): Record<string, unknown> {\n const addressTokens = new Set(\n Array.isArray(data?.['customerEmailTokens']) ? data['customerEmailTokens'] : [],\n )\n return {\n customerEmail: null,\n customerName: null,\n shippingAddress: FieldValue.delete(),\n billingAddress: FieldValue.delete(),\n customerErasedAtMs: now,\n customerEmailLower: null,\n customerEmailTokens: [],\n ...(Array.isArray(data?.['searchTokens'])\n ? {\n searchTokens: (data['searchTokens'] as unknown[]).filter(\n (token) => !addressTokens.has(token),\n ),\n }\n : {}),\n }\n}\n\n/** The patch that takes the customer off one check-in at the window (AGL-4043). */\n/**\n * A quote's customer, erased (AGL-4093). An unpaid quote keeps its customer\n * in its `quote` section rather than on the order's own fields, so it is\n * found by `quote.customer.emailLower` and loses the person and both\n * addresses; its lines and totals stay, as an order's do.\n */\nexport function erasedQuotePatch(\n data: FirebaseFirestore.DocumentData | undefined,\n now: number,\n): Record<string, unknown> {\n const quote = (data?.['quote'] ?? {}) as Record<string, unknown>\n return {\n quote: { ...quote, customer: {}, billTo: null, shipTo: null },\n customerErasedAtMs: now,\n }\n}\n\nexport function erasedCheckInPatch(\n data: FirebaseFirestore.DocumentData | undefined,\n now: number,\n): Record<string, unknown> {\n // One key per order or stay, so the people one order brought still read\n // as one party, and as nobody's name.\n const source = String(data?.['orderId'] ?? data?.['reservationId'] ?? '')\n return {\n customerKey: `erased:${source}`,\n customerName: null,\n customerEmail: null,\n customerEmailLower: null,\n customerPhone: null,\n contactId: null,\n searchTokens: [],\n customerErasedAtMs: now,\n }\n}\n\nexport function createCommercePersonEraser(deps: CommercePersonEraserDeps): PluginPersonEraser {\n return async ({ orgId, email, dryRun, atMs }): Promise<PluginPersonErasureReport> => {\n const db = deps.firestore()\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n let orders = 0\n let checkIns = 0\n let quotes = 0\n const emailLower = email.trim().toLowerCase()\n for (const host of hosts.docs) {\n const placed = host.ref.collection('orders')\n const checked = host.ref.collection('checkIns')\n if (dryRun) {\n orders += (await placed.where('customerEmail', '==', email).get()).size\n checkIns += (await checked.where('customerEmailLower', '==', emailLower).get()).size\n quotes += (await placed.where(QUOTE_EMAIL_FIELD, '==', emailLower).get()).size\n continue\n }\n checkIns += await updateWhereEquals(\n db,\n checked,\n 'customerEmailLower',\n emailLower,\n (data) => erasedCheckInPatch(data, atMs),\n LABEL,\n )\n orders += await updateWhereEquals(\n db,\n placed,\n 'customerEmail',\n email,\n (data) => erasedOrderPatch(data, atMs),\n LABEL,\n )\n quotes += await updateWhereEquals(\n db,\n placed,\n QUOTE_EMAIL_FIELD,\n emailLower,\n (data) => erasedQuotePatch(data, atMs),\n LABEL,\n )\n }\n return { orders, checkIns, quotes }\n }\n}\n\n/** The eraser over the platform's own Firestore, as the declarations register it. */\nexport const commercePersonEraser: PluginPersonEraser = (request) =>\n createCommercePersonEraser({ firestore: () => firebaseAdmin.app().firestore() })(request)\n"],"names":["updateWhereEquals","FieldValue","firebaseAdmin","LABEL","QUOTE_EMAIL_FIELD","erasedOrderPatch","data","now","addressTokens","Set","Array","isArray","customerEmail","customerName","shippingAddress","delete","billingAddress","customerErasedAtMs","customerEmailLower","customerEmailTokens","searchTokens","filter","token","has","erasedQuotePatch","quote","customer","billTo","shipTo","erasedCheckInPatch","source","String","customerKey","customerPhone","contactId","createCommercePersonEraser","deps","orgId","email","dryRun","atMs","db","firestore","hosts","collection","where","get","orders","checkIns","quotes","emailLower","trim","toLowerCase","host","docs","placed","ref","checked","size","commercePersonEraser","request","app"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/person-eraser.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 type {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { updateWhereEquals } from '@aglyn/tenant-data-admin/server/paged-sweeps'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\n\n/**\n * COMMERCE'S SHARE OF A PERSON ERASURE (AGL-2623, AGL-3080).\n *\n * An order is the merchant's record of a sale: the amounts, the line items\n * and the tax are theirs to keep and the law expects them kept. So the person\n * is taken OFF every order they placed on a site of the workspace — name,\n * email, both addresses — and a stamp says when; the order itself stays.\n *\n * The address also lives in what the orders list queries by (AGL-3321): its\n * lower-cased key, its search prefixes, and the quick search's token array,\n * which holds those prefixes beside the order number's and the item names'.\n * The first two go; the prefixes are taken back out of the third. A prefix\n * the number or an item name shares with the address goes with it — a search\n * for that prefix misses this order until the orders backfill restamps it —\n * and nothing left on the order answers to the address.\n *\n * A check-in at the window (AGL-4043) copies the buyer's name, address and\n * phone, so the same person is taken off each of theirs, by the stored\n * lower-cased address; the check-in itself — who was due, who checked them\n * in, the wristband — stays, as the order does.\n *\n * A DRY RUN counts the orders and check-ins and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CommercePersonEraserDeps {\n firestore(): Firestore\n}\n\nconst LABEL = 'commerce person eraser'\n\n/** Where a quote keeps its customer's address (`commerce-quotes.ts`). */\nconst QUOTE_EMAIL_FIELD = 'quote.customer.emailLower'\n\n/**\n * Each time the order's invoice was emailed names where it went (AGL-4099);\n * the record of the send stays, without the address.\n */\nfunction erasedInvoiceSends(data: FirebaseFirestore.DocumentData | undefined): Record<string, unknown> {\n return Array.isArray(data?.['invoiceSends'])\n ? { invoiceSends: (data['invoiceSends'] as Array<Record<string, unknown>>).map((send) => ({ ...send, to: '' })) }\n : {}\n}\n\n/** The patch that takes the buyer off one order, as it stands. */\nexport function erasedOrderPatch(\n data: FirebaseFirestore.DocumentData | undefined,\n now: number,\n): Record<string, unknown> {\n const addressTokens = new Set(\n Array.isArray(data?.['customerEmailTokens']) ? data['customerEmailTokens'] : [],\n )\n return {\n ...erasedInvoiceSends(data),\n customerEmail: null,\n customerName: null,\n shippingAddress: FieldValue.delete(),\n billingAddress: FieldValue.delete(),\n customerErasedAtMs: now,\n customerEmailLower: null,\n customerEmailTokens: [],\n ...(Array.isArray(data?.['searchTokens'])\n ? {\n searchTokens: (data['searchTokens'] as unknown[]).filter(\n (token) => !addressTokens.has(token),\n ),\n }\n : {}),\n }\n}\n\n/** The patch that takes the customer off one check-in at the window (AGL-4043). */\n/**\n * A quote's customer, erased (AGL-4093). An unpaid quote keeps its customer\n * in its `quote` section rather than on the order's own fields, so it is\n * found by `quote.customer.emailLower` and loses the person, both\n * addresses and the address each email went to; its lines and totals stay,\n * as an order's do.\n */\nexport function erasedQuotePatch(\n data: FirebaseFirestore.DocumentData | undefined,\n now: number,\n): Record<string, unknown> {\n const quote = (data?.['quote'] ?? {}) as Record<string, unknown>\n // Each time it was emailed names where it went (AGL-4097); the record of\n // the send stays, without the address.\n const sends = Array.isArray(quote['sends'])\n ? { sends: (quote['sends'] as Array<Record<string, unknown>>).map((send) => ({ ...send, to: '' })) }\n : {}\n return {\n quote: { ...quote, customer: {}, billTo: null, shipTo: null, ...sends },\n ...erasedInvoiceSends(data),\n customerErasedAtMs: now,\n }\n}\n\nexport function erasedCheckInPatch(\n data: FirebaseFirestore.DocumentData | undefined,\n now: number,\n): Record<string, unknown> {\n // One key per order or stay, so the people one order brought still read\n // as one party, and as nobody's name.\n const source = String(data?.['orderId'] ?? data?.['reservationId'] ?? '')\n return {\n customerKey: `erased:${source}`,\n customerName: null,\n customerEmail: null,\n customerEmailLower: null,\n customerPhone: null,\n contactId: null,\n searchTokens: [],\n customerErasedAtMs: now,\n }\n}\n\nexport function createCommercePersonEraser(deps: CommercePersonEraserDeps): PluginPersonEraser {\n return async ({ orgId, email, dryRun, atMs }): Promise<PluginPersonErasureReport> => {\n const db = deps.firestore()\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n let orders = 0\n let checkIns = 0\n let quotes = 0\n const emailLower = email.trim().toLowerCase()\n for (const host of hosts.docs) {\n const placed = host.ref.collection('orders')\n const checked = host.ref.collection('checkIns')\n if (dryRun) {\n orders += (await placed.where('customerEmail', '==', email).get()).size\n checkIns += (await checked.where('customerEmailLower', '==', emailLower).get()).size\n quotes += (await placed.where(QUOTE_EMAIL_FIELD, '==', emailLower).get()).size\n continue\n }\n checkIns += await updateWhereEquals(\n db,\n checked,\n 'customerEmailLower',\n emailLower,\n (data) => erasedCheckInPatch(data, atMs),\n LABEL,\n )\n orders += await updateWhereEquals(\n db,\n placed,\n 'customerEmail',\n email,\n (data) => erasedOrderPatch(data, atMs),\n LABEL,\n )\n quotes += await updateWhereEquals(\n db,\n placed,\n QUOTE_EMAIL_FIELD,\n emailLower,\n (data) => erasedQuotePatch(data, atMs),\n LABEL,\n )\n }\n return { orders, checkIns, quotes }\n }\n}\n\n/** The eraser over the platform's own Firestore, as the declarations register it. */\nexport const commercePersonEraser: PluginPersonEraser = (request) =>\n createCommercePersonEraser({ firestore: () => firebaseAdmin.app().firestore() })(request)\n"],"names":["updateWhereEquals","FieldValue","firebaseAdmin","LABEL","QUOTE_EMAIL_FIELD","erasedInvoiceSends","data","Array","isArray","invoiceSends","map","send","to","erasedOrderPatch","now","addressTokens","Set","customerEmail","customerName","shippingAddress","delete","billingAddress","customerErasedAtMs","customerEmailLower","customerEmailTokens","searchTokens","filter","token","has","erasedQuotePatch","quote","sends","customer","billTo","shipTo","erasedCheckInPatch","source","String","customerKey","customerPhone","contactId","createCommercePersonEraser","deps","orgId","email","dryRun","atMs","db","firestore","hosts","collection","where","get","orders","checkIns","quotes","emailLower","trim","toLowerCase","host","docs","placed","ref","checked","size","commercePersonEraser","request","app"],"mappings":"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"}