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

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 (66) hide show
  1. package/README.md +2 -2
  2. package/package.json +13 -13
  3. package/src/lib/components/console/commerce-pdf-download.d.ts +30 -0
  4. package/src/lib/components/console/commerce-pdf-download.js +53 -0
  5. package/src/lib/components/console/commerce-pdf-download.js.map +1 -0
  6. package/src/lib/components/console/host-quotes-card.component.js +20 -4
  7. package/src/lib/components/console/host-quotes-card.component.js.map +1 -1
  8. package/src/lib/components/console/order-invoice.component.d.ts +6 -4
  9. package/src/lib/components/console/order-invoice.component.js +67 -11
  10. package/src/lib/components/console/order-invoice.component.js.map +1 -1
  11. package/src/lib/components/console/quote-editor-dialog.component.js +53 -2
  12. package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -1
  13. package/src/lib/components/console/quote-proofs.component.d.ts +37 -0
  14. package/src/lib/components/console/quote-proofs.component.js +626 -0
  15. package/src/lib/components/console/quote-proofs.component.js.map +1 -0
  16. package/src/lib/components/quote-page-body.js +29 -1
  17. package/src/lib/components/quote-page-body.js.map +1 -1
  18. package/src/lib/components/quote-proof-panel.d.ts +42 -0
  19. package/src/lib/components/quote-proof-panel.js +436 -0
  20. package/src/lib/components/quote-proof-panel.js.map +1 -0
  21. package/src/lib/model/commerce-quote-pay.d.ts +27 -0
  22. package/src/lib/model/commerce-quote-pay.js +36 -3
  23. package/src/lib/model/commerce-quote-pay.js.map +1 -1
  24. package/src/lib/model/commerce-quote-proofs.d.ts +159 -0
  25. package/src/lib/model/commerce-quote-proofs.js +348 -0
  26. package/src/lib/model/commerce-quote-proofs.js.map +1 -0
  27. package/src/lib/model/commerce-quotes.d.ts +59 -3
  28. package/src/lib/model/commerce-quotes.js.map +1 -1
  29. package/src/lib/model/index.d.ts +1 -0
  30. package/src/lib/model/index.js +1 -0
  31. package/src/lib/model/index.js.map +1 -1
  32. package/src/lib/server/commerce-pdf.d.ts +100 -0
  33. package/src/lib/server/commerce-pdf.js +283 -0
  34. package/src/lib/server/commerce-pdf.js.map +1 -0
  35. package/src/lib/server/order-print.d.ts +6 -0
  36. package/src/lib/server/order-print.js +22 -5
  37. package/src/lib/server/order-print.js.map +1 -1
  38. package/src/lib/server/pdf-response.d.ts +31 -0
  39. package/src/lib/server/pdf-response.js +30 -0
  40. package/src/lib/server/pdf-response.js.map +1 -0
  41. package/src/lib/server/pos-reader-splash.d.ts +2 -18
  42. package/src/lib/server/pos-reader-splash.js +8 -93
  43. package/src/lib/server/pos-reader-splash.js.map +1 -1
  44. package/src/lib/server/quote-pay.d.ts +31 -4
  45. package/src/lib/server/quote-pay.js +11 -11
  46. package/src/lib/server/quote-pay.js.map +1 -1
  47. package/src/lib/server/quote-proofs.d.ts +51 -0
  48. package/src/lib/server/quote-proofs.js +500 -0
  49. package/src/lib/server/quote-proofs.js.map +1 -0
  50. package/src/lib/server/quote-uploads.d.ts +33 -0
  51. package/src/lib/server/quote-uploads.js +29 -0
  52. package/src/lib/server/quote-uploads.js.map +1 -0
  53. package/src/lib/server/quote.d.ts +14 -0
  54. package/src/lib/server/quote.js +336 -3
  55. package/src/lib/server/quote.js.map +1 -1
  56. package/src/lib/server/site-image-source.d.ts +38 -0
  57. package/src/lib/server/site-image-source.js +130 -0
  58. package/src/lib/server/site-image-source.js.map +1 -0
  59. package/src/lib/server.js +19 -1
  60. package/src/lib/server.js.map +1 -1
  61. package/src/lib/utils/commerce-document-pdf.d.ts +77 -0
  62. package/src/lib/utils/commerce-document-pdf.js +608 -0
  63. package/src/lib/utils/commerce-document-pdf.js.map +1 -0
  64. package/src/lib/utils/order-invoice.d.ts +52 -3
  65. package/src/lib/utils/order-invoice.js +116 -64
  66. package/src/lib/utils/order-invoice.js.map +1 -1
@@ -18,6 +18,9 @@ import { type PluginApiHandler, type PluginApiRequest } from '@aglyn/aglyn/serve
18
18
  import { type PluginPersonRecord, type PluginPersonSearchRequest } from '@aglyn/aglyn/plugin-manager/plugin-person-records';
19
19
  import { type SaleTaxEngineRequest, type SaleTaxEngineResult } from './tax-engine-quote';
20
20
  import { type CheckoutSessionExpiry } from './checkout-session-expiry';
21
+ import type { quotePdfFile } from './commerce-pdf';
22
+ import { type CommercePdfFile } from './pdf-response';
23
+ import type { QuoteUploadCalls } from './quote-uploads';
21
24
  export interface QuoteDeps {
22
25
  verifyIdToken(token: string): Promise<{
23
26
  uid: string;
@@ -36,12 +39,23 @@ export interface QuoteDeps {
36
39
  expireSession(sessionId: string): Promise<CheckoutSessionExpiry>;
37
40
  /** The quote's pay link at a link version, or null when the site has no address or no signing secret. */
38
41
  payLinkUrl(host: Record<string, unknown>, hostId: string, quoteId: string, version: number): string | null;
42
+ /** The quote's PDF; loads the PDF writer and sharp only when asked. */
43
+ quotePdf(input: Parameters<typeof quotePdfFile>[0]): Promise<CommercePdfFile>;
44
+ /** The upload pipeline, loaded only by the proof actions. */
45
+ uploads(): Promise<QuoteUploadCalls>;
39
46
  now(): number;
40
47
  }
41
48
  export declare function defaultQuoteDeps(): QuoteDeps;
42
49
  type Outcome = {
43
50
  status: number;
44
51
  body: Record<string, unknown>;
52
+ pdf?: CommercePdfFile;
53
+ /** A proof or a customer's file, streamed by the handler. */
54
+ file?: {
55
+ path: string;
56
+ name: string;
57
+ contentType: string;
58
+ };
45
59
  };
46
60
  export declare function handleQuote(deps: QuoteDeps, req: PluginApiRequest): Promise<Outcome>;
47
61
  /** `POST /api/commerce/quote`. */
@@ -26,6 +26,7 @@ import { commerceSitePermissionRefusal } from "../model/plugin-permissions.js";
26
26
  import { holdsCommerceSiteKey } from "./commerce-site-keys.js";
27
27
  import { quoteSaleTaxWithEngine } from "./tax-engine-quote.js";
28
28
  import { expireCheckoutSession } from "./checkout-session-expiry.js";
29
+ import { sendPdf } from "./pdf-response.js";
29
30
  import { quotePayLinkUrl } from "./quote-link-token.js";
30
31
  export function defaultQuoteDeps() {
31
32
  return {
@@ -42,6 +43,8 @@ export function defaultQuoteDeps() {
42
43
  quoteTax: (request)=>quoteSaleTaxWithEngine(request),
43
44
  expireSession: (sessionId)=>expireCheckoutSession(sessionId),
44
45
  payLinkUrl: (host, hostId, quoteId, version)=>quotePayLinkUrl(host, hostId, quoteId, version),
46
+ quotePdf: async (input)=>(await import("./commerce-pdf.js")).quotePdfFile(input),
47
+ uploads: async ()=>(await import("./quote-uploads.js")).quoteUploadCalls(),
45
48
  now: ()=>Date.now()
46
49
  };
47
50
  }
@@ -281,6 +284,54 @@ export async function handleQuote(deps, req) {
281
284
  }
282
285
  };
283
286
  }
287
+ if (PROOF_ACTIONS.has(action)) {
288
+ var _body_quoteId2;
289
+ const quoteId = String((_body_quoteId2 = body['quoteId']) != null ? _body_quoteId2 : '');
290
+ if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
291
+ return handleProofAction(deps, {
292
+ action,
293
+ body,
294
+ hostId,
295
+ quoteId,
296
+ hostRef,
297
+ firestore,
298
+ uid: decoded.uid,
299
+ actor
300
+ });
301
+ }
302
+ if (action === 'pdf') {
303
+ var _body_quoteId3, _host_data1;
304
+ const quoteId = String((_body_quoteId3 = body['quoteId']) != null ? _body_quoteId3 : '');
305
+ if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
306
+ const [snapshot, store] = await Promise.all([
307
+ hostRef.collection('orders').doc(quoteId).get(),
308
+ hostRef.collection('settings').doc('store').get()
309
+ ]);
310
+ const order = snapshot.exists ? snapshot.data() : null;
311
+ if (!order || !CommerceModel.isQuote(order) || !order.quote) return refuse(404, 'Unknown quote');
312
+ const hostData = (_host_data1 = host.data()) != null ? _host_data1 : {};
313
+ // The pay link is "Send quotes and invoices to customers"; without it the PDF leaves the link off.
314
+ const payUrl = CommerceModel.isUnpaidQuote(order) && holdsCommerceSiteKey(host, decoded.uid, 'commerce.quotes.send') ? deps.payLinkUrl(hostData, hostId, quoteId, CommerceModel.quoteLinkVersion(order.quote)) : null;
315
+ try {
316
+ var _store_data;
317
+ const pdf = await deps.quotePdf({
318
+ hostId,
319
+ quoteId,
320
+ order,
321
+ host: hostData,
322
+ store: (_store_data = store.data()) != null ? _store_data : null,
323
+ payUrl
324
+ });
325
+ return {
326
+ status: 200,
327
+ body: {},
328
+ pdf
329
+ };
330
+ } catch (error) {
331
+ console.error('commerce quote pdf failed', error);
332
+ return refuse(500, 'The PDF could not be made. Try again.');
333
+ }
334
+ }
284
335
  if (action === 'search-customers') {
285
336
  var _body_text;
286
337
  const text = String((_body_text = body['text']) != null ? _body_text : '').trim().slice(0, 120);
@@ -332,8 +383,8 @@ export async function handleQuote(deps, req) {
332
383
  if (!DOC_ID.test(quoteId)) return refuse(400, 'Missing quoteId');
333
384
  const ref = hostRef.collection('orders').doc(quoteId);
334
385
  const deleted = await firestore.runTransaction(async (transaction)=>{
335
- var _ref;
336
- var _order_quote;
386
+ var _ref, _ref1;
387
+ var _order_quote, _order_quote1;
337
388
  const snapshot = await transaction.get(ref);
338
389
  // Already gone is what a retried delete finds.
339
390
  if (!snapshot.exists) return {
@@ -350,6 +401,11 @@ export async function handleQuote(deps, req) {
350
401
  if (((_ref = (_order_quote = order.quote) == null ? void 0 : _order_quote.stage) != null ? _ref : 'draft') !== 'draft') {
351
402
  return refuse(409, 'Only a draft quote can be deleted.');
352
403
  }
404
+ // Earlier revisions are what the customer was shown and answered; a
405
+ // quote that has them stays on record.
406
+ if (Number((_ref1 = (_order_quote1 = order.quote) == null ? void 0 : _order_quote1.revision) != null ? _ref1 : 1) > 1) {
407
+ return refuse(409, 'This quote has earlier revisions the customer saw, so it stays on record.');
408
+ }
353
409
  transaction.delete(ref);
354
410
  return {
355
411
  status: 200,
@@ -363,6 +419,8 @@ export async function handleQuote(deps, req) {
363
419
  "closeSession"
364
420
  ]);
365
421
  await closeOpenSession(deps, closeSession, 'quote deleted');
422
+ // Its proofs and anything the customer sent go with it.
423
+ if (outcome.status === 200) await removeQuoteFiles(deps, hostId, quoteId);
366
424
  return outcome;
367
425
  }
368
426
  if (action !== 'create' && action !== 'update') return refuse(400, 'Unknown action');
@@ -505,8 +563,283 @@ export async function handleQuote(deps, req) {
505
563
  return refuse(500, 'The quote could not be saved');
506
564
  }
507
565
  }
566
+ /*==========================================
567
+ * PROOFS AND REVISIONS (AGL-4096)
568
+ *=========================================*/ const PROOF_ACTIONS = new Set([
569
+ 'proof-ticket',
570
+ 'proof-finalize',
571
+ 'proof-remove',
572
+ 'start-revision',
573
+ 'revisions',
574
+ 'file'
575
+ ]);
576
+ /** Deletes a quote's kept files, its proofs and the customer's artwork; never throws. */ async function removeQuoteFiles(deps, hostId, quoteId) {
577
+ try {
578
+ const uploads = await deps.uploads();
579
+ await Promise.all([
580
+ CommerceModel.QUOTE_PROOF_PURPOSE,
581
+ CommerceModel.QUOTE_ARTWORK_PURPOSE
582
+ ].map((purpose)=>uploads.removeAll({
583
+ hostId,
584
+ purpose,
585
+ purposeId: quoteId
586
+ })));
587
+ } catch (error) {
588
+ console.error('quote files were not removed', {
589
+ hostId,
590
+ quoteId,
591
+ error
592
+ });
593
+ }
594
+ }
595
+ /** A file as the console is told about it: everything but where it is kept. */ function fileView(file) {
596
+ const rest = _extends({}, file);
597
+ delete rest.path;
598
+ return rest;
599
+ }
600
+ /** A revision as the console's history lists it. */ function revisionView(snapshot) {
601
+ var _snapshot_proofs, _snapshot_responses;
602
+ return _extends({}, snapshot, {
603
+ proofs: ((_snapshot_proofs = snapshot.proofs) != null ? _snapshot_proofs : []).map(fileView),
604
+ responses: ((_snapshot_responses = snapshot.responses) != null ? _snapshot_responses : []).map((response)=>_extends({}, response, response.files ? {
605
+ files: response.files.map(fileView)
606
+ } : {}))
607
+ });
608
+ }
609
+ /**
610
+ * Writes `next` over the quote in `transaction`, without the pay page's open
611
+ * session, which was priced from the quote as it stood; the caller closes it.
612
+ */ function writeEditedQuote(transaction, ref, next) {
613
+ const rest = _extends({}, next);
614
+ const session = rest.checkoutSessionId;
615
+ delete rest.checkoutSessionId;
616
+ transaction.set(ref, rest);
617
+ return session;
618
+ }
619
+ async function handleProofAction(deps, context) {
620
+ const { action, body, hostId, quoteId, hostRef, firestore, uid, actor } = context;
621
+ const ref = hostRef.collection('orders').doc(quoteId);
622
+ const proofScope = {
623
+ hostId,
624
+ purpose: CommerceModel.QUOTE_PROOF_PURPOSE,
625
+ purposeId: quoteId
626
+ };
627
+ if (action === 'revisions' || action === 'file') {
628
+ var _order_quote_revision, _body_fileId, _order_quote_responses, _ref, _ref1;
629
+ const snapshot = await ref.get();
630
+ const order = snapshot.exists ? snapshot.data() : null;
631
+ if (!order || !CommerceModel.isQuote(order) || !order.quote) return refuse(404, 'Unknown quote');
632
+ const current = Number((_order_quote_revision = order.quote.revision) != null ? _order_quote_revision : 1);
633
+ const wanted = body['revision'] === undefined || body['revision'] === null ? current : Number(body['revision']);
634
+ let snapshots = [];
635
+ if (action === 'revisions' || wanted !== current) {
636
+ const listed = await ref.collection('revisions').get();
637
+ snapshots = listed.docs.map((entry)=>entry.data()).filter((entry)=>Number.isInteger(Number(entry == null ? void 0 : entry.revision))).sort((a, b)=>Number(a.revision) - Number(b.revision));
638
+ }
639
+ if (action === 'revisions') return {
640
+ status: 200,
641
+ body: {
642
+ revisions: snapshots.map(revisionView)
643
+ }
644
+ };
645
+ const fileId = String((_body_fileId = body['fileId']) != null ? _body_fileId : '');
646
+ const source = wanted === current ? {
647
+ proofs: CommerceModel.currentQuoteProofs(order.quote),
648
+ responses: (_order_quote_responses = order.quote.responses) != null ? _order_quote_responses : []
649
+ } : snapshots.find((entry)=>Number(entry.revision) === wanted);
650
+ const files = [
651
+ ...(_ref = source == null ? void 0 : source.proofs) != null ? _ref : [],
652
+ ...((_ref1 = source == null ? void 0 : source.responses) != null ? _ref1 : []).flatMap((response)=>{
653
+ var _response_files;
654
+ return (_response_files = response.files) != null ? _response_files : [];
655
+ })
656
+ ];
657
+ const file = files.find((entry)=>entry.id === fileId);
658
+ if (!(file == null ? void 0 : file.path)) return refuse(404, 'That file is no longer on this quote.');
659
+ return {
660
+ status: 200,
661
+ body: {},
662
+ file: {
663
+ path: file.path,
664
+ name: file.name,
665
+ contentType: file.contentType
666
+ }
667
+ };
668
+ }
669
+ if (action === 'proof-ticket') {
670
+ const snapshot = await ref.get();
671
+ const order = snapshot.exists ? snapshot.data() : null;
672
+ const refused = CommerceModel.quoteProofChangeRefusal(order, 'add');
673
+ if (refused) return refuse(refused === 'not-a-quote' ? 404 : 409, CommerceModel.quoteProofRefusalMessage(refused));
674
+ // Storage is spent here, so the plan must sell, as a save's must.
675
+ const owner = await deps.orgForHost(hostId);
676
+ if (!checkEntitlement(owner == null ? void 0 : owner.org, 'commerce')) return refuse(403, 'Selling is not enabled');
677
+ const uploads = await deps.uploads();
678
+ const minted = await uploads.mint({
679
+ scope: proofScope,
680
+ request: body['file'],
681
+ caller: {
682
+ uid
683
+ }
684
+ });
685
+ if ('error' in minted) return refuse(minted.status, minted.error);
686
+ return {
687
+ status: 200,
688
+ body: {
689
+ ticket: minted.ticket,
690
+ upload: minted.upload,
691
+ file: minted.file
692
+ }
693
+ };
694
+ }
695
+ if (action === 'proof-finalize') {
696
+ const uploads = await deps.uploads();
697
+ const kept = await uploads.finalize({
698
+ scope: proofScope,
699
+ ticket: body['ticket'],
700
+ caller: {
701
+ uid
702
+ }
703
+ });
704
+ if ('error' in kept) return refuse(kept.status, kept.error);
705
+ const now = deps.now();
706
+ const answer = await firestore.runTransaction(async (transaction)=>{
707
+ const snapshot = await transaction.get(ref);
708
+ const order = snapshot.exists ? snapshot.data() : null;
709
+ if (!order) return {
710
+ outcome: refuse(404, 'Unknown quote')
711
+ };
712
+ // A retried finalize finds its proof already on the quote.
713
+ const already = CommerceModel.currentQuoteProofs(order.quote).find((proof)=>proof.id === kept.file.id);
714
+ if (already) {
715
+ var _ref;
716
+ var _order_quote;
717
+ return {
718
+ outcome: {
719
+ status: 200,
720
+ body: {
721
+ proof: fileView(already),
722
+ updatedAtMs: (_ref = (_order_quote = order.quote) == null ? void 0 : _order_quote.updatedAtMs) != null ? _ref : null
723
+ }
724
+ }
725
+ };
726
+ }
727
+ const added = CommerceModel.withQuoteProofAdded(order, {
728
+ id: kept.file.id,
729
+ name: kept.file.name,
730
+ contentType: kept.file.contentType,
731
+ size: kept.file.size,
732
+ path: kept.file.path
733
+ }, {
734
+ atMs: now,
735
+ by: actor
736
+ });
737
+ if ('reason' in added) {
738
+ return {
739
+ outcome: refuse(added.reason === 'not-a-quote' ? 404 : 409, CommerceModel.quoteProofRefusalMessage(added.reason))
740
+ };
741
+ }
742
+ const closeSession = writeEditedQuote(transaction, ref, added.order);
743
+ return {
744
+ outcome: {
745
+ status: 200,
746
+ body: {
747
+ proof: fileView(added.proof),
748
+ updatedAtMs: now
749
+ }
750
+ },
751
+ closeSession
752
+ };
753
+ });
754
+ if (answer.outcome.status !== 200) await uploads.remove(kept.file.path).catch(()=>undefined);
755
+ await closeOpenSession(deps, answer.closeSession, 'quote proof added');
756
+ return answer.outcome;
757
+ }
758
+ if (action === 'proof-remove') {
759
+ var _body_proofId;
760
+ const proofId = String((_body_proofId = body['proofId']) != null ? _body_proofId : '');
761
+ const now = deps.now();
762
+ const answer = await firestore.runTransaction(async (transaction)=>{
763
+ const snapshot = await transaction.get(ref);
764
+ const order = snapshot.exists ? snapshot.data() : null;
765
+ if (!order) return {
766
+ outcome: refuse(404, 'Unknown quote')
767
+ };
768
+ const removed = CommerceModel.withQuoteProofRemoved(order, proofId, {
769
+ atMs: now,
770
+ by: actor
771
+ });
772
+ if ('reason' in removed) {
773
+ return {
774
+ outcome: refuse(removed.reason === 'not-a-quote' || removed.reason === 'unknown-proof' ? 404 : 409, CommerceModel.quoteProofRefusalMessage(removed.reason))
775
+ };
776
+ }
777
+ const closeSession = writeEditedQuote(transaction, ref, removed.order);
778
+ return {
779
+ outcome: {
780
+ status: 200,
781
+ body: {
782
+ removed: true,
783
+ updatedAtMs: now
784
+ }
785
+ },
786
+ closeSession,
787
+ path: removed.proof.path
788
+ };
789
+ });
790
+ if (answer.path) {
791
+ const uploads = await deps.uploads();
792
+ await uploads.remove(answer.path).catch((error)=>console.error('quote proof file was not deleted', error));
793
+ }
794
+ await closeOpenSession(deps, answer.closeSession, 'quote proof removed');
795
+ return answer.outcome;
796
+ }
797
+ // start-revision
798
+ const now = deps.now();
799
+ const answer = await firestore.runTransaction(async (transaction)=>{
800
+ const snapshot = await transaction.get(ref);
801
+ const order = snapshot.exists ? snapshot.data() : null;
802
+ if (!order) return {
803
+ outcome: refuse(404, 'Unknown quote')
804
+ };
805
+ const started = CommerceModel.startQuoteRevision(order, {
806
+ atMs: now,
807
+ by: actor
808
+ });
809
+ if ('reason' in started) return {
810
+ outcome: refuse(started.reason === 'paid' ? 409 : 404, started.error)
811
+ };
812
+ const revisionRef = ref.collection('revisions').doc(String(started.revision));
813
+ // A revision on record is never written over.
814
+ if ((await transaction.get(revisionRef)).exists) {
815
+ return {
816
+ outcome: refuse(409, `Revision ${started.revision} is already on record. Close the quote and open it again.`)
817
+ };
818
+ }
819
+ transaction.set(revisionRef, started.snapshot);
820
+ const closeSession = writeEditedQuote(transaction, ref, started.order);
821
+ return {
822
+ outcome: {
823
+ status: 200,
824
+ body: {
825
+ revision: started.revision + 1,
826
+ updatedAtMs: now
827
+ }
828
+ },
829
+ closeSession
830
+ };
831
+ });
832
+ await closeOpenSession(deps, answer.closeSession, 'quote revision started');
833
+ return answer.outcome;
834
+ }
508
835
  /** `POST /api/commerce/quote`. */ export const quoteHandler = async (req, res)=>{
509
- const outcome = await handleQuote(defaultQuoteDeps(), req);
836
+ const deps = defaultQuoteDeps();
837
+ const outcome = await handleQuote(deps, req);
838
+ if (outcome.pdf) return sendPdf(res, outcome.pdf);
839
+ if (outcome.file) {
840
+ const uploads = await deps.uploads();
841
+ return uploads.send(res, outcome.file);
842
+ }
510
843
  return res.status(outcome.status).json(outcome.body);
511
844
  };
512
845