@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
package/src/lib/server/quote.js
CHANGED
|
@@ -19,6 +19,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
19
19
|
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
20
20
|
import { checkEntitlement } from "@aglyn/aglyn/server";
|
|
21
21
|
import { searchPluginPeople } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
|
|
22
|
+
import { pluginRecordFactsReader } from "@aglyn/aglyn/plugin-manager/plugin-record-facts";
|
|
22
23
|
import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
23
24
|
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
24
25
|
import * as CommerceModel from "../model/index.js";
|
|
@@ -27,7 +28,7 @@ import { holdsCommerceSiteKey } from "./commerce-site-keys.js";
|
|
|
27
28
|
import { quoteSaleTaxWithEngine } from "./tax-engine-quote.js";
|
|
28
29
|
import { expireCheckoutSession } from "./checkout-session-expiry.js";
|
|
29
30
|
import { sendPdf } from "./pdf-response.js";
|
|
30
|
-
import { quotePayLinkUrl } from "./quote-link-token.js";
|
|
31
|
+
import { quotePayLinkUrl, quoteProofLinkUrl } from "./quote-link-token.js";
|
|
31
32
|
export function defaultQuoteDeps() {
|
|
32
33
|
return {
|
|
33
34
|
verifyIdToken: (token)=>firebaseAdmin.app().auth().verifyIdToken(token),
|
|
@@ -43,11 +44,47 @@ export function defaultQuoteDeps() {
|
|
|
43
44
|
quoteTax: (request)=>quoteSaleTaxWithEngine(request),
|
|
44
45
|
expireSession: (sessionId)=>expireCheckoutSession(sessionId),
|
|
45
46
|
payLinkUrl: (host, hostId, quoteId, version)=>quotePayLinkUrl(host, hostId, quoteId, version),
|
|
47
|
+
proofLinkUrl: (host, hostId, quoteId, version, proofId)=>quoteProofLinkUrl(host, hostId, quoteId, version, proofId),
|
|
46
48
|
quotePdf: async (input)=>(await import("./commerce-pdf.js")).quotePdfFile(input),
|
|
47
49
|
uploads: async ()=>(await import("./quote-uploads.js")).quoteUploadCalls(),
|
|
50
|
+
mailer: async ()=>(await import("./quote-email.js")).quoteMailer(),
|
|
51
|
+
requestFacts: async (request)=>{
|
|
52
|
+
var _ref;
|
|
53
|
+
var _pluginRecordFactsReader;
|
|
54
|
+
return (_ref = await ((_pluginRecordFactsReader = pluginRecordFactsReader(CommerceModel.QUOTE_REQUEST_FACTS_RESOURCE)) == null ? void 0 : _pluginRecordFactsReader.reader.read(request))) != null ? _ref : null;
|
|
55
|
+
},
|
|
48
56
|
now: ()=>Date.now()
|
|
49
57
|
};
|
|
50
58
|
}
|
|
59
|
+
/** A request's facts for the member asking, or the refusal to answer with. */ async function readRequest(deps, input) {
|
|
60
|
+
var _ref, _ref1;
|
|
61
|
+
if (!DOC_ID.test(input.submissionId)) return {
|
|
62
|
+
outcome: refuse(400, 'Missing submissionId')
|
|
63
|
+
};
|
|
64
|
+
const owner = await deps.orgForHost(input.hostId);
|
|
65
|
+
const read = await deps.requestFacts({
|
|
66
|
+
orgId: (_ref = owner == null ? void 0 : owner.id) != null ? _ref : '',
|
|
67
|
+
hostId: input.hostId,
|
|
68
|
+
id: input.submissionId,
|
|
69
|
+
uid: input.uid,
|
|
70
|
+
staff: input.staff,
|
|
71
|
+
org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
|
|
72
|
+
now: new Date(deps.now())
|
|
73
|
+
});
|
|
74
|
+
if (!read) return {
|
|
75
|
+
outcome: refuse(404, 'Requests aren’t available on this site.')
|
|
76
|
+
};
|
|
77
|
+
if ('error' in read) return {
|
|
78
|
+
outcome: refuse(read.status, read.error)
|
|
79
|
+
};
|
|
80
|
+
const facts = CommerceModel.readQuoteRequestFacts(read.facts);
|
|
81
|
+
if (!facts) return {
|
|
82
|
+
outcome: refuse(404, 'That request is no longer in the Inbox.')
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
facts
|
|
86
|
+
};
|
|
87
|
+
}
|
|
51
88
|
/** Expires the session the pay page opened, when there is one; a failure is logged, never thrown. */ async function closeOpenSession(deps, sessionId, context) {
|
|
52
89
|
if (!sessionId) return;
|
|
53
90
|
const result = await deps.expireSession(sessionId).catch(()=>'open');
|
|
@@ -82,6 +119,11 @@ function headerOf(req, name) {
|
|
|
82
119
|
return String(Array.isArray(value) ? (_value_ = value[0]) != null ? _value_ : '' : value != null ? value : '').trim();
|
|
83
120
|
}
|
|
84
121
|
const DOC_ID = /^(?!__.*__$)[^/]{1,200}$/;
|
|
122
|
+
/** The actions under "Send quotes and invoices to customers" (`commerce.quotes.send`). */ const SEND_ACTIONS = new Set([
|
|
123
|
+
'pay-link',
|
|
124
|
+
'replace-link',
|
|
125
|
+
'send'
|
|
126
|
+
]);
|
|
85
127
|
/** The ship-to as a tax service reads it, or `null` to tax at the store's address. */ function serviceAddress(shipTo) {
|
|
86
128
|
if (!(shipTo == null ? void 0 : shipTo.country)) return null;
|
|
87
129
|
return _extends({
|
|
@@ -200,7 +242,7 @@ const DOC_ID = /^(?!__.*__$)[^/]{1,200}$/;
|
|
|
200
242
|
};
|
|
201
243
|
}
|
|
202
244
|
export async function handleQuote(deps, req) {
|
|
203
|
-
var _body_hostId, _body_action, _body_quoteId, _ref;
|
|
245
|
+
var _body_hostId, _body_action, _body_quoteId, _body_from, _ref;
|
|
204
246
|
if (req.method !== 'POST') return refuse(405, 'Method not allowed');
|
|
205
247
|
const authorization = headerOf(req, 'authorization');
|
|
206
248
|
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
|
|
@@ -220,7 +262,7 @@ export async function handleQuote(deps, req) {
|
|
|
220
262
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
221
263
|
const host = await hostRef.get();
|
|
222
264
|
if (!host.exists) return refuse(404, 'Unknown site');
|
|
223
|
-
const key = action
|
|
265
|
+
const key = SEND_ACTIONS.has(action) ? 'commerce.quotes.send' : 'commerce.quotes';
|
|
224
266
|
if (!holdsCommerceSiteKey(host, decoded.uid, key)) {
|
|
225
267
|
return refuse(403, commerceSitePermissionRefusal(key));
|
|
226
268
|
}
|
|
@@ -284,10 +326,46 @@ export async function handleQuote(deps, req) {
|
|
|
284
326
|
}
|
|
285
327
|
};
|
|
286
328
|
}
|
|
287
|
-
if (
|
|
288
|
-
var _body_quoteId2;
|
|
329
|
+
if (action === 'send') {
|
|
330
|
+
var _body_quoteId2, _host_data1;
|
|
289
331
|
const quoteId = String((_body_quoteId2 = body['quoteId']) != null ? _body_quoteId2 : '');
|
|
290
332
|
if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
|
|
333
|
+
return handleSendAction(deps, {
|
|
334
|
+
req,
|
|
335
|
+
body,
|
|
336
|
+
hostId,
|
|
337
|
+
quoteId,
|
|
338
|
+
hostRef,
|
|
339
|
+
host: (_host_data1 = host.data()) != null ? _host_data1 : {},
|
|
340
|
+
firestore,
|
|
341
|
+
actor,
|
|
342
|
+
senderEmail: decoded.email ? String(decoded.email) : null
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (action === 'start-from-request') {
|
|
346
|
+
var _body_submissionId;
|
|
347
|
+
const read = await readRequest(deps, {
|
|
348
|
+
hostId,
|
|
349
|
+
submissionId: String((_body_submissionId = body['submissionId']) != null ? _body_submissionId : ''),
|
|
350
|
+
uid: decoded.uid,
|
|
351
|
+
staff: decoded.staff === true
|
|
352
|
+
});
|
|
353
|
+
if ('outcome' in read) return read.outcome;
|
|
354
|
+
const started = CommerceModel.quoteStartFromRequest(String(body['submissionId']), read.facts);
|
|
355
|
+
return {
|
|
356
|
+
status: 200,
|
|
357
|
+
body: {
|
|
358
|
+
prefill: started.prefill,
|
|
359
|
+
request: started.request,
|
|
360
|
+
files: CommerceModel.quoteRequestFileViews(started.files),
|
|
361
|
+
threaded: Boolean(started.emailThread)
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (PROOF_ACTIONS.has(action)) {
|
|
366
|
+
var _body_quoteId3;
|
|
367
|
+
const quoteId = String((_body_quoteId3 = body['quoteId']) != null ? _body_quoteId3 : '');
|
|
368
|
+
if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
|
|
291
369
|
return handleProofAction(deps, {
|
|
292
370
|
action,
|
|
293
371
|
body,
|
|
@@ -300,8 +378,8 @@ export async function handleQuote(deps, req) {
|
|
|
300
378
|
});
|
|
301
379
|
}
|
|
302
380
|
if (action === 'pdf') {
|
|
303
|
-
var
|
|
304
|
-
const quoteId = String((
|
|
381
|
+
var _body_quoteId4, _host_data2;
|
|
382
|
+
const quoteId = String((_body_quoteId4 = body['quoteId']) != null ? _body_quoteId4 : '');
|
|
305
383
|
if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
|
|
306
384
|
const [snapshot, store] = await Promise.all([
|
|
307
385
|
hostRef.collection('orders').doc(quoteId).get(),
|
|
@@ -309,7 +387,7 @@ export async function handleQuote(deps, req) {
|
|
|
309
387
|
]);
|
|
310
388
|
const order = snapshot.exists ? snapshot.data() : null;
|
|
311
389
|
if (!order || !CommerceModel.isQuote(order) || !order.quote) return refuse(404, 'Unknown quote');
|
|
312
|
-
const hostData = (
|
|
390
|
+
const hostData = (_host_data2 = host.data()) != null ? _host_data2 : {};
|
|
313
391
|
// The pay link is "Send quotes and invoices to customers"; without it the PDF leaves the link off.
|
|
314
392
|
const payUrl = CommerceModel.isUnpaidQuote(order) && holdsCommerceSiteKey(host, decoded.uid, 'commerce.quotes.send') ? deps.payLinkUrl(hostData, hostId, quoteId, CommerceModel.quoteLinkVersion(order.quote)) : null;
|
|
315
393
|
try {
|
|
@@ -432,6 +510,24 @@ export async function handleQuote(deps, req) {
|
|
|
432
510
|
const parsed = CommerceModel.parseQuoteInput(body['quote']);
|
|
433
511
|
if ('error' in parsed) return refuse(400, parsed.error);
|
|
434
512
|
const input = parsed.input;
|
|
513
|
+
/*
|
|
514
|
+
* A new quote from a request (AGL-4098): the request is read again, here,
|
|
515
|
+
* for the member saving, and what the quote takes from it — which request,
|
|
516
|
+
* its files, its conversation — comes from that read and never from the
|
|
517
|
+
* body. The editable fields (customer, lines, notes) are the editor's.
|
|
518
|
+
*/ const from = (_body_from = body['from']) != null ? _body_from : null;
|
|
519
|
+
let started = null;
|
|
520
|
+
if (action === 'create' && (from == null ? void 0 : from.kind) === 'submission') {
|
|
521
|
+
var _from_id;
|
|
522
|
+
const read = await readRequest(deps, {
|
|
523
|
+
hostId,
|
|
524
|
+
submissionId: String((_from_id = from.id) != null ? _from_id : ''),
|
|
525
|
+
uid: decoded.uid,
|
|
526
|
+
staff: decoded.staff === true
|
|
527
|
+
});
|
|
528
|
+
if ('outcome' in read) return read.outcome;
|
|
529
|
+
started = CommerceModel.quoteStartFromRequest(String(from.id), read.facts);
|
|
530
|
+
}
|
|
435
531
|
const priced = await priceQuote(deps, hostRef, hostId, input);
|
|
436
532
|
if ('status' in priced) return priced;
|
|
437
533
|
const claimed = await claimAttempt(firestore, {
|
|
@@ -469,7 +565,14 @@ export async function handleQuote(deps, req) {
|
|
|
469
565
|
revision: 1
|
|
470
566
|
}, editedQuoteFields(input), {
|
|
471
567
|
tax: priced.tax,
|
|
472
|
-
requireProofApproval: (_input_requireProofApproval = input.requireProofApproval) != null ? _input_requireProofApproval : priced.requireProofApprovalDefault
|
|
568
|
+
requireProofApproval: (_input_requireProofApproval = input.requireProofApproval) != null ? _input_requireProofApproval : priced.requireProofApprovalDefault
|
|
569
|
+
}, started ? _extends({
|
|
570
|
+
request: started.request
|
|
571
|
+
}, started.files.length ? {
|
|
572
|
+
requestFiles: started.files
|
|
573
|
+
} : {}, started.emailThread ? {
|
|
574
|
+
emailThread: started.emailThread
|
|
575
|
+
} : {}) : {}, {
|
|
473
576
|
createdAtMs: now,
|
|
474
577
|
updatedAtMs: now,
|
|
475
578
|
createdBy: actor,
|
|
@@ -490,7 +593,7 @@ export async function handleQuote(deps, req) {
|
|
|
490
593
|
{
|
|
491
594
|
atMs: now,
|
|
492
595
|
event: 'quote',
|
|
493
|
-
detail: `Quote created by ${actor}`
|
|
596
|
+
detail: started ? `Quote created by ${actor} from a request sent through “${started.request.formName}”` : `Quote created by ${actor}`
|
|
494
597
|
}
|
|
495
598
|
],
|
|
496
599
|
quote
|
|
@@ -625,7 +728,7 @@ async function handleProofAction(deps, context) {
|
|
|
625
728
|
purposeId: quoteId
|
|
626
729
|
};
|
|
627
730
|
if (action === 'revisions' || action === 'file') {
|
|
628
|
-
var _order_quote_revision, _body_fileId, _order_quote_responses, _ref, _ref1;
|
|
731
|
+
var _order_quote_revision, _body_fileId, _order_quote_responses, _ref, _ref1, _order_quote_requestFiles;
|
|
629
732
|
const snapshot = await ref.get();
|
|
630
733
|
const order = snapshot.exists ? snapshot.data() : null;
|
|
631
734
|
if (!order || !CommerceModel.isQuote(order) || !order.quote) return refuse(404, 'Unknown quote');
|
|
@@ -652,7 +755,9 @@ async function handleProofAction(deps, context) {
|
|
|
652
755
|
...((_ref1 = source == null ? void 0 : source.responses) != null ? _ref1 : []).flatMap((response)=>{
|
|
653
756
|
var _response_files;
|
|
654
757
|
return (_response_files = response.files) != null ? _response_files : [];
|
|
655
|
-
})
|
|
758
|
+
}),
|
|
759
|
+
// The request's files (AGL-4098), on every revision: they came first.
|
|
760
|
+
...(_order_quote_requestFiles = order.quote.requestFiles) != null ? _order_quote_requestFiles : []
|
|
656
761
|
];
|
|
657
762
|
const file = files.find((entry)=>entry.id === fileId);
|
|
658
763
|
if (!(file == null ? void 0 : file.path)) return refuse(404, 'That file is no longer on this quote.');
|
|
@@ -832,6 +937,198 @@ async function handleProofAction(deps, context) {
|
|
|
832
937
|
await closeOpenSession(deps, answer.closeSession, 'quote revision started');
|
|
833
938
|
return answer.outcome;
|
|
834
939
|
}
|
|
940
|
+
/*==========================================
|
|
941
|
+
* SENDING THE QUOTE (AGL-4097)
|
|
942
|
+
*
|
|
943
|
+
* { action: 'send', hostId, quoteId, to?, subject?, message } + Idempotency-Key
|
|
944
|
+
*
|
|
945
|
+
* Emails the quote to `to` (else the customer's email) under `subject` (else
|
|
946
|
+
* the conversation's, else "Quote #N from {business}"), with the business's
|
|
947
|
+
* message, the proof, the PDF and the pay link. Refused for a paid quote, one
|
|
948
|
+
* with nowhere to send it, and an address on a suppression list.
|
|
949
|
+
*
|
|
950
|
+
* When the customer was emailed an earlier revision and the link has not
|
|
951
|
+
* been replaced since, the link is replaced first (as Replace link does: the
|
|
952
|
+
* version moves on, the open payment closes) so the earlier email's link
|
|
953
|
+
* says the quote was updated. A resend of the same revision keeps it. Then
|
|
954
|
+
* the send is recorded (`quote.sends`), the conversation moves on
|
|
955
|
+
* (`quote.emailThread`), the quote moves to Sent unless the customer has
|
|
956
|
+
* answered this revision, and the timeline says so.
|
|
957
|
+
*
|
|
958
|
+
* One attempt per key: a retried press replays the answer and never sends a
|
|
959
|
+
* second email. A send that fails releases the key, and a retry finds the
|
|
960
|
+
* link already replaced, so it never replaces it twice.
|
|
961
|
+
*=========================================*/ async function handleSendAction(deps, context) {
|
|
962
|
+
var _ref, _ref1;
|
|
963
|
+
var _order_quote;
|
|
964
|
+
const { req, body, hostId, quoteId, hostRef, host, firestore, actor } = context;
|
|
965
|
+
const parsed = CommerceModel.parseQuoteSendInput(body);
|
|
966
|
+
if ('error' in parsed) return refuse(400, parsed.error);
|
|
967
|
+
const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200);
|
|
968
|
+
if (!idempotencyKey) return refuse(400, 'Sending a quote needs an Idempotency-Key header.');
|
|
969
|
+
const ref = hostRef.collection('orders').doc(quoteId);
|
|
970
|
+
const [snapshot, store] = await Promise.all([
|
|
971
|
+
ref.get(),
|
|
972
|
+
hostRef.collection('settings').doc('store').get()
|
|
973
|
+
]);
|
|
974
|
+
const stored = snapshot.exists ? snapshot.data() : null;
|
|
975
|
+
const to = CommerceModel.quoteSendRecipient(stored, parsed.input.to);
|
|
976
|
+
const refused = CommerceModel.quoteSendRefusal(stored, to);
|
|
977
|
+
if (refused) {
|
|
978
|
+
return refuse(refused === 'not-a-quote' ? 404 : refused === 'paid' ? 409 : 400, CommerceModel.quoteSendRefusalMessage(refused));
|
|
979
|
+
}
|
|
980
|
+
if (!deps.payLinkUrl(host, hostId, quoteId, CommerceModel.quoteLinkVersion(stored == null ? void 0 : stored.quote))) {
|
|
981
|
+
return refuse(409, 'This site has no web address yet, so its quotes have no pay link. Publish the site first.');
|
|
982
|
+
}
|
|
983
|
+
const owner = await deps.orgForHost(hostId);
|
|
984
|
+
if (!checkEntitlement(owner == null ? void 0 : owner.org, 'commerce')) return refuse(403, 'Selling is not enabled');
|
|
985
|
+
const mailer = await deps.mailer();
|
|
986
|
+
if (!mailer.configured()) {
|
|
987
|
+
return refuse(503, 'Email isn’t set up here, so quotes can’t be emailed. Copy the pay link and send it yourself.');
|
|
988
|
+
}
|
|
989
|
+
// The bounce and complaint list only (AGL-4098): the customer asked for the
|
|
990
|
+
// quote, so unsubscribing from the site's newsletter does not refuse it.
|
|
991
|
+
if (await mailer.suppression(to)) {
|
|
992
|
+
return refuse(409, `${to} bounced or reported an email as spam, so it can’t be emailed. Copy the pay link and send it another way.`);
|
|
993
|
+
}
|
|
994
|
+
const claimed = await claimAttempt(firestore, {
|
|
995
|
+
kind: 'commerce-quote-send',
|
|
996
|
+
scopeId: `${hostId}:${quoteId}`,
|
|
997
|
+
orgId: (_ref = owner == null ? void 0 : owner.id) != null ? _ref : '',
|
|
998
|
+
key: idempotencyKey,
|
|
999
|
+
busyMessage: 'This quote is already being sent'
|
|
1000
|
+
});
|
|
1001
|
+
if ('replay' in claimed) {
|
|
1002
|
+
return {
|
|
1003
|
+
status: claimed.replay.status,
|
|
1004
|
+
body: claimed.replay.body
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
const claim = claimed.claim;
|
|
1008
|
+
const now = deps.now();
|
|
1009
|
+
let prepared;
|
|
1010
|
+
try {
|
|
1011
|
+
// A new revision's first email replaces the link the customer was sent.
|
|
1012
|
+
prepared = await firestore.runTransaction(async (transaction)=>{
|
|
1013
|
+
var _order_quote_revision;
|
|
1014
|
+
const current = await transaction.get(ref);
|
|
1015
|
+
const order = current.exists ? current.data() : null;
|
|
1016
|
+
const again = CommerceModel.quoteSendRefusal(order, to);
|
|
1017
|
+
if (again || !(order == null ? void 0 : order.quote)) {
|
|
1018
|
+
const reason = again != null ? again : 'not-a-quote';
|
|
1019
|
+
return {
|
|
1020
|
+
outcome: refuse(reason === 'not-a-quote' ? 404 : 409, CommerceModel.quoteSendRefusalMessage(reason))
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const version = CommerceModel.quoteLinkVersion(order.quote);
|
|
1024
|
+
if (!CommerceModel.quoteSendReplacesLink(order.quote)) return {
|
|
1025
|
+
order,
|
|
1026
|
+
linkVersion: version,
|
|
1027
|
+
linkReplaced: false
|
|
1028
|
+
};
|
|
1029
|
+
const rest = _extends({}, order);
|
|
1030
|
+
delete rest.checkoutSessionId;
|
|
1031
|
+
const replaced = _extends({}, rest, {
|
|
1032
|
+
timeline: CommerceModel.appendOrderEvent(order, 'quote-link-replaced', `Pay link replaced to send revision ${Number((_order_quote_revision = order.quote.revision) != null ? _order_quote_revision : 1)}; the earlier email's link no longer opens the quote`, now),
|
|
1033
|
+
quote: _extends({}, order.quote, {
|
|
1034
|
+
linkVersion: version + 1
|
|
1035
|
+
})
|
|
1036
|
+
});
|
|
1037
|
+
transaction.set(ref, replaced);
|
|
1038
|
+
return {
|
|
1039
|
+
order: replaced,
|
|
1040
|
+
linkVersion: version + 1,
|
|
1041
|
+
linkReplaced: true,
|
|
1042
|
+
closeSession: order.checkoutSessionId
|
|
1043
|
+
};
|
|
1044
|
+
});
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
console.error('commerce quote send failed before sending', error);
|
|
1047
|
+
await claim.release();
|
|
1048
|
+
return refuse(500, 'The quote could not be sent. Try again.');
|
|
1049
|
+
}
|
|
1050
|
+
if ('outcome' in prepared) {
|
|
1051
|
+
await claim.release();
|
|
1052
|
+
return prepared.outcome;
|
|
1053
|
+
}
|
|
1054
|
+
const { order, linkVersion, linkReplaced } = prepared;
|
|
1055
|
+
await closeOpenSession(deps, prepared.closeSession, 'quote link replaced to send a revision');
|
|
1056
|
+
const payUrl = deps.payLinkUrl(host, hostId, quoteId, linkVersion);
|
|
1057
|
+
let delivered;
|
|
1058
|
+
try {
|
|
1059
|
+
var _store_data;
|
|
1060
|
+
delivered = await mailer.deliver({
|
|
1061
|
+
hostId,
|
|
1062
|
+
quoteId,
|
|
1063
|
+
order,
|
|
1064
|
+
host,
|
|
1065
|
+
store: (_store_data = store.data()) != null ? _store_data : null,
|
|
1066
|
+
owner: owner ? {
|
|
1067
|
+
id: owner.id,
|
|
1068
|
+
org: owner.org
|
|
1069
|
+
} : null,
|
|
1070
|
+
to,
|
|
1071
|
+
subject: parsed.input.subject,
|
|
1072
|
+
message: parsed.input.message,
|
|
1073
|
+
payUrl,
|
|
1074
|
+
proofUrl: (proofId)=>deps.proofLinkUrl(host, hostId, quoteId, linkVersion, proofId),
|
|
1075
|
+
linkReplaced,
|
|
1076
|
+
senderEmail: context.senderEmail,
|
|
1077
|
+
atMs: now
|
|
1078
|
+
});
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
console.error('commerce quote send failed', error);
|
|
1081
|
+
await claim.release();
|
|
1082
|
+
return refuse(502, 'The quote email could not be sent. Try again.');
|
|
1083
|
+
}
|
|
1084
|
+
// `strictNullChecks` is off, so the result does not narrow on `sent`.
|
|
1085
|
+
if ('error' in delivered) {
|
|
1086
|
+
await claim.release();
|
|
1087
|
+
return refuse(delivered.status, delivered.error);
|
|
1088
|
+
}
|
|
1089
|
+
const done = delivered;
|
|
1090
|
+
// The email is out: from here a failure is logged, never answered as one,
|
|
1091
|
+
// so a retry does not send it twice.
|
|
1092
|
+
const revision = Math.max(1, Number((_ref1 = (_order_quote = order.quote) == null ? void 0 : _order_quote.revision) != null ? _ref1 : 1) || 1);
|
|
1093
|
+
const send = {
|
|
1094
|
+
revision,
|
|
1095
|
+
to,
|
|
1096
|
+
subject: done.subject,
|
|
1097
|
+
atMs: now,
|
|
1098
|
+
by: actor,
|
|
1099
|
+
messageId: done.messageId,
|
|
1100
|
+
providerId: done.providerId,
|
|
1101
|
+
linkVersion
|
|
1102
|
+
};
|
|
1103
|
+
try {
|
|
1104
|
+
await firestore.runTransaction(async (transaction)=>{
|
|
1105
|
+
const current = await transaction.get(ref);
|
|
1106
|
+
const latest = current.exists ? current.data() : null;
|
|
1107
|
+
if (!(latest == null ? void 0 : latest.quote)) return;
|
|
1108
|
+
transaction.set(ref, CommerceModel.quoteAfterSend(latest, send, done.thread));
|
|
1109
|
+
});
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
console.error('commerce quote sent but not recorded', {
|
|
1112
|
+
hostId,
|
|
1113
|
+
quoteId,
|
|
1114
|
+
messageId: done.messageId,
|
|
1115
|
+
error
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
const outcome = {
|
|
1119
|
+
status: 200,
|
|
1120
|
+
body: {
|
|
1121
|
+
sent: true,
|
|
1122
|
+
to,
|
|
1123
|
+
atMs: now,
|
|
1124
|
+
revision,
|
|
1125
|
+
linkVersion,
|
|
1126
|
+
linkReplaced
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
await claim.record(outcome.status, outcome.body);
|
|
1130
|
+
return outcome;
|
|
1131
|
+
}
|
|
835
1132
|
/** `POST /api/commerce/quote`. */ export const quoteHandler = async (req, res)=>{
|
|
836
1133
|
const deps = defaultQuoteDeps();
|
|
837
1134
|
const outcome = await handleQuote(deps, req);
|