@aglyn/plugins-commerce 1.0.0-beta.288 → 1.0.0-beta.289
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 +2 -2
- package/package.json +13 -13
- package/src/lib/components/commerce-console-page.js +29 -2
- package/src/lib/components/commerce-console-page.js.map +1 -1
- package/src/lib/components/console/commerce-analytics-card.component.js +4 -2
- package/src/lib/components/console/commerce-analytics-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.js +5 -1
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/host-quotes-card.component.d.ts +34 -0
- package/src/lib/components/console/host-quotes-card.component.js +247 -0
- package/src/lib/components/console/host-quotes-card.component.js.map +1 -0
- package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js +33 -28
- package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js.map +1 -1
- package/src/lib/components/console/quote-editor-dialog.component.d.ts +45 -0
- package/src/lib/components/console/quote-editor-dialog.component.js +1266 -0
- package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -0
- package/src/lib/components/console/quote-pay-link.component.d.ts +38 -0
- package/src/lib/components/console/quote-pay-link.component.js +213 -0
- package/src/lib/components/console/quote-pay-link.component.js.map +1 -0
- package/src/lib/components/console/quote-settings-card.component.d.ts +29 -0
- package/src/lib/components/console/quote-settings-card.component.js +121 -0
- package/src/lib/components/console/quote-settings-card.component.js.map +1 -0
- package/src/lib/components/console/tax-settings-card.component.js +4 -1
- package/src/lib/components/console/tax-settings-card.component.js.map +1 -1
- package/src/lib/components/quote-page-body.d.ts +33 -0
- package/src/lib/components/quote-page-body.js +696 -0
- package/src/lib/components/quote-page-body.js.map +1 -0
- package/src/lib/components/quote-page.d.ts +29 -0
- package/src/lib/components/quote-page.js +96 -0
- package/src/lib/components/quote-page.js.map +1 -0
- package/src/lib/components/storefront-payment-element.d.ts +7 -1
- package/src/lib/components/storefront-payment-element.js +8 -3
- package/src/lib/components/storefront-payment-element.js.map +1 -1
- package/src/lib/constants/orders-list-query.js +6 -1
- package/src/lib/constants/orders-list-query.js.map +1 -1
- package/src/lib/constants/quote-page.d.ts +30 -0
- package/src/lib/constants/quote-page.js +35 -0
- package/src/lib/constants/quote-page.js.map +1 -0
- package/src/lib/model/commerce-orders.d.ts +25 -2
- package/src/lib/model/commerce-orders.js +10 -1
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/commerce-quote-pay.d.ts +229 -0
- package/src/lib/model/commerce-quote-pay.js +393 -0
- package/src/lib/model/commerce-quote-pay.js.map +1 -0
- package/src/lib/model/commerce-quotes.d.ts +279 -0
- package/src/lib/model/commerce-quotes.js +522 -0
- package/src/lib/model/commerce-quotes.js.map +1 -0
- package/src/lib/model/index.d.ts +2 -0
- package/src/lib/model/index.js +2 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/plugin-permissions.d.ts +1 -1
- package/src/lib/model/plugin-permissions.js +20 -0
- package/src/lib/model/plugin-permissions.js.map +1 -1
- package/src/lib/server/api-v1/orders-and-products.js +6 -2
- package/src/lib/server/api-v1/orders-and-products.js.map +1 -1
- package/src/lib/server/billing-webhook.js +127 -52
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/checkout-session-expiry.d.ts +36 -0
- package/src/lib/server/checkout-session-expiry.js +63 -0
- package/src/lib/server/checkout-session-expiry.js.map +1 -0
- package/src/lib/server/draft-order.js +16 -8
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +7 -0
- package/src/lib/server/person-eraser.js +24 -2
- package/src/lib/server/person-eraser.js.map +1 -1
- package/src/lib/server/pos-return.js +3 -1
- package/src/lib/server/pos-return.js.map +1 -1
- package/src/lib/server/quote-link-token.d.ts +39 -0
- package/src/lib/server/quote-link-token.js +97 -0
- package/src/lib/server/quote-link-token.js.map +1 -0
- package/src/lib/server/quote-pay.d.ts +83 -0
- package/src/lib/server/quote-pay.js +606 -0
- package/src/lib/server/quote-pay.js.map +1 -0
- package/src/lib/server/quote.d.ts +49 -0
- package/src/lib/server/quote.js +513 -0
- package/src/lib/server/quote.js.map +1 -0
- package/src/lib/server/related.js +2 -0
- package/src/lib/server/related.js.map +1 -1
- package/src/lib/server/site-page-resolver.d.ts +2 -0
- package/src/lib/server/site-page-resolver.js +75 -0
- package/src/lib/server/site-page-resolver.js.map +1 -1
- package/src/lib/server.js +14 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/site.js +6 -0
- package/src/lib/site.js.map +1 -1
- package/src/lib/transfer/tracking.server.js +6 -0
- package/src/lib/transfer/tracking.server.js.map +1 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright 2026 Aglyn LLC
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
* you may not use this file except in compliance with the License.
|
|
9
|
+
* You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/ import { claimAttempt } from "@aglyn/aglyn/server";
|
|
19
|
+
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
20
|
+
import { checkEntitlement } from "@aglyn/aglyn/server";
|
|
21
|
+
import { searchPluginPeople } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
|
|
22
|
+
import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
23
|
+
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
24
|
+
import * as CommerceModel from "../model/index.js";
|
|
25
|
+
import { commerceSitePermissionRefusal } from "../model/plugin-permissions.js";
|
|
26
|
+
import { holdsCommerceSiteKey } from "./commerce-site-keys.js";
|
|
27
|
+
import { quoteSaleTaxWithEngine } from "./tax-engine-quote.js";
|
|
28
|
+
import { expireCheckoutSession } from "./checkout-session-expiry.js";
|
|
29
|
+
import { quotePayLinkUrl } from "./quote-link-token.js";
|
|
30
|
+
export function defaultQuoteDeps() {
|
|
31
|
+
return {
|
|
32
|
+
verifyIdToken: (token)=>firebaseAdmin.app().auth().verifyIdToken(token),
|
|
33
|
+
firestore: ()=>firebaseAdmin.app().firestore(),
|
|
34
|
+
orgForHost: async (hostId)=>{
|
|
35
|
+
const found = await getOrgForHost(hostId);
|
|
36
|
+
return found ? {
|
|
37
|
+
id: found.orgId,
|
|
38
|
+
org: found.org
|
|
39
|
+
} : null;
|
|
40
|
+
},
|
|
41
|
+
searchPeople: (request)=>searchPluginPeople(request),
|
|
42
|
+
quoteTax: (request)=>quoteSaleTaxWithEngine(request),
|
|
43
|
+
expireSession: (sessionId)=>expireCheckoutSession(sessionId),
|
|
44
|
+
payLinkUrl: (host, hostId, quoteId, version)=>quotePayLinkUrl(host, hostId, quoteId, version),
|
|
45
|
+
now: ()=>Date.now()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Expires the session the pay page opened, when there is one; a failure is logged, never thrown. */ async function closeOpenSession(deps, sessionId, context) {
|
|
49
|
+
if (!sessionId) return;
|
|
50
|
+
const result = await deps.expireSession(sessionId).catch(()=>'open');
|
|
51
|
+
if (result === 'open') {
|
|
52
|
+
console.error(`${context}: the quote's Stripe session was not expired and may still be paid`, {
|
|
53
|
+
sessionId
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const refuse = (status, error)=>({
|
|
58
|
+
status,
|
|
59
|
+
body: {
|
|
60
|
+
error
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
function bodyOf(req) {
|
|
64
|
+
const raw = req.body;
|
|
65
|
+
if (typeof raw === 'string') {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
69
|
+
} catch (unused) {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
74
|
+
}
|
|
75
|
+
function headerOf(req, name) {
|
|
76
|
+
var _req_headers, _headers_name_toLowerCase, _value_;
|
|
77
|
+
const headers = (_req_headers = req.headers) != null ? _req_headers : {};
|
|
78
|
+
const value = (_headers_name_toLowerCase = headers[name.toLowerCase()]) != null ? _headers_name_toLowerCase : headers[name];
|
|
79
|
+
return String(Array.isArray(value) ? (_value_ = value[0]) != null ? _value_ : '' : value != null ? value : '').trim();
|
|
80
|
+
}
|
|
81
|
+
const DOC_ID = /^(?!__.*__$)[^/]{1,200}$/;
|
|
82
|
+
/** The ship-to as a tax service reads it, or `null` to tax at the store's address. */ function serviceAddress(shipTo) {
|
|
83
|
+
if (!(shipTo == null ? void 0 : shipTo.country)) return null;
|
|
84
|
+
return _extends({
|
|
85
|
+
country: shipTo.country
|
|
86
|
+
}, shipTo.line1 ? {
|
|
87
|
+
line1: shipTo.line1
|
|
88
|
+
} : {}, shipTo.line2 ? {
|
|
89
|
+
line2: shipTo.line2
|
|
90
|
+
} : {}, shipTo.city ? {
|
|
91
|
+
city: shipTo.city
|
|
92
|
+
} : {}, shipTo.state ? {
|
|
93
|
+
region: shipTo.state
|
|
94
|
+
} : {}, shipTo.postalCode ? {
|
|
95
|
+
postalCode: shipTo.postalCode
|
|
96
|
+
} : {});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Prices a quote: each product line from its product, the tax from the
|
|
100
|
+
* store's settings and, where the site connected one, its tax service.
|
|
101
|
+
*/ async function priceQuote(deps, hostRef, hostId, input) {
|
|
102
|
+
var _store_get, _ref;
|
|
103
|
+
var _input_shipping;
|
|
104
|
+
const productIds = [
|
|
105
|
+
...new Set(input.lines.flatMap((line)=>line.kind === 'catalog' ? [
|
|
106
|
+
line.productId
|
|
107
|
+
] : []))
|
|
108
|
+
];
|
|
109
|
+
const productDocs = await Promise.all(productIds.map((id)=>hostRef.collection('products').doc(id).get()));
|
|
110
|
+
const products = new Map(productDocs.map((snapshot, index)=>[
|
|
111
|
+
productIds[index],
|
|
112
|
+
snapshot.exists ? snapshot.data() : null
|
|
113
|
+
]));
|
|
114
|
+
const lineItems = [];
|
|
115
|
+
for (const line of input.lines){
|
|
116
|
+
if (line.kind === 'custom') {
|
|
117
|
+
lineItems.push(CommerceModel.priceQuoteCustomLine(line));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const priced = CommerceModel.priceQuoteCatalogLine(products.get(line.productId), line);
|
|
121
|
+
if ('error' in priced) return refuse(400, priced.error);
|
|
122
|
+
lineItems.push(priced.line);
|
|
123
|
+
}
|
|
124
|
+
const store = await hostRef.collection('settings').doc('store').get();
|
|
125
|
+
const taxSettings = (_store_get = store.get('tax')) != null ? _store_get : {};
|
|
126
|
+
let tax = CommerceModel.quoteTaxBasisFromSettings(taxSettings, input.shipTo);
|
|
127
|
+
const shippingCents = (_ref = (_input_shipping = input.shipping) == null ? void 0 : _input_shipping.amountCents) != null ? _ref : 0;
|
|
128
|
+
const amounts = CommerceModel.quoteLineAmounts(lineItems, input.discount);
|
|
129
|
+
// A tax service answers only for a store on its own rates; it is asked about
|
|
130
|
+
// what each line is charged after its share of the discount.
|
|
131
|
+
if (taxSettings.mode === 'manual' && amounts.itemsCents > 0) {
|
|
132
|
+
var _input_customer_email;
|
|
133
|
+
const serviceLines = lineItems.map((line, index)=>({
|
|
134
|
+
line,
|
|
135
|
+
index,
|
|
136
|
+
amountCents: amounts.netCents[index]
|
|
137
|
+
})).filter((entry)=>entry.amountCents > 0);
|
|
138
|
+
const answer = await deps.quoteTax(_extends({
|
|
139
|
+
hostId,
|
|
140
|
+
settings: taxSettings,
|
|
141
|
+
channel: 'invoice',
|
|
142
|
+
lines: serviceLines.map(({ line, index, amountCents })=>_extends({
|
|
143
|
+
id: String(index)
|
|
144
|
+
}, line.productId ? {
|
|
145
|
+
productId: line.productId
|
|
146
|
+
} : {}, line.variantId ? {
|
|
147
|
+
variantId: line.variantId
|
|
148
|
+
} : {}, line.sku ? {
|
|
149
|
+
sku: line.sku
|
|
150
|
+
} : {}, {
|
|
151
|
+
description: [
|
|
152
|
+
line.name,
|
|
153
|
+
line.variantLabel
|
|
154
|
+
].filter(Boolean).join(' — '),
|
|
155
|
+
quantity: line.quantity,
|
|
156
|
+
amountCents
|
|
157
|
+
}, line.taxExempt ? {
|
|
158
|
+
exempt: true
|
|
159
|
+
} : {}))
|
|
160
|
+
}, shippingCents ? {
|
|
161
|
+
shippingCents
|
|
162
|
+
} : {}, {
|
|
163
|
+
shipTo: serviceAddress(input.shipTo),
|
|
164
|
+
customerEmail: (_input_customer_email = input.customer.email) != null ? _input_customer_email : null
|
|
165
|
+
}));
|
|
166
|
+
if (answer.quote) {
|
|
167
|
+
tax = {
|
|
168
|
+
kind: 'service',
|
|
169
|
+
taxCents: answer.quote.taxCents,
|
|
170
|
+
providerLabel: answer.quote.providerLabel || answer.quote.provider
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const totals = CommerceModel.computeQuoteTotals({
|
|
175
|
+
lines: lineItems,
|
|
176
|
+
discount: input.discount,
|
|
177
|
+
shippingCents,
|
|
178
|
+
tax
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
lineItems,
|
|
182
|
+
totals: CommerceModel.quoteOrderTotals(totals),
|
|
183
|
+
tax,
|
|
184
|
+
taxMode: CommerceModel.quoteTaxMode(tax, totals.taxCents),
|
|
185
|
+
requireProofApprovalDefault: CommerceModel.quoteRequiresProofApprovalByDefault(store.get('quotes'))
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** The editable half of the `quote` section, every key written so a save clears what was removed. */ function editedQuoteFields(input) {
|
|
189
|
+
return {
|
|
190
|
+
customer: input.customer,
|
|
191
|
+
billTo: input.billTo,
|
|
192
|
+
shipTo: input.shipTo,
|
|
193
|
+
notes: input.notes,
|
|
194
|
+
terms: input.terms,
|
|
195
|
+
discount: input.discount,
|
|
196
|
+
shipping: input.shipping
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export async function handleQuote(deps, req) {
|
|
200
|
+
var _body_hostId, _body_action, _body_quoteId, _ref;
|
|
201
|
+
if (req.method !== 'POST') return refuse(405, 'Method not allowed');
|
|
202
|
+
const authorization = headerOf(req, 'authorization');
|
|
203
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
|
|
204
|
+
if (!idToken) return refuse(401, 'Unauthenticated');
|
|
205
|
+
const body = bodyOf(req);
|
|
206
|
+
const hostId = String((_body_hostId = body['hostId']) != null ? _body_hostId : '');
|
|
207
|
+
const action = String((_body_action = body['action']) != null ? _body_action : '');
|
|
208
|
+
if (!DOC_ID.test(hostId)) return refuse(400, 'Missing hostId');
|
|
209
|
+
let decoded;
|
|
210
|
+
try {
|
|
211
|
+
decoded = await deps.verifyIdToken(idToken);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!isRefusedIdToken(error)) throw error;
|
|
214
|
+
return refuse(401, 'Unauthenticated');
|
|
215
|
+
}
|
|
216
|
+
const firestore = deps.firestore();
|
|
217
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
218
|
+
const host = await hostRef.get();
|
|
219
|
+
if (!host.exists) return refuse(404, 'Unknown site');
|
|
220
|
+
const key = action === 'pay-link' || action === 'replace-link' ? 'commerce.quotes.send' : 'commerce.quotes';
|
|
221
|
+
if (!holdsCommerceSiteKey(host, decoded.uid, key)) {
|
|
222
|
+
return refuse(403, commerceSitePermissionRefusal(key));
|
|
223
|
+
}
|
|
224
|
+
const actor = String(decoded.email || decoded.uid);
|
|
225
|
+
if (action === 'pay-link' || action === 'replace-link') {
|
|
226
|
+
var _body_quoteId1, _host_data;
|
|
227
|
+
const quoteId = String((_body_quoteId1 = body['quoteId']) != null ? _body_quoteId1 : '');
|
|
228
|
+
if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
|
|
229
|
+
const ref = hostRef.collection('orders').doc(quoteId);
|
|
230
|
+
const now = deps.now();
|
|
231
|
+
const answer = await firestore.runTransaction(async (transaction)=>{
|
|
232
|
+
const snapshot = await transaction.get(ref);
|
|
233
|
+
const order = snapshot.exists ? snapshot.data() : null;
|
|
234
|
+
if (!order || !CommerceModel.isQuote(order) || !order.quote) return {
|
|
235
|
+
outcome: refuse(404, 'Unknown quote')
|
|
236
|
+
};
|
|
237
|
+
const version = CommerceModel.quoteLinkVersion(order.quote);
|
|
238
|
+
if (action === 'pay-link') return {
|
|
239
|
+
outcome: {
|
|
240
|
+
status: 200,
|
|
241
|
+
body: {
|
|
242
|
+
linkVersion: version
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
if (!CommerceModel.isUnpaidQuote(order)) {
|
|
247
|
+
return {
|
|
248
|
+
outcome: refuse(409, 'This quote has been paid, so its link can no longer be replaced.')
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const next = version + 1;
|
|
252
|
+
// The open session goes with the link it was opened from.
|
|
253
|
+
const rest = _extends({}, order);
|
|
254
|
+
delete rest.checkoutSessionId;
|
|
255
|
+
transaction.set(ref, _extends({}, rest, {
|
|
256
|
+
timeline: CommerceModel.appendOrderEvent(order, 'quote-link-replaced', `Pay link replaced by ${actor}; the earlier link no longer opens the quote`, now),
|
|
257
|
+
quote: _extends({}, order.quote, {
|
|
258
|
+
linkVersion: next
|
|
259
|
+
})
|
|
260
|
+
}));
|
|
261
|
+
return {
|
|
262
|
+
outcome: {
|
|
263
|
+
status: 200,
|
|
264
|
+
body: {
|
|
265
|
+
linkVersion: next
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
closeSession: order.checkoutSessionId
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
if (answer.closeSession) await closeOpenSession(deps, answer.closeSession, 'quote link replaced');
|
|
272
|
+
if (answer.outcome.status !== 200) return answer.outcome;
|
|
273
|
+
const linkVersion = Number(answer.outcome.body['linkVersion']);
|
|
274
|
+
const url = deps.payLinkUrl((_host_data = host.data()) != null ? _host_data : {}, hostId, quoteId, linkVersion);
|
|
275
|
+
if (!url) return refuse(409, 'This site has no web address yet, so its quotes have no pay link. Publish the site first.');
|
|
276
|
+
return {
|
|
277
|
+
status: 200,
|
|
278
|
+
body: {
|
|
279
|
+
url,
|
|
280
|
+
linkVersion
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (action === 'search-customers') {
|
|
285
|
+
var _body_text;
|
|
286
|
+
const text = String((_body_text = body['text']) != null ? _body_text : '').trim().slice(0, 120);
|
|
287
|
+
if (text.length < 2) return {
|
|
288
|
+
status: 200,
|
|
289
|
+
body: {
|
|
290
|
+
available: true,
|
|
291
|
+
customers: []
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const owner = await deps.orgForHost(hostId);
|
|
295
|
+
const people = await deps.searchPeople({
|
|
296
|
+
hostId,
|
|
297
|
+
orgId: (owner == null ? void 0 : owner.id) || null,
|
|
298
|
+
text,
|
|
299
|
+
limit: 10
|
|
300
|
+
});
|
|
301
|
+
if (people === null) return {
|
|
302
|
+
status: 200,
|
|
303
|
+
body: {
|
|
304
|
+
available: false,
|
|
305
|
+
customers: []
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
status: 200,
|
|
310
|
+
body: {
|
|
311
|
+
available: true,
|
|
312
|
+
customers: people.map((person)=>{
|
|
313
|
+
var _person_data;
|
|
314
|
+
const data = (_person_data = person.data) != null ? _person_data : {};
|
|
315
|
+
const read = (key)=>typeof data[key] === 'string' ? String(data[key]).trim() : '';
|
|
316
|
+
return {
|
|
317
|
+
record: {
|
|
318
|
+
kind: person.kind,
|
|
319
|
+
id: person.id
|
|
320
|
+
},
|
|
321
|
+
name: read('name'),
|
|
322
|
+
company: read('company'),
|
|
323
|
+
email: person.email,
|
|
324
|
+
phone: read('phone') || null
|
|
325
|
+
};
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const quoteId = String((_body_quoteId = body['quoteId']) != null ? _body_quoteId : '');
|
|
331
|
+
if (action === 'delete') {
|
|
332
|
+
if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
|
|
333
|
+
const ref = hostRef.collection('orders').doc(quoteId);
|
|
334
|
+
const deleted = await firestore.runTransaction(async (transaction)=>{
|
|
335
|
+
var _ref;
|
|
336
|
+
var _order_quote;
|
|
337
|
+
const snapshot = await transaction.get(ref);
|
|
338
|
+
// Already gone is what a retried delete finds.
|
|
339
|
+
if (!snapshot.exists) return {
|
|
340
|
+
status: 200,
|
|
341
|
+
body: {
|
|
342
|
+
deleted: true
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const order = snapshot.data();
|
|
346
|
+
if (!CommerceModel.isQuote(order)) return refuse(404, 'Unknown quote');
|
|
347
|
+
if (!CommerceModel.isUnpaidQuote(order)) {
|
|
348
|
+
return refuse(409, 'This quote has been paid, so it is an order now and stays on record.');
|
|
349
|
+
}
|
|
350
|
+
if (((_ref = (_order_quote = order.quote) == null ? void 0 : _order_quote.stage) != null ? _ref : 'draft') !== 'draft') {
|
|
351
|
+
return refuse(409, 'Only a draft quote can be deleted.');
|
|
352
|
+
}
|
|
353
|
+
transaction.delete(ref);
|
|
354
|
+
return {
|
|
355
|
+
status: 200,
|
|
356
|
+
body: {
|
|
357
|
+
deleted: true
|
|
358
|
+
},
|
|
359
|
+
closeSession: order.checkoutSessionId
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
const { closeSession } = deleted, outcome = _object_without_properties_loose(deleted, [
|
|
363
|
+
"closeSession"
|
|
364
|
+
]);
|
|
365
|
+
await closeOpenSession(deps, closeSession, 'quote deleted');
|
|
366
|
+
return outcome;
|
|
367
|
+
}
|
|
368
|
+
if (action !== 'create' && action !== 'update') return refuse(400, 'Unknown action');
|
|
369
|
+
if (action === 'update' && !DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
|
|
370
|
+
const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200);
|
|
371
|
+
if (!idempotencyKey) return refuse(400, 'A quote save needs an Idempotency-Key header.');
|
|
372
|
+
const owner = await deps.orgForHost(hostId);
|
|
373
|
+
if (!checkEntitlement(owner == null ? void 0 : owner.org, 'commerce')) return refuse(403, 'Selling is not enabled');
|
|
374
|
+
const parsed = CommerceModel.parseQuoteInput(body['quote']);
|
|
375
|
+
if ('error' in parsed) return refuse(400, parsed.error);
|
|
376
|
+
const input = parsed.input;
|
|
377
|
+
const priced = await priceQuote(deps, hostRef, hostId, input);
|
|
378
|
+
if ('status' in priced) return priced;
|
|
379
|
+
const claimed = await claimAttempt(firestore, {
|
|
380
|
+
kind: action === 'create' ? 'commerce-quote' : 'commerce-quote-update',
|
|
381
|
+
scopeId: action === 'create' ? hostId : `${hostId}:${quoteId}`,
|
|
382
|
+
orgId: (_ref = owner == null ? void 0 : owner.id) != null ? _ref : '',
|
|
383
|
+
key: idempotencyKey,
|
|
384
|
+
busyMessage: 'This quote is already being saved'
|
|
385
|
+
});
|
|
386
|
+
if ('replay' in claimed) {
|
|
387
|
+
return {
|
|
388
|
+
status: claimed.replay.status,
|
|
389
|
+
body: claimed.replay.body
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const claim = claimed.claim;
|
|
393
|
+
const now = deps.now();
|
|
394
|
+
try {
|
|
395
|
+
let outcome;
|
|
396
|
+
if (action === 'create') {
|
|
397
|
+
const orderRef = hostRef.collection('orders').doc(createResourceUid());
|
|
398
|
+
const counterRef = hostRef.collection('counters').doc('orders');
|
|
399
|
+
let number = 0;
|
|
400
|
+
await firestore.runTransaction(async (transaction)=>{
|
|
401
|
+
var _counter_get, _input_requireProofApproval;
|
|
402
|
+
const counter = await transaction.get(counterRef);
|
|
403
|
+
number = Number((_counter_get = counter.get('next')) != null ? _counter_get : 1);
|
|
404
|
+
transaction.set(counterRef, {
|
|
405
|
+
next: number + 1
|
|
406
|
+
}, {
|
|
407
|
+
merge: true
|
|
408
|
+
});
|
|
409
|
+
const quote = _extends({
|
|
410
|
+
stage: 'draft',
|
|
411
|
+
revision: 1
|
|
412
|
+
}, editedQuoteFields(input), {
|
|
413
|
+
tax: priced.tax,
|
|
414
|
+
requireProofApproval: (_input_requireProofApproval = input.requireProofApproval) != null ? _input_requireProofApproval : priced.requireProofApprovalDefault,
|
|
415
|
+
createdAtMs: now,
|
|
416
|
+
updatedAtMs: now,
|
|
417
|
+
createdBy: actor,
|
|
418
|
+
updatedBy: actor
|
|
419
|
+
});
|
|
420
|
+
// No `createdAtMs`, list fields or customer fields at the top: an
|
|
421
|
+
// unpaid quote is not an order any list counts (`commerce-quotes.ts`).
|
|
422
|
+
transaction.set(orderRef, _extends({
|
|
423
|
+
number,
|
|
424
|
+
status: 'pending',
|
|
425
|
+
channel: 'quote',
|
|
426
|
+
lineItems: priced.lineItems,
|
|
427
|
+
totals: priced.totals
|
|
428
|
+
}, priced.taxMode ? {
|
|
429
|
+
taxMode: priced.taxMode
|
|
430
|
+
} : {}, {
|
|
431
|
+
timeline: [
|
|
432
|
+
{
|
|
433
|
+
atMs: now,
|
|
434
|
+
event: 'quote',
|
|
435
|
+
detail: `Quote created by ${actor}`
|
|
436
|
+
}
|
|
437
|
+
],
|
|
438
|
+
quote
|
|
439
|
+
}));
|
|
440
|
+
});
|
|
441
|
+
outcome = {
|
|
442
|
+
status: 200,
|
|
443
|
+
body: {
|
|
444
|
+
quoteId: orderRef.id,
|
|
445
|
+
number,
|
|
446
|
+
updatedAtMs: now
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
} else {
|
|
450
|
+
const baseUpdatedAtMs = Number(body['baseUpdatedAtMs']);
|
|
451
|
+
const orderRef = hostRef.collection('orders').doc(quoteId);
|
|
452
|
+
let closeSession;
|
|
453
|
+
outcome = await firestore.runTransaction(async (transaction)=>{
|
|
454
|
+
var _input_requireProofApproval, _existing_number;
|
|
455
|
+
closeSession = undefined;
|
|
456
|
+
const snapshot = await transaction.get(orderRef);
|
|
457
|
+
const existing = snapshot.exists ? snapshot.data() : null;
|
|
458
|
+
if (!existing || !CommerceModel.isQuote(existing) || !existing.quote) {
|
|
459
|
+
return refuse(404, 'Unknown quote');
|
|
460
|
+
}
|
|
461
|
+
if (!CommerceModel.isUnpaidQuote(existing)) {
|
|
462
|
+
return refuse(409, 'This quote has been paid, so it can no longer be changed.');
|
|
463
|
+
}
|
|
464
|
+
if (Number(existing.quote.updatedAtMs) !== baseUpdatedAtMs) {
|
|
465
|
+
return refuse(409, 'Someone else changed this quote. Close it and open it again to see their changes.');
|
|
466
|
+
}
|
|
467
|
+
// The tax mode is restated from this pricing, and dropped when it has
|
|
468
|
+
// none. The pay page's open session was priced from the quote before
|
|
469
|
+
// this edit, so it is closed and forgotten.
|
|
470
|
+
const rest = _extends({}, existing);
|
|
471
|
+
delete rest.taxMode;
|
|
472
|
+
delete rest.checkoutSessionId;
|
|
473
|
+
closeSession = existing.checkoutSessionId;
|
|
474
|
+
transaction.set(orderRef, _extends({}, rest, {
|
|
475
|
+
lineItems: priced.lineItems,
|
|
476
|
+
totals: priced.totals
|
|
477
|
+
}, priced.taxMode ? {
|
|
478
|
+
taxMode: priced.taxMode
|
|
479
|
+
} : {}, {
|
|
480
|
+
timeline: CommerceModel.appendOrderEvent(existing, 'quote-updated', `Quote edited by ${actor}`, now),
|
|
481
|
+
quote: _extends({}, existing.quote, editedQuoteFields(input), {
|
|
482
|
+
tax: priced.tax,
|
|
483
|
+
requireProofApproval: (_input_requireProofApproval = input.requireProofApproval) != null ? _input_requireProofApproval : existing.quote.requireProofApproval === true,
|
|
484
|
+
updatedAtMs: now,
|
|
485
|
+
updatedBy: actor
|
|
486
|
+
})
|
|
487
|
+
}));
|
|
488
|
+
return {
|
|
489
|
+
status: 200,
|
|
490
|
+
body: {
|
|
491
|
+
quoteId,
|
|
492
|
+
number: (_existing_number = existing.number) != null ? _existing_number : null,
|
|
493
|
+
updatedAtMs: now
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
if (outcome.status === 200) await closeOpenSession(deps, closeSession, 'quote edited');
|
|
498
|
+
}
|
|
499
|
+
if (outcome.status === 200) await claim.record(outcome.status, outcome.body);
|
|
500
|
+
else await claim.release();
|
|
501
|
+
return outcome;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error('commerce quote save failed', error);
|
|
504
|
+
await claim.release();
|
|
505
|
+
return refuse(500, 'The quote could not be saved');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** `POST /api/commerce/quote`. */ export const quoteHandler = async (req, res)=>{
|
|
509
|
+
const outcome = await handleQuote(defaultQuoteDeps(), req);
|
|
510
|
+
return res.status(outcome.status).json(outcome.body);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
//# sourceMappingURL=quote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote.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 { claimAttempt, type PluginApiHandler, type PluginApiRequest } from '@aglyn/aglyn/server'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport type { PluginTaxAddress } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport {\n searchPluginPeople,\n type PluginPersonRecord,\n type PluginPersonSearchRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-records'\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 {\n quoteSaleTaxWithEngine,\n type SaleTaxEngineRequest,\n type SaleTaxEngineResult,\n} from './tax-engine-quote'\nimport { expireCheckoutSession, type CheckoutSessionExpiry } from './checkout-session-expiry'\nimport { quotePayLinkUrl } from './quote-link-token'\n\n/*==========================================\n * QUOTES IN THE CONSOLE (AGL-4093): `POST /api/commerce/quote`.\n *\n * { action: 'create', hostId, quote } → { quoteId, number, updatedAtMs }\n * { action: 'update', hostId, quoteId, baseUpdatedAtMs, quote } → { quoteId, number, updatedAtMs }\n * { action: 'delete', hostId, quoteId } → { deleted: true }\n * { action: 'search-customers', hostId, text } → { available, customers }\n * { action: 'pay-link', hostId, quoteId } → { url, linkVersion }\n * { action: 'replace-link', hostId, quoteId } → { url, linkVersion }\n *\n * The first four ask \"Create and edit quotes\" (`commerce.quotes`); the pay\n * link's two ask \"Send quotes and invoices to customers\"\n * (`commerce.quotes.send`, AGL-4094). Create and update take an\n * `Idempotency-Key` header, one per attempt, so a retried save replays its\n * answer instead of writing a second quote or failing its own change check;\n * delete is idempotent by nature, and so are the link's two (a replace bumps\n * the version once per press, and a second press is another replacement).\n *\n * A quote's open Checkout Session (`checkoutSessionId`, opened by the pay\n * page) was priced from the quote as it stood. Editing the quote, deleting it\n * or replacing its link expires that session, so nobody pays a price or a\n * link the business has withdrawn.\n *\n * The server prices every product line from the product and works out the\n * tax; the console sends what was chosen, never what it costs. A quote is\n * written to `hosts/{hostId}/orders` as an unpaid order on the `quote`\n * channel, numbered from the store's order counter, and WITHOUT the fields\n * order lists read (see `commerce-quotes.ts`). No payment is created here.\n *=========================================*/\n\nexport interface QuoteDeps {\n verifyIdToken(token: string): Promise<{ uid: string; email?: string | null }>\n firestore(): FirebaseFirestore.Firestore\n /** The workspace that owns the site, for its plan. */\n orgForHost(hostId: string): Promise<{ id?: string; org?: unknown } | null>\n searchPeople(request: PluginPersonSearchRequest): Promise<PluginPersonRecord[] | null>\n /** The site's tax service, where one is connected; `{ quote: null }` otherwise. */\n quoteTax(request: SaleTaxEngineRequest): Promise<SaleTaxEngineResult>\n /** Closes a Checkout Session the pay page opened for the quote. */\n expireSession(sessionId: string): Promise<CheckoutSessionExpiry>\n /** The quote'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, quoteId: string, version: number): string | null\n now(): number\n}\n\nexport function defaultQuoteDeps(): QuoteDeps {\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 searchPeople: (request) => searchPluginPeople(request),\n quoteTax: (request) => quoteSaleTaxWithEngine(request),\n expireSession: (sessionId) => expireCheckoutSession(sessionId),\n payLinkUrl: (host, hostId, quoteId, version) => quotePayLinkUrl(host, hostId, quoteId, version),\n now: () => Date.now(),\n }\n}\n\n/** Expires the session the pay page opened, when there is one; a failure is logged, never thrown. */\nasync function closeOpenSession(deps: QuoteDeps, sessionId: string | undefined, context: string): Promise<void> {\n if (!sessionId) return\n const result = await deps.expireSession(sessionId).catch((): CheckoutSessionExpiry => 'open')\n if (result === 'open') {\n console.error(`${context}: the quote's Stripe session was not expired and may still be paid`, { sessionId })\n }\n}\n\ntype Outcome = { status: number; body: Record<string, unknown> }\n\nconst refuse = (status: number, error: string): Outcome => ({ status, body: { error } })\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 DOC_ID = /^(?!__.*__$)[^/]{1,200}$/\n\n/** The ship-to as a tax service reads it, or `null` to tax at the store's address. */\nfunction serviceAddress(shipTo: CommerceModel.OrderAddress | null): PluginTaxAddress | null {\n if (!shipTo?.country) return null\n return {\n country: shipTo.country,\n ...(shipTo.line1 ? { line1: shipTo.line1 } : {}),\n ...(shipTo.line2 ? { line2: shipTo.line2 } : {}),\n ...(shipTo.city ? { city: shipTo.city } : {}),\n ...(shipTo.state ? { region: shipTo.state } : {}),\n ...(shipTo.postalCode ? { postalCode: shipTo.postalCode } : {}),\n }\n}\n\ninterface PricedQuote {\n lineItems: CommerceModel.PricedQuoteLine[]\n totals: CommerceModel.OrderTotals\n tax: CommerceModel.QuoteTaxBasis\n taxMode: CommerceModel.StorefrontTaxMode | undefined\n requireProofApprovalDefault: boolean\n}\n\n/**\n * Prices a quote: each product line from its product, the tax from the\n * store's settings and, where the site connected one, its tax service.\n */\nasync function priceQuote(\n deps: QuoteDeps,\n hostRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n input: CommerceModel.QuoteInput,\n): Promise<PricedQuote | Outcome> {\n const productIds = [\n ...new Set(\n input.lines.flatMap((line) => (line.kind === 'catalog' ? [line.productId] : [])),\n ),\n ]\n const productDocs = await Promise.all(\n productIds.map((id) => hostRef.collection('products').doc(id).get()),\n )\n const products = new Map(\n productDocs.map((snapshot, index) => [\n productIds[index],\n snapshot.exists ? (snapshot.data() as Partial<CommerceModel.HostProduct>) : null,\n ]),\n )\n const lineItems: CommerceModel.PricedQuoteLine[] = []\n for (const line of input.lines) {\n if (line.kind === 'custom') {\n lineItems.push(CommerceModel.priceQuoteCustomLine(line))\n continue\n }\n const priced = CommerceModel.priceQuoteCatalogLine(products.get(line.productId), line)\n if ('error' in priced) return refuse(400, priced.error)\n lineItems.push(priced.line)\n }\n\n const store = await hostRef.collection('settings').doc('store').get()\n const taxSettings = (store.get('tax') ?? {}) as CommerceModel.TaxSettings\n let tax = CommerceModel.quoteTaxBasisFromSettings(taxSettings, input.shipTo)\n const shippingCents = input.shipping?.amountCents ?? 0\n const amounts = CommerceModel.quoteLineAmounts(lineItems, input.discount)\n // A tax service answers only for a store on its own rates; it is asked about\n // what each line is charged after its share of the discount.\n if (taxSettings.mode === 'manual' && amounts.itemsCents > 0) {\n const serviceLines = lineItems\n .map((line, index) => ({ line, index, amountCents: amounts.netCents[index] }))\n .filter((entry) => entry.amountCents > 0)\n const answer = await deps.quoteTax({\n hostId,\n settings: taxSettings,\n channel: 'invoice',\n lines: serviceLines.map(({ line, index, amountCents }) => ({\n id: String(index),\n ...(line.productId ? { productId: line.productId } : {}),\n ...(line.variantId ? { variantId: line.variantId } : {}),\n ...(line.sku ? { sku: line.sku } : {}),\n description: [line.name, line.variantLabel].filter(Boolean).join(' — '),\n quantity: line.quantity,\n amountCents,\n ...(line.taxExempt ? { exempt: true } : {}),\n })),\n ...(shippingCents ? { shippingCents } : {}),\n shipTo: serviceAddress(input.shipTo),\n customerEmail: input.customer.email ?? null,\n })\n if (answer.quote) {\n tax = {\n kind: 'service',\n taxCents: answer.quote.taxCents,\n providerLabel: answer.quote.providerLabel || answer.quote.provider,\n }\n }\n }\n const totals = CommerceModel.computeQuoteTotals({\n lines: lineItems,\n discount: input.discount,\n shippingCents,\n tax,\n })\n return {\n lineItems,\n totals: CommerceModel.quoteOrderTotals(totals),\n tax,\n taxMode: CommerceModel.quoteTaxMode(tax, totals.taxCents),\n requireProofApprovalDefault: CommerceModel.quoteRequiresProofApprovalByDefault(\n store.get('quotes') as CommerceModel.QuoteStoreSettings | undefined,\n ),\n }\n}\n\n/** The editable half of the `quote` section, every key written so a save clears what was removed. */\nfunction editedQuoteFields(input: CommerceModel.QuoteInput) {\n return {\n customer: input.customer,\n billTo: input.billTo,\n shipTo: input.shipTo,\n notes: input.notes,\n terms: input.terms,\n discount: input.discount,\n shipping: input.shipping,\n }\n}\n\nexport async function handleQuote(deps: QuoteDeps, 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 action = String(body['action'] ?? '')\n if (!DOC_ID.test(hostId)) return refuse(400, 'Missing hostId')\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 host = await hostRef.get()\n if (!host.exists) return refuse(404, 'Unknown site')\n const key = action === 'pay-link' || action === 'replace-link' ? 'commerce.quotes.send' : 'commerce.quotes'\n if (!holdsCommerceSiteKey(host, decoded.uid, key)) {\n return refuse(403, commerceSitePermissionRefusal(key))\n }\n const actor = String(decoded.email || decoded.uid)\n\n if (action === 'pay-link' || action === 'replace-link') {\n const quoteId = String(body['quoteId'] ?? '')\n if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId')\n const ref = hostRef.collection('orders').doc(quoteId)\n const now = deps.now()\n const answer = await firestore.runTransaction(\n async (transaction): Promise<{ outcome: Outcome; closeSession?: string }> => {\n const snapshot = await transaction.get(ref)\n const order = snapshot.exists ? (snapshot.data() as CommerceModel.HostOrder) : null\n if (!order || !CommerceModel.isQuote(order) || !order.quote) return { outcome: refuse(404, 'Unknown quote') }\n const version = CommerceModel.quoteLinkVersion(order.quote)\n if (action === 'pay-link') return { outcome: { status: 200, body: { linkVersion: version } } }\n if (!CommerceModel.isUnpaidQuote(order)) {\n return { outcome: refuse(409, 'This quote has been paid, so its link can no longer be replaced.') }\n }\n const next = version + 1\n // The open session goes with the link it was opened from.\n const rest: Partial<CommerceModel.HostOrder> = { ...order }\n delete rest.checkoutSessionId\n transaction.set(ref, {\n ...rest,\n timeline: CommerceModel.appendOrderEvent(\n order,\n 'quote-link-replaced',\n `Pay link replaced by ${actor}; the earlier link no longer opens the quote`,\n now,\n ),\n quote: { ...order.quote, linkVersion: next },\n })\n return { outcome: { status: 200, body: { linkVersion: next } }, closeSession: order.checkoutSessionId }\n },\n )\n if (answer.closeSession) await closeOpenSession(deps, answer.closeSession, 'quote link replaced')\n if (answer.outcome.status !== 200) return answer.outcome\n const linkVersion = Number(answer.outcome.body['linkVersion'])\n const url = deps.payLinkUrl((host.data() ?? {}) as Record<string, unknown>, hostId, quoteId, linkVersion)\n if (!url) return refuse(409, 'This site has no web address yet, so its quotes have no pay link. Publish the site first.')\n return { status: 200, body: { url, linkVersion } }\n }\n\n if (action === 'search-customers') {\n const text = String(body['text'] ?? '').trim().slice(0, 120)\n if (text.length < 2) return { status: 200, body: { available: true, customers: [] } }\n const owner = await deps.orgForHost(hostId)\n const people = await deps.searchPeople({ hostId, orgId: owner?.id || null, text, limit: 10 })\n if (people === null) return { status: 200, body: { available: false, customers: [] } }\n return {\n status: 200,\n body: {\n available: true,\n customers: people.map((person) => {\n const data = person.data ?? {}\n const read = (key: string) => (typeof data[key] === 'string' ? String(data[key]).trim() : '')\n return {\n record: { kind: person.kind, id: person.id },\n name: read('name'),\n company: read('company'),\n email: person.email,\n phone: read('phone') || null,\n }\n }),\n },\n }\n }\n\n const quoteId = String(body['quoteId'] ?? '')\n if (action === 'delete') {\n if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId')\n const ref = hostRef.collection('orders').doc(quoteId)\n const deleted = await firestore.runTransaction(async (transaction): Promise<Outcome & { closeSession?: string }> => {\n const snapshot = await transaction.get(ref)\n // Already gone is what a retried delete finds.\n if (!snapshot.exists) return { status: 200, body: { deleted: true } }\n const order = snapshot.data() as CommerceModel.HostOrder\n if (!CommerceModel.isQuote(order)) return refuse(404, 'Unknown quote')\n if (!CommerceModel.isUnpaidQuote(order)) {\n return refuse(409, 'This quote has been paid, so it is an order now and stays on record.')\n }\n if ((order.quote?.stage ?? 'draft') !== 'draft') {\n return refuse(409, 'Only a draft quote can be deleted.')\n }\n transaction.delete(ref)\n return { status: 200, body: { deleted: true }, closeSession: order.checkoutSessionId }\n })\n const { closeSession, ...outcome } = deleted as Outcome & { closeSession?: string }\n await closeOpenSession(deps, closeSession, 'quote deleted')\n return outcome\n }\n\n if (action !== 'create' && action !== 'update') return refuse(400, 'Unknown action')\n if (action === 'update' && !DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId')\n const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200)\n if (!idempotencyKey) return refuse(400, 'A quote save needs an Idempotency-Key header.')\n\n const owner = await deps.orgForHost(hostId)\n if (!checkEntitlement(owner?.org as never, 'commerce')) return refuse(403, 'Selling is not enabled')\n\n const parsed = CommerceModel.parseQuoteInput(body['quote'])\n if ('error' in parsed) return refuse(400, parsed.error)\n const input = parsed.input\n const priced = await priceQuote(deps, hostRef, hostId, input)\n if ('status' in priced) return priced\n\n const claimed = await claimAttempt(firestore, {\n kind: action === 'create' ? 'commerce-quote' : 'commerce-quote-update',\n scopeId: action === 'create' ? hostId : `${hostId}:${quoteId}`,\n orgId: owner?.id ?? '',\n key: idempotencyKey,\n busyMessage: 'This quote is already being saved',\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 try {\n let outcome: Outcome\n if (action === 'create') {\n const orderRef = hostRef.collection('orders').doc(createResourceUid())\n const counterRef = hostRef.collection('counters').doc('orders')\n let number = 0\n await firestore.runTransaction(async (transaction) => {\n const counter = await transaction.get(counterRef)\n number = Number(counter.get('next') ?? 1)\n transaction.set(counterRef, { next: number + 1 }, { merge: true })\n const quote: CommerceModel.OrderQuote = {\n stage: 'draft',\n revision: 1,\n ...editedQuoteFields(input),\n tax: priced.tax,\n requireProofApproval: input.requireProofApproval ?? priced.requireProofApprovalDefault,\n createdAtMs: now,\n updatedAtMs: now,\n createdBy: actor,\n updatedBy: actor,\n }\n // No `createdAtMs`, list fields or customer fields at the top: an\n // unpaid quote is not an order any list counts (`commerce-quotes.ts`).\n transaction.set(orderRef, {\n number,\n status: 'pending',\n channel: 'quote',\n lineItems: priced.lineItems,\n totals: priced.totals,\n ...(priced.taxMode ? { taxMode: priced.taxMode } : {}),\n timeline: [{ atMs: now, event: 'quote', detail: `Quote created by ${actor}` }],\n quote,\n })\n })\n outcome = { status: 200, body: { quoteId: orderRef.id, number, updatedAtMs: now } }\n } else {\n const baseUpdatedAtMs = Number(body['baseUpdatedAtMs'])\n const orderRef = hostRef.collection('orders').doc(quoteId)\n let closeSession: string | undefined\n outcome = await firestore.runTransaction(async (transaction): Promise<Outcome> => {\n closeSession = undefined\n const snapshot = await transaction.get(orderRef)\n const existing = snapshot.exists ? (snapshot.data() as CommerceModel.HostOrder) : null\n if (!existing || !CommerceModel.isQuote(existing) || !existing.quote) {\n return refuse(404, 'Unknown quote')\n }\n if (!CommerceModel.isUnpaidQuote(existing)) {\n return refuse(409, 'This quote has been paid, so it can no longer be changed.')\n }\n if (Number(existing.quote.updatedAtMs) !== baseUpdatedAtMs) {\n return refuse(409, 'Someone else changed this quote. Close it and open it again to see their changes.')\n }\n // The tax mode is restated from this pricing, and dropped when it has\n // none. The pay page's open session was priced from the quote before\n // this edit, so it is closed and forgotten.\n const rest: Partial<CommerceModel.HostOrder> = { ...existing }\n delete rest.taxMode\n delete rest.checkoutSessionId\n closeSession = existing.checkoutSessionId\n transaction.set(orderRef, {\n ...rest,\n lineItems: priced.lineItems,\n totals: priced.totals,\n ...(priced.taxMode ? { taxMode: priced.taxMode } : {}),\n timeline: CommerceModel.appendOrderEvent(existing, 'quote-updated', `Quote edited by ${actor}`, now),\n quote: {\n ...existing.quote,\n ...editedQuoteFields(input),\n tax: priced.tax,\n requireProofApproval:\n input.requireProofApproval ?? existing.quote.requireProofApproval === true,\n updatedAtMs: now,\n updatedBy: actor,\n },\n })\n return { status: 200, body: { quoteId, number: existing.number ?? null, updatedAtMs: now } }\n })\n if (outcome.status === 200) await closeOpenSession(deps, closeSession, 'quote edited')\n }\n if (outcome.status === 200) await claim.record(outcome.status, outcome.body)\n else await claim.release()\n return outcome\n } catch (error) {\n console.error('commerce quote save failed', error)\n await claim.release()\n return refuse(500, 'The quote could not be saved')\n }\n}\n\n/** `POST /api/commerce/quote`. */\nexport const quoteHandler: PluginApiHandler = async (req, res) => {\n const outcome = await handleQuote(defaultQuoteDeps(), req)\n return res.status(outcome.status).json(outcome.body)\n}\n"],"names":["claimAttempt","createResourceUid","checkEntitlement","searchPluginPeople","firebaseAdmin","getOrgForHost","isRefusedIdToken","CommerceModel","commerceSitePermissionRefusal","holdsCommerceSiteKey","quoteSaleTaxWithEngine","expireCheckoutSession","quotePayLinkUrl","defaultQuoteDeps","verifyIdToken","token","app","auth","firestore","orgForHost","hostId","found","id","orgId","org","searchPeople","request","quoteTax","expireSession","sessionId","payLinkUrl","host","quoteId","version","now","Date","closeOpenSession","deps","context","result","catch","console","error","refuse","status","body","bodyOf","req","raw","parsed","JSON","parse","headerOf","name","headers","value","toLowerCase","String","Array","isArray","trim","DOC_ID","serviceAddress","shipTo","country","line1","line2","city","state","region","postalCode","priceQuote","hostRef","input","store","productIds","Set","lines","flatMap","line","kind","productId","productDocs","Promise","all","map","collection","doc","get","products","Map","snapshot","index","exists","data","lineItems","push","priceQuoteCustomLine","priced","priceQuoteCatalogLine","taxSettings","tax","quoteTaxBasisFromSettings","shippingCents","shipping","amountCents","amounts","quoteLineAmounts","discount","mode","itemsCents","serviceLines","netCents","filter","entry","answer","settings","channel","variantId","sku","description","variantLabel","Boolean","join","quantity","taxExempt","exempt","customerEmail","customer","email","quote","taxCents","providerLabel","provider","totals","computeQuoteTotals","quoteOrderTotals","taxMode","quoteTaxMode","requireProofApprovalDefault","quoteRequiresProofApprovalByDefault","editedQuoteFields","billTo","notes","terms","handleQuote","method","authorization","idToken","startsWith","slice","length","action","test","decoded","key","uid","actor","ref","runTransaction","transaction","order","isQuote","outcome","quoteLinkVersion","linkVersion","isUnpaidQuote","next","rest","checkoutSessionId","set","timeline","appendOrderEvent","closeSession","Number","url","text","available","customers","owner","people","limit","person","read","record","company","phone","deleted","stage","delete","idempotencyKey","parseQuoteInput","claimed","scopeId","busyMessage","replay","claim","orderRef","counterRef","number","counter","merge","revision","requireProofApproval","createdAtMs","updatedAtMs","createdBy","updatedBy","atMs","event","detail","baseUpdatedAtMs","existing","undefined","release","quoteHandler","res","json"],"mappings":"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"}
|
|
@@ -61,6 +61,8 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
|
61
61
|
const counts = new Map();
|
|
62
62
|
for (const docSnapshot of orders.docs){
|
|
63
63
|
var _order_lineItems;
|
|
64
|
+
// A quote nobody has paid is not something bought together (AGL-4093).
|
|
65
|
+
if (CommerceModel.isUnpaidQuote(docSnapshot.data())) continue;
|
|
64
66
|
const order = CommerceModel.liftLegacyOrder(docSnapshot.data());
|
|
65
67
|
const lineIds = ((_order_lineItems = order.lineItems) != null ? _order_lineItems : []).map((line)=>line.productId);
|
|
66
68
|
if (!lineIds.includes(productId)) continue;
|