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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +4 -2
  2. package/package.json +13 -13
  3. package/src/lib/components/commerce-console-page.js +2 -1
  4. package/src/lib/components/commerce-console-page.js.map +1 -1
  5. package/src/lib/components/console/host-orders-card.component.d.ts +6 -0
  6. package/src/lib/components/console/host-orders-card.component.js +3 -2
  7. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  8. package/src/lib/components/console/invoice-send.component.d.ts +44 -0
  9. package/src/lib/components/console/invoice-send.component.js +237 -0
  10. package/src/lib/components/console/invoice-send.component.js.map +1 -0
  11. package/src/lib/components/console/order-detail-dialog.component.d.ts +5 -0
  12. package/src/lib/components/console/order-detail-dialog.component.js +9 -2
  13. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  14. package/src/lib/components/console/order-invoice.component.d.ts +9 -0
  15. package/src/lib/components/console/order-invoice.component.js +27 -2
  16. package/src/lib/components/console/order-invoice.component.js.map +1 -1
  17. package/src/lib/components/console/quote-editor-dialog.component.d.ts +29 -0
  18. package/src/lib/components/console/quote-editor-dialog.component.js +102 -4
  19. package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -1
  20. package/src/lib/components/console/quote-proofs.component.d.ts +11 -0
  21. package/src/lib/components/console/quote-proofs.component.js +68 -0
  22. package/src/lib/components/console/quote-proofs.component.js.map +1 -1
  23. package/src/lib/components/console/quote-send.component.d.ts +42 -0
  24. package/src/lib/components/console/quote-send.component.js +307 -0
  25. package/src/lib/components/console/quote-send.component.js.map +1 -0
  26. package/src/lib/components/console/quote-start.component.d.ts +45 -0
  27. package/src/lib/components/console/quote-start.component.js +182 -0
  28. package/src/lib/components/console/quote-start.component.js.map +1 -0
  29. package/src/lib/components/product-options-field-body.d.ts +44 -0
  30. package/src/lib/components/product-options-field-body.js +260 -0
  31. package/src/lib/components/product-options-field-body.js.map +1 -0
  32. package/src/lib/components/product-options-field.d.ts +32 -0
  33. package/src/lib/components/product-options-field.js +128 -0
  34. package/src/lib/components/product-options-field.js.map +1 -0
  35. package/src/lib/components/quote-page-body.js +39 -20
  36. package/src/lib/components/quote-page-body.js.map +1 -1
  37. package/src/lib/model/commerce-invoices.d.ts +132 -0
  38. package/src/lib/model/commerce-invoices.js +332 -0
  39. package/src/lib/model/commerce-invoices.js.map +1 -0
  40. package/src/lib/model/commerce-orders.d.ts +29 -2
  41. package/src/lib/model/commerce-orders.js.map +1 -1
  42. package/src/lib/model/commerce-product-field.d.ts +23 -0
  43. package/src/lib/model/commerce-product-field.js +26 -0
  44. package/src/lib/model/commerce-product-field.js.map +1 -0
  45. package/src/lib/model/commerce-quote-email.d.ts +67 -0
  46. package/src/lib/model/commerce-quote-email.js +154 -0
  47. package/src/lib/model/commerce-quote-email.js.map +1 -0
  48. package/src/lib/model/commerce-quote-pay.d.ts +49 -6
  49. package/src/lib/model/commerce-quote-pay.js +69 -36
  50. package/src/lib/model/commerce-quote-pay.js.map +1 -1
  51. package/src/lib/model/commerce-quote-request.d.ts +93 -0
  52. package/src/lib/model/commerce-quote-request.js +207 -0
  53. package/src/lib/model/commerce-quote-request.js.map +1 -0
  54. package/src/lib/model/commerce-quotes.d.ts +81 -0
  55. package/src/lib/model/commerce-quotes.js.map +1 -1
  56. package/src/lib/model/index.d.ts +3 -0
  57. package/src/lib/model/index.js +3 -0
  58. package/src/lib/model/index.js.map +1 -1
  59. package/src/lib/plugin.js +19 -0
  60. package/src/lib/plugin.js.map +1 -1
  61. package/src/lib/server/billing-webhook.js +115 -70
  62. package/src/lib/server/billing-webhook.js.map +1 -1
  63. package/src/lib/server/commerce-pdf.d.ts +5 -1
  64. package/src/lib/server/commerce-pdf.js +37 -10
  65. package/src/lib/server/commerce-pdf.js.map +1 -1
  66. package/src/lib/server/draft-order.js +23 -3
  67. package/src/lib/server/draft-order.js.map +1 -1
  68. package/src/lib/server/invoice-email.d.ts +76 -0
  69. package/src/lib/server/invoice-email.js +183 -0
  70. package/src/lib/server/invoice-email.js.map +1 -0
  71. package/src/lib/server/order-invoice-send.d.ts +43 -0
  72. package/src/lib/server/order-invoice-send.js +218 -0
  73. package/src/lib/server/order-invoice-send.js.map +1 -0
  74. package/src/lib/server/order-status.d.ts +11 -0
  75. package/src/lib/server/order-status.js +44 -8
  76. package/src/lib/server/order-status.js.map +1 -1
  77. package/src/lib/server/person-eraser.d.ts +3 -2
  78. package/src/lib/server/person-eraser.js +25 -6
  79. package/src/lib/server/person-eraser.js.map +1 -1
  80. package/src/lib/server/quote-email.d.ts +85 -0
  81. package/src/lib/server/quote-email.js +211 -0
  82. package/src/lib/server/quote-email.js.map +1 -0
  83. package/src/lib/server/quote-link-token.d.ts +11 -0
  84. package/src/lib/server/quote-link-token.js +21 -0
  85. package/src/lib/server/quote-link-token.js.map +1 -1
  86. package/src/lib/server/quote-pay.d.ts +8 -3
  87. package/src/lib/server/quote-pay.js +105 -43
  88. package/src/lib/server/quote-pay.js.map +1 -1
  89. package/src/lib/server/quote.d.ts +11 -0
  90. package/src/lib/server/quote.js +309 -12
  91. package/src/lib/server/quote.js.map +1 -1
  92. package/src/lib/server/site-page-resolver.js +2 -1
  93. package/src/lib/server/site-page-resolver.js.map +1 -1
  94. package/src/lib/server.js +5 -0
  95. package/src/lib/server.js.map +1 -1
  96. package/src/lib/site.js +8 -0
  97. package/src/lib/site.js.map +1 -1
  98. package/src/lib/tenant-emails.js +243 -0
  99. package/src/lib/tenant-emails.js.map +1 -1
  100. package/src/lib/utils/commerce-document-pdf.d.ts +5 -0
  101. package/src/lib/utils/commerce-document-pdf.js +6 -1
  102. package/src/lib/utils/commerce-document-pdf.js.map +1 -1
  103. package/src/lib/utils/invoice-email-content.d.ts +45 -0
  104. package/src/lib/utils/invoice-email-content.js +126 -0
  105. package/src/lib/utils/invoice-email-content.js.map +1 -0
  106. package/src/lib/utils/order-invoice.d.ts +1 -1
  107. package/src/lib/utils/order-invoice.js +14 -6
  108. package/src/lib/utils/order-invoice.js.map +1 -1
  109. package/src/lib/utils/quote-email-content.d.ts +62 -0
  110. package/src/lib/utils/quote-email-content.js +154 -0
  111. package/src/lib/utils/quote-email-content.js.map +1 -0
@@ -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 === 'pay-link' || action === 'replace-link' ? 'commerce.quotes.send' : 'commerce.quotes';
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 (PROOF_ACTIONS.has(action)) {
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 _body_quoteId3, _host_data1;
304
- const quoteId = String((_body_quoteId3 = body['quoteId']) != null ? _body_quoteId3 : '');
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 = (_host_data1 = host.data()) != null ? _host_data1 : {};
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);