@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.
- package/README.md +4 -2
- package/package.json +13 -13
- package/src/lib/components/commerce-console-page.js +2 -1
- package/src/lib/components/commerce-console-page.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.d.ts +6 -0
- package/src/lib/components/console/host-orders-card.component.js +3 -2
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/invoice-send.component.d.ts +44 -0
- package/src/lib/components/console/invoice-send.component.js +237 -0
- package/src/lib/components/console/invoice-send.component.js.map +1 -0
- package/src/lib/components/console/order-detail-dialog.component.d.ts +5 -0
- package/src/lib/components/console/order-detail-dialog.component.js +9 -2
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/order-invoice.component.d.ts +9 -0
- package/src/lib/components/console/order-invoice.component.js +27 -2
- package/src/lib/components/console/order-invoice.component.js.map +1 -1
- package/src/lib/components/console/quote-editor-dialog.component.d.ts +29 -0
- package/src/lib/components/console/quote-editor-dialog.component.js +102 -4
- package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/quote-proofs.component.d.ts +11 -0
- package/src/lib/components/console/quote-proofs.component.js +68 -0
- package/src/lib/components/console/quote-proofs.component.js.map +1 -1
- package/src/lib/components/console/quote-send.component.d.ts +42 -0
- package/src/lib/components/console/quote-send.component.js +307 -0
- package/src/lib/components/console/quote-send.component.js.map +1 -0
- package/src/lib/components/console/quote-start.component.d.ts +45 -0
- package/src/lib/components/console/quote-start.component.js +182 -0
- package/src/lib/components/console/quote-start.component.js.map +1 -0
- package/src/lib/components/product-options-field-body.d.ts +44 -0
- package/src/lib/components/product-options-field-body.js +260 -0
- package/src/lib/components/product-options-field-body.js.map +1 -0
- package/src/lib/components/product-options-field.d.ts +32 -0
- package/src/lib/components/product-options-field.js +128 -0
- package/src/lib/components/product-options-field.js.map +1 -0
- package/src/lib/components/quote-page-body.js +39 -20
- package/src/lib/components/quote-page-body.js.map +1 -1
- package/src/lib/model/commerce-invoices.d.ts +132 -0
- package/src/lib/model/commerce-invoices.js +332 -0
- package/src/lib/model/commerce-invoices.js.map +1 -0
- package/src/lib/model/commerce-orders.d.ts +29 -2
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/commerce-product-field.d.ts +23 -0
- package/src/lib/model/commerce-product-field.js +26 -0
- package/src/lib/model/commerce-product-field.js.map +1 -0
- package/src/lib/model/commerce-quote-email.d.ts +67 -0
- package/src/lib/model/commerce-quote-email.js +154 -0
- package/src/lib/model/commerce-quote-email.js.map +1 -0
- package/src/lib/model/commerce-quote-pay.d.ts +49 -6
- package/src/lib/model/commerce-quote-pay.js +69 -36
- package/src/lib/model/commerce-quote-pay.js.map +1 -1
- package/src/lib/model/commerce-quote-request.d.ts +93 -0
- package/src/lib/model/commerce-quote-request.js +207 -0
- package/src/lib/model/commerce-quote-request.js.map +1 -0
- package/src/lib/model/commerce-quotes.d.ts +81 -0
- package/src/lib/model/commerce-quotes.js.map +1 -1
- package/src/lib/model/index.d.ts +3 -0
- package/src/lib/model/index.js +3 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.js +19 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/billing-webhook.js +115 -70
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/commerce-pdf.d.ts +5 -1
- package/src/lib/server/commerce-pdf.js +37 -10
- package/src/lib/server/commerce-pdf.js.map +1 -1
- package/src/lib/server/draft-order.js +23 -3
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/invoice-email.d.ts +76 -0
- package/src/lib/server/invoice-email.js +183 -0
- package/src/lib/server/invoice-email.js.map +1 -0
- package/src/lib/server/order-invoice-send.d.ts +43 -0
- package/src/lib/server/order-invoice-send.js +218 -0
- package/src/lib/server/order-invoice-send.js.map +1 -0
- package/src/lib/server/order-status.d.ts +11 -0
- package/src/lib/server/order-status.js +44 -8
- package/src/lib/server/order-status.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +3 -2
- package/src/lib/server/person-eraser.js +25 -6
- package/src/lib/server/person-eraser.js.map +1 -1
- package/src/lib/server/quote-email.d.ts +85 -0
- package/src/lib/server/quote-email.js +211 -0
- package/src/lib/server/quote-email.js.map +1 -0
- package/src/lib/server/quote-link-token.d.ts +11 -0
- package/src/lib/server/quote-link-token.js +21 -0
- package/src/lib/server/quote-link-token.js.map +1 -1
- package/src/lib/server/quote-pay.d.ts +8 -3
- package/src/lib/server/quote-pay.js +105 -43
- package/src/lib/server/quote-pay.js.map +1 -1
- package/src/lib/server/quote.d.ts +11 -0
- package/src/lib/server/quote.js +309 -12
- package/src/lib/server/quote.js.map +1 -1
- package/src/lib/server/site-page-resolver.js +2 -1
- package/src/lib/server/site-page-resolver.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/site.js +8 -0
- package/src/lib/site.js.map +1 -1
- package/src/lib/tenant-emails.js +243 -0
- package/src/lib/tenant-emails.js.map +1 -1
- package/src/lib/utils/commerce-document-pdf.d.ts +5 -0
- package/src/lib/utils/commerce-document-pdf.js +6 -1
- package/src/lib/utils/commerce-document-pdf.js.map +1 -1
- package/src/lib/utils/invoice-email-content.d.ts +45 -0
- package/src/lib/utils/invoice-email-content.js +126 -0
- package/src/lib/utils/invoice-email-content.js.map +1 -0
- package/src/lib/utils/order-invoice.d.ts +1 -1
- package/src/lib/utils/order-invoice.js +14 -6
- package/src/lib/utils/order-invoice.js.map +1 -1
- package/src/lib/utils/quote-email-content.d.ts +62 -0
- package/src/lib/utils/quote-email-content.js +154 -0
- 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,
|
|
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
|
|
140
|
+
currency,
|
|
110
141
|
number: CommerceModel.formatOrderNumber(order, orderId),
|
|
111
|
-
actions:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
52
|
-
* addresses; its lines and totals stay,
|
|
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
|
|
40
|
-
* addresses; its lines and totals stay,
|
|
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
|
-
|
|
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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"}
|