@aglyn/plugins-commerce 1.0.0-beta.292 → 1.0.0-beta.293
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 +1 -0
- package/package.json +13 -13
- package/src/lib/components/console/host-quotes-card.component.js +11 -9
- package/src/lib/components/console/host-quotes-card.component.js.map +1 -1
- package/src/lib/declarations.console-server.js +15 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/model/commerce-quote-proofs.d.ts +7 -0
- package/src/lib/model/commerce-quote-proofs.js +15 -0
- package/src/lib/model/commerce-quote-proofs.js.map +1 -1
- package/src/lib/server/quote-link-token.d.ts +4 -2
- package/src/lib/server/quote-link-token.js +4 -2
- package/src/lib/server/quote-link-token.js.map +1 -1
- package/src/lib/server/quote-pay.d.ts +5 -1
- package/src/lib/server/quote-pay.js +5 -2
- package/src/lib/server/quote-pay.js.map +1 -1
- package/src/lib/server/quote-proofs.d.ts +9 -1
- package/src/lib/server/quote-proofs.js +92 -23
- package/src/lib/server/quote-proofs.js.map +1 -1
- package/src/lib/server/quote-upload-keys.d.ts +50 -0
- package/src/lib/server/quote-upload-keys.js +86 -0
- package/src/lib/server/quote-upload-keys.js.map +1 -0
- package/src/lib/server/quote-upload-sweep.d.ts +30 -0
- package/src/lib/server/quote-upload-sweep.js +117 -0
- package/src/lib/server/quote-upload-sweep.js.map +1 -0
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ All console pages and cards are code-split.
|
|
|
60
60
|
- `registerCommerceConsoleApi()` (the `consoleApi` surface) registers the merchant routes: cancel, fulfill and refund an order, draft orders, quotes with their pay links, PDFs, proofs, revisions and the email that sends them (`quote` `send`), an order's or a quote's invoice email (`order-invoice-send`), and a quote started from a form's quote request (`quote` `start-from-request`, and `create` with `from`, read through the forms plugin's `forms.submission` facts), POS orders, gift cards, member posts, a site member's password help and removal (`membership/admin-password`, `membership/admin-remove`), Stripe Connect, supplier updates, and the abandoned-checkout and restock processors. It also adds a handler on the platform billing webhook and order figure readers.
|
|
61
61
|
- `registerCommerceConsoleApi()` also registers what an AI build may make in a store, on core's resource-drafts and AI-capability seams: the `product` draft writer and build op, and the `store-settings` writer with the `store-shipping`, `store-pickup`, `store-delivery` and `store-tax` build ops (`server/store-setup-ai-*.ts`). Those four set shipping zones and rates, pickup, local delivery and sales tax exactly as the console's cards save them, live, using only amounts and rates the request states, and never a carrier or tax-service account. `model/store-setup-content.ts` holds the pure read-and-apply step.
|
|
62
62
|
- Loading the module registers scheduled plugin jobs: `abandoned-checkout-recovery`, `back-in-stock-alerts`, `review-request-emails`, `stock-decrement-reconciliation`, `supplier-webhook-delivery`, `payment-method-domains` and `pos-reader-splash-refresh` (card reader start-up screens after a logo change). This is why `package.json` lists `./src/lib/server.*` under `sideEffects`.
|
|
63
|
+
- `registerCommerceConsoleServerDeclarations()` (the `consoleServerDeclarations` surface) declares `commerce-quote-upload-sweep`, a console job on the same fifteen-minute tick as the forms plugin's sweep, that deletes the artwork a customer attached to an answer on a quote and never sent, a day after its last upload (AGL-4096). Each file is recorded as pending at `orders/{quoteId}/quoteUploadKeys/{quoteId}` from its ticket until `quote-respond` sends it (`server/quote-upload-keys.ts`, `server/quote-upload-sweep.ts`).
|
|
63
64
|
|
|
64
65
|
- Product reviews are verified-buyer by default. `commerce/reviews` (POST) marks a review verified only for a signed-in site member whose proven email has an order that still entitles the product, or for a single-use link from the review-request email (`server/review-tokens.ts`: hashed, 60 days, bound to order, product and email). A typed email never verifies. Verified reviews publish at once; under the store's `anyone` policy (`settings/store.reviews.policy`) the rest wait as `pending`, and under `verified` they get 403. `GET commerce/reviews?mode=eligibility` tells the storefront block which form to draw. The POST is rate limited per address and per member. The `order.fulfilled`, `order.delivered` and `order.paid` subscriber stamps an order for its review request, and the hourly `review-request-emails` job sends it (`server/review-requests.ts`, catalog key `review-request`). Merchants hide a review only for a content reason (`REVIEW_MODERATION_REASONS`), which the Firestore rules enforce.
|
|
65
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-commerce",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.293",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.293",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.293",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.293",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.293",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.293",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.293",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.293",
|
|
35
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.293",
|
|
36
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.293",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.293",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.293",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.293",
|
|
40
40
|
"@stripe/react-stripe-js": "^6.12.0",
|
|
41
41
|
"@stripe/stripe-js": "^9.17.0",
|
|
42
42
|
"@swc/helpers": "0.5.23",
|
|
@@ -26,7 +26,8 @@ import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component"
|
|
|
26
26
|
import { ceilingedWindow, collectionCeiling, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
|
|
27
27
|
import { Button, Chip, Stack, Typography } from "@mui/material";
|
|
28
28
|
import { collection, limit, query, where } from "firebase/firestore";
|
|
29
|
-
import {
|
|
29
|
+
import { useSearchParams } from "next/navigation";
|
|
30
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
30
31
|
import QuoteEditorDialog from "./quote-editor-dialog.component.js";
|
|
31
32
|
/**
|
|
32
33
|
* How many quotes the list reads. The whole set is read and the grid sorts
|
|
@@ -58,7 +59,7 @@ const REFUSAL = commerceSitePermissionRefusal('commerce.quotes');
|
|
|
58
59
|
* this reads `hosts/{hostId}/orders` for that channel alone; the Orders list
|
|
59
60
|
* above never shows one until it is paid.
|
|
60
61
|
*/ export function HostQuotesCard(props) {
|
|
61
|
-
var _quotes_rows_find;
|
|
62
|
+
var _quotes_rows_find, _ref;
|
|
62
63
|
const { hostId, canEdit, canSend } = props;
|
|
63
64
|
const firestore = useFirestore();
|
|
64
65
|
const { data: quoteDocs, status } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'orders'), where('channel', '==', 'quote'), limit(QUOTE_CEILING + 1)), [
|
|
@@ -85,15 +86,16 @@ const REFUSAL = commerceSitePermissionRefusal('commerce.quotes');
|
|
|
85
86
|
/** The quote open in the editor: its id, or `new`. */ const [openId, setOpenId] = useState(null);
|
|
86
87
|
const openQuote = openId && openId !== 'new' ? (_quotes_rows_find = quotes.rows.find((row)=>row.$id === openId)) != null ? _quotes_rows_find : null : null;
|
|
87
88
|
// A manager notice opens its quote (AGL-4096): `?quote=<id>` on the Orders
|
|
88
|
-
// page, once that quote has loaded.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
// page, once that quote has loaded. Read from the router, so a notice
|
|
90
|
+
// followed while this page is already open opens its quote too; once per
|
|
91
|
+
// id, so closing the dialog does not reopen it.
|
|
92
|
+
const searchParams = useSearchParams();
|
|
93
|
+
const linkedId = (_ref = searchParams == null ? void 0 : searchParams.get('quote')) != null ? _ref : '';
|
|
94
|
+
const opened = useRef('');
|
|
93
95
|
useEffect(()=>{
|
|
94
|
-
if (!linkedId || !quotes.rows.some((row)=>row.$id === linkedId)) return;
|
|
96
|
+
if (!linkedId || opened.current === linkedId || !quotes.rows.some((row)=>row.$id === linkedId)) return;
|
|
97
|
+
opened.current = linkedId;
|
|
95
98
|
setOpenId(linkedId);
|
|
96
|
-
setLinkedId('');
|
|
97
99
|
}, [
|
|
98
100
|
linkedId,
|
|
99
101
|
quotes.rows
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/host-quotes-card.component.tsx"],"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\n'use client'\n\nimport * as CommerceModel from '../../model'\nimport { posMoney } from '../../model/commerce-pos-ops'\nimport { commerceSitePermissionRefusal } from '../../model/plugin-permissions'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { BlockedControl } from '@aglyn/shared-ui-jsx/components/blocked-control.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n ceilingedWindow,\n collectionCeiling,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, query, where } from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport QuoteEditorDialog, { type QuoteRow } from './quote-editor-dialog.component'\n\nexport interface HostQuotesCardProps {\n hostId: string\n /** Holds \"Create and edit quotes\" (`commerce.quotes`). */\n canEdit: boolean\n /** Holds \"Send quotes and invoices to customers\" (`commerce.quotes.send`). */\n canSend: boolean\n}\n\n/**\n * How many quotes the list reads. The whole set is read and the grid sorts\n * it by any column; a store past this many is told the list stops short.\n */\nconst QUOTE_CEILING = 500\n\n/** The products the editor's picker offers. */\nconst PRODUCT_WINDOW = 200\n\nconst REFUSAL = commerceSitePermissionRefusal('commerce.quotes')\n\ninterface QuoteListRow extends QuoteRow {\n customerLabel: string\n firstItem: string\n moreItems: number\n statusKey: ReturnType<typeof CommerceModel.quoteDisplayStatus>['key']\n statusLabel: string\n totalCents: number\n updatedAtMs: number\n}\n\n/** One quote as its row draws and sorts it. */\nfunction quoteListRow(quote: QuoteRow): QuoteListRow {\n const customer = quote.quote?.customer\n const status = CommerceModel.quoteDisplayStatus(quote)\n const items = quote.lineItems ?? []\n return {\n ...quote,\n customerLabel: customer?.name || customer?.company || customer?.email || '',\n firstItem: items[0]?.name ?? '',\n moreItems: Math.max(0, items.length - 1),\n statusKey: status.key,\n statusLabel: status.label,\n totalCents: Number(quote.totals?.totalCents ?? 0),\n // The customer's latest answer counts as an update (AGL-4096), so a quote\n // they just approved or asked changes to sorts to the top.\n updatedAtMs: Math.max(\n Number(quote.quote?.updatedAtMs ?? 0),\n Number(CommerceModel.latestQuoteResponse(quote.quote)?.atMs ?? 0),\n ),\n }\n}\n\n/**\n * Quotes (AGL-4093): prices put to a customer before they buy, beside the\n * orders they become. A quote is an unpaid order on the `quote` channel, so\n * this reads `hosts/{hostId}/orders` for that channel alone; the Orders list\n * above never shows one until it is paid.\n */\nexport function HostQuotesCard(props: HostQuotesCardProps) {\n const { hostId, canEdit, canSend } = props\n const firestore = useFirestore()\n const { data: quoteDocs, status } = useFirestoreCollection<QuoteRow>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'orders'),\n where('channel', '==', 'quote'),\n limit(QUOTE_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const quotes = useMemo(() => ceilingedWindow<QuoteRow>(quoteDocs ?? undefined, QUOTE_CEILING), [quoteDocs])\n const rows = useMemo(() => Array.from(quotes.rows, quoteListRow), [quotes])\n\n const { data: productDocs } = useFirestoreCollection<Record<string, any>>(\n () => collectionCeiling(collection(firestore, 'hosts', hostId, 'products'), PRODUCT_WINDOW),\n [firestore, hostId],\n { idField: '$id' },\n )\n const products = useMemo(\n () => ceilingedWindow<Record<string, any>>(productDocs ?? undefined, PRODUCT_WINDOW).rows.filter((product) => !product['deletedAt']),\n [productDocs],\n )\n\n /** The quote open in the editor: its id, or `new`. */\n const [openId, setOpenId] = useState<string | null>(null)\n const openQuote = openId && openId !== 'new' ? (quotes.rows.find((row) => row.$id === openId) ?? null) : null\n\n // A manager notice opens its quote (AGL-4096): `?quote=<id>` on the Orders\n // page, once that quote has loaded.\n const [linkedId, setLinkedId] = useState(() =>\n typeof window === 'undefined' ? '' : (new URLSearchParams(window.location.search).get('quote') ?? ''),\n )\n useEffect(() => {\n if (!linkedId || !quotes.rows.some((row) => row.$id === linkedId)) return\n setOpenId(linkedId)\n setLinkedId('')\n }, [linkedId, quotes.rows])\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'number',\n headerName: 'Quote',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {CommerceModel.formatQuoteNumber(row, row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.moreItems ? `${row.firstItem} and more` : row.firstItem}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'customerLabel',\n headerName: 'Customer',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Typography variant=\"body2\" noWrap>\n {row.customerLabel || '—'}\n </Typography>\n ),\n },\n {\n field: 'totalCents',\n headerName: 'Total',\n type: 'number',\n width: 140,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Stack sx={{ alignItems: 'flex-end', minWidth: 0, width: 1 }}>\n <Typography variant=\"body2\">{posMoney(row.totalCents)}</Typography>\n {row.quote?.tax?.kind === 'at-payment' && CommerceModel.isUnpaidQuote(row) ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {'plus tax at payment'}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'statusLabel',\n headerName: 'Status',\n width: 180,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={row.statusLabel}\n color={CommerceModel.QUOTE_STATUS_COLOR[row.statusKey]}\n />\n ),\n },\n {\n field: 'updatedAtMs',\n headerName: 'Updated',\n type: 'number',\n width: 140,\n align: 'left',\n headerAlign: 'left',\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Typography variant=\"body2\" noWrap>\n {row.updatedAtMs ? new Date(row.updatedAtMs).toLocaleDateString() : '—'}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const empty = status === 'success' && rows.length === 0\n\n return (\n <CardDisplay\n header={'Quotes'}\n subheader={'Prices for a customer’s order before they buy'}\n help={pluginDocsHelp('quotes', {\n anchor: '#make-a-quote',\n title: 'Quotes',\n excerpt:\n 'Put a price to a customer with your products, items typed for the job and anything ' +\n 'included. A quote becomes an order when it is paid.',\n })}\n HeaderProps={{\n action: (\n <BlockedControl blocked={!canEdit} reason={REFUSAL}>\n <Button size=\"small\" variant=\"contained\" onClick={() => setOpenId('new')}>\n {'New quote'}\n </Button>\n </BlockedControl>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n {empty ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No quotes yet. A quote prices a customer’s order before they buy: your products, ' +\n 'items typed for the job, and anything you include at no charge.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n {quotes.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This store has more quotes than the list shows at once.'}\n </Typography>\n ) : null}\n <ListTable\n aria-label=\"Quotes\"\n rows={rows}\n columns={columns}\n loading={status === 'loading'}\n onOpen={(id) => setOpenId(id)}\n quickFilter\n initialState={{ sorting: { sortModel: [{ field: 'updatedAtMs', sort: 'desc' }] } }}\n noRowsLabel=\"No quotes match\"\n />\n </Stack>\n )}\n {/* A quote deleted while open closes its editor rather than turn it into a new one. */}\n {openId === 'new' || openQuote ? (\n <QuoteEditorDialog\n key={openId}\n hostId={hostId}\n quote={openQuote}\n products={products}\n canEdit={canEdit}\n canSend={canSend}\n onClose={() => setOpenId(null)}\n />\n ) : null}\n </CardDisplay>\n )\n}\nHostQuotesCard.displayName = 'HostQuotesCard'\n\nexport default HostQuotesCard\n"],"names":["CommerceModel","posMoney","commerceSitePermissionRefusal","pluginDocsHelp","CardDisplay","BlockedControl","ListTable","ceilingedWindow","collectionCeiling","useFirestore","useFirestoreCollection","Button","Chip","Stack","Typography","collection","limit","query","where","useEffect","useMemo","useState","QuoteEditorDialog","QUOTE_CEILING","PRODUCT_WINDOW","REFUSAL","quoteListRow","quote","items","customer","status","quoteDisplayStatus","lineItems","customerLabel","name","company","email","firstItem","moreItems","Math","max","length","statusKey","key","statusLabel","label","totalCents","Number","totals","updatedAtMs","latestQuoteResponse","atMs","HostQuotesCard","props","quotes","hostId","canEdit","canSend","firestore","data","quoteDocs","idField","undefined","rows","Array","from","productDocs","products","filter","product","openId","setOpenId","openQuote","find","row","$id","linkedId","setLinkedId","window","URLSearchParams","location","search","get","some","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","formatQuoteNumber","color","type","width","align","headerAlign","alignItems","tax","kind","isUnpaidQuote","size","QUOTE_STATUS_COLOR","Date","toLocaleDateString","empty","header","subheader","help","anchor","title","excerpt","HeaderProps","action","blocked","reason","onClick","contentGutterX","contentGutterY","spacing","truncated","aria-label","loading","onOpen","id","quickFilter","initialState","sorting","sortModel","sort","noRowsLabel","onClose","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;AAEA,YAAYA,mBAAmB,uBAAa;AAC5C,SAASC,QAAQ,QAAQ,kCAA8B;AACvD,SAASC,6BAA6B,QAAQ,oCAAgC;AAC9E,SAASC,cAAc,QAAQ,eAAc;AAC7C,SAASC,WAAW,QAAQ,uBAAsB;AAClD,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,SAASC,SAAS,QAAQ,uDAAsD;AAChF,SACEC,eAAe,EACfC,iBAAiB,EACjBC,YAAY,EACZC,sBAAsB,QACjB,iCAAgC;AACvC,SAASC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAE/D,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AACpE,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACpD,OAAOC,uBAA0C,qCAAiC;AAUlF;;;CAGC,GACD,MAAMC,gBAAgB;AAEtB,6CAA6C,GAC7C,MAAMC,iBAAiB;AAEvB,MAAMC,UAAUvB,8BAA8B;AAY9C,6CAA6C,GAC7C,SAASwB,aAAaC,KAAe;QAGrBA;QAFGA,cAMJC,SAIQD,eAIVA,eACA3B;IAfX,MAAM6B,YAAWF,eAAAA,MAAMA,KAAK,qBAAXA,aAAaE,QAAQ;IACtC,MAAMC,SAAS9B,cAAc+B,kBAAkB,CAACJ;IAChD,MAAMC,SAAQD,mBAAAA,MAAMK,SAAS,YAAfL,mBAAmB,EAAE;IACnC,OAAO,aACFA;QACHM,eAAeJ,CAAAA,4BAAAA,SAAUK,IAAI,MAAIL,4BAAAA,SAAUM,OAAO,MAAIN,4BAAAA,SAAUO,KAAK,KAAI;QACzEC,SAAS,WAAET,UAAAA,KAAK,CAAC,EAAE,qBAARA,QAAUM,IAAI,mBAAI;QAC7BI,WAAWC,KAAKC,GAAG,CAAC,GAAGZ,MAAMa,MAAM,GAAG;QACtCC,WAAWZ,OAAOa,GAAG;QACrBC,aAAad,OAAOe,KAAK;QACzBC,YAAYC,iBAAOpB,gBAAAA,MAAMqB,MAAM,qBAAZrB,cAAcmB,UAAU,oBAAI;QAC/C,0EAA0E;QAC1E,2DAA2D;QAC3DG,aAAaV,KAAKC,GAAG,CACnBO,iBAAOpB,gBAAAA,MAAMA,KAAK,qBAAXA,cAAasB,WAAW,oBAAI,IACnCF,iBAAO/C,qCAAAA,cAAckD,mBAAmB,CAACvB,MAAMA,KAAK,sBAA7C3B,mCAAgDmD,IAAI,oBAAI;;AAGrE;AAEA;;;;;CAKC,GACD,OAAO,SAASC,eAAeC,KAA0B;QA4BPC;IA3BhD,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAEC,OAAO,EAAE,GAAGJ;IACrC,MAAMK,YAAYjD;IAClB,MAAM,EAAEkD,MAAMC,SAAS,EAAE9B,MAAM,EAAE,GAAGpB,uBAClC,IACEO,MACEF,WAAW2C,WAAW,SAASH,QAAQ,WACvCrC,MAAM,WAAW,MAAM,UACvBF,MAAMO,gBAAgB,KAE1B;QAACmC;QAAWH;KAAO,EACnB;QAAEM,SAAS;IAAM;IAEnB,MAAMP,SAASlC,QAAQ,IAAMb,gBAA0BqD,oBAAAA,YAAaE,WAAWvC,gBAAgB;QAACqC;KAAU;IAC1G,MAAMG,OAAO3C,QAAQ,IAAM4C,MAAMC,IAAI,CAACX,OAAOS,IAAI,EAAErC,eAAe;QAAC4B;KAAO;IAE1E,MAAM,EAAEK,MAAMO,WAAW,EAAE,GAAGxD,uBAC5B,IAAMF,kBAAkBO,WAAW2C,WAAW,SAASH,QAAQ,aAAa/B,iBAC5E;QAACkC;QAAWH;KAAO,EACnB;QAAEM,SAAS;IAAM;IAEnB,MAAMM,WAAW/C,QACf,IAAMb,gBAAqC2D,sBAAAA,cAAeJ,WAAWtC,gBAAgBuC,IAAI,CAACK,MAAM,CAAC,CAACC,UAAY,CAACA,OAAO,CAAC,YAAY,GACnI;QAACH;KAAY;IAGf,oDAAoD,GACpD,MAAM,CAACI,QAAQC,UAAU,GAAGlD,SAAwB;IACpD,MAAMmD,YAAYF,UAAUA,WAAW,SAAShB,oBAAAA,OAAOS,IAAI,CAACU,IAAI,CAAC,CAACC,MAAQA,IAAIC,GAAG,KAAKL,mBAAtChB,oBAAiD,OAAQ;IAEzG,2EAA2E;IAC3E,oCAAoC;IACpC,MAAM,CAACsB,UAAUC,YAAY,GAAGxD,SAAS;YACD;eAAtC,OAAOyD,WAAW,cAAc,MAAM,OAAA,IAAIC,gBAAgBD,OAAOE,QAAQ,CAACC,MAAM,EAAEC,GAAG,CAAC,oBAAhD,OAA4D;;IAEpG/D,UAAU;QACR,IAAI,CAACyD,YAAY,CAACtB,OAAOS,IAAI,CAACoB,IAAI,CAAC,CAACT,MAAQA,IAAIC,GAAG,KAAKC,WAAW;QACnEL,UAAUK;QACVC,YAAY;IACd,GAAG;QAACD;QAAUtB,OAAOS,IAAI;KAAC;IAE1B,MAAMqB,UAAUhE,QACd,IAAM;YACJ;gBACEiE,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEf,GAAG,EAAyB,iBACzC,MAAC7D;wBAAM6E,IAAI;4BAAEF,UAAU;wBAAE;;0CACvB,KAAC1E;gCAAW6E,SAAQ;gCAAQC,MAAM;0CAC/B5F,cAAc6F,iBAAiB,CAACnB,KAAKA,IAAIC,GAAG;;0CAE/C,KAAC7D;gCAAW6E,SAAQ;gCAAUG,OAAM;gCAAiBF,MAAM;0CACxDlB,IAAIpC,SAAS,GAAG,GAAGoC,IAAIrC,SAAS,CAAC,SAAS,CAAC,GAAGqC,IAAIrC,SAAS;;;;YAIpE;YACA;gBACEgD,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEf,GAAG,EAAyB,iBACzC,KAAC5D;wBAAW6E,SAAQ;wBAAQC,MAAM;kCAC/BlB,IAAIzC,aAAa,IAAI;;YAG5B;YACA;gBACEoD,OAAO;gBACPC,YAAY;gBACZS,MAAM;gBACNC,OAAO;gBACPC,OAAO;gBACPC,aAAa;gBACbT,YAAY,CAAC,EAAEf,GAAG,EAAyB;wBAGtCA,gBAAAA;yCAFH,MAAC7D;wBAAM6E,IAAI;4BAAES,YAAY;4BAAYX,UAAU;4BAAGQ,OAAO;wBAAE;;0CACzD,KAAClF;gCAAW6E,SAAQ;0CAAS1F,SAASyE,IAAI5B,UAAU;;4BACnD4B,EAAAA,aAAAA,IAAI/C,KAAK,sBAAT+C,iBAAAA,WAAW0B,GAAG,qBAAd1B,eAAgB2B,IAAI,MAAK,gBAAgBrG,cAAcsG,aAAa,CAAC5B,qBACpE,KAAC5D;gCAAW6E,SAAQ;gCAAUG,OAAM;gCAAiBF,MAAM;0CACxD;iCAED;;;;YAGV;YACA;gBACEP,OAAO;gBACPC,YAAY;gBACZU,OAAO;gBACPP,YAAY,CAAC,EAAEf,GAAG,EAAyB,iBACzC,KAAC9D;wBACC2F,MAAK;wBACLZ,SAAQ;wBACR9C,OAAO6B,IAAI9B,WAAW;wBACtBkD,OAAO9F,cAAcwG,kBAAkB,CAAC9B,IAAIhC,SAAS,CAAC;;YAG5D;YACA;gBACE2C,OAAO;gBACPC,YAAY;gBACZS,MAAM;gBACNC,OAAO;gBACPC,OAAO;gBACPC,aAAa;gBACbT,YAAY,CAAC,EAAEf,GAAG,EAAyB,iBACzC,KAAC5D;wBAAW6E,SAAQ;wBAAQC,MAAM;kCAC/BlB,IAAIzB,WAAW,GAAG,IAAIwD,KAAK/B,IAAIzB,WAAW,EAAEyD,kBAAkB,KAAK;;YAG1E;SACD,EACD,EAAE;IAGJ,MAAMC,QAAQ7E,WAAW,aAAaiC,KAAKtB,MAAM,KAAK;IAEtD,qBACE,MAACrC;QACCwG,QAAQ;QACRC,WAAW;QACXC,MAAM3G,eAAe,UAAU;YAC7B4G,QAAQ;YACRC,OAAO;YACPC,SACE,wFACA;QACJ;QACAC,aAAa;YACXC,sBACE,KAAC9G;gBAAe+G,SAAS,CAAC5D;gBAAS6D,QAAQ5F;0BACzC,cAAA,KAACd;oBAAO4F,MAAK;oBAAQZ,SAAQ;oBAAY2B,SAAS,IAAM/C,UAAU;8BAC/D;;;QAIT;QACAgD,cAAc;QACdC,cAAc;;YAEbb,sBACC,KAAC7F;gBAAW6E,SAAQ;gBAAQG,OAAM;0BAC/B,sFACC;+BAGJ,MAACjF;gBAAM4G,SAAS;;oBACbnE,OAAOoE,SAAS,iBACf,KAAC5G;wBAAW6E,SAAQ;wBAAUG,OAAM;kCACjC;yBAED;kCACJ,KAACxF;wBACCqH,cAAW;wBACX5D,MAAMA;wBACNqB,SAASA;wBACTwC,SAAS9F,WAAW;wBACpB+F,QAAQ,CAACC,KAAOvD,UAAUuD;wBAC1BC,WAAW;wBACXC,cAAc;4BAAEC,SAAS;gCAAEC,WAAW;oCAAC;wCAAE7C,OAAO;wCAAe8C,MAAM;oCAAO;iCAAE;4BAAC;wBAAE;wBACjFC,aAAY;;;;YAKjB9D,WAAW,SAASE,0BACnB,KAAClD;gBAECiC,QAAQA;gBACR5B,OAAO6C;gBACPL,UAAUA;gBACVX,SAASA;gBACTC,SAASA;gBACT4E,SAAS,IAAM9D,UAAU;eANpBD,UAQL;;;AAGV;AACAlB,eAAekF,WAAW,GAAG;AAE7B,eAAelF,eAAc"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/host-quotes-card.component.tsx"],"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\n'use client'\n\nimport * as CommerceModel from '../../model'\nimport { posMoney } from '../../model/commerce-pos-ops'\nimport { commerceSitePermissionRefusal } from '../../model/plugin-permissions'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { BlockedControl } from '@aglyn/shared-ui-jsx/components/blocked-control.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n ceilingedWindow,\n collectionCeiling,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, query, where } from 'firebase/firestore'\nimport { useSearchParams } from 'next/navigation'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport QuoteEditorDialog, { type QuoteRow } from './quote-editor-dialog.component'\n\nexport interface HostQuotesCardProps {\n hostId: string\n /** Holds \"Create and edit quotes\" (`commerce.quotes`). */\n canEdit: boolean\n /** Holds \"Send quotes and invoices to customers\" (`commerce.quotes.send`). */\n canSend: boolean\n}\n\n/**\n * How many quotes the list reads. The whole set is read and the grid sorts\n * it by any column; a store past this many is told the list stops short.\n */\nconst QUOTE_CEILING = 500\n\n/** The products the editor's picker offers. */\nconst PRODUCT_WINDOW = 200\n\nconst REFUSAL = commerceSitePermissionRefusal('commerce.quotes')\n\ninterface QuoteListRow extends QuoteRow {\n customerLabel: string\n firstItem: string\n moreItems: number\n statusKey: ReturnType<typeof CommerceModel.quoteDisplayStatus>['key']\n statusLabel: string\n totalCents: number\n updatedAtMs: number\n}\n\n/** One quote as its row draws and sorts it. */\nfunction quoteListRow(quote: QuoteRow): QuoteListRow {\n const customer = quote.quote?.customer\n const status = CommerceModel.quoteDisplayStatus(quote)\n const items = quote.lineItems ?? []\n return {\n ...quote,\n customerLabel: customer?.name || customer?.company || customer?.email || '',\n firstItem: items[0]?.name ?? '',\n moreItems: Math.max(0, items.length - 1),\n statusKey: status.key,\n statusLabel: status.label,\n totalCents: Number(quote.totals?.totalCents ?? 0),\n // The customer's latest answer counts as an update (AGL-4096), so a quote\n // they just approved or asked changes to sorts to the top.\n updatedAtMs: Math.max(\n Number(quote.quote?.updatedAtMs ?? 0),\n Number(CommerceModel.latestQuoteResponse(quote.quote)?.atMs ?? 0),\n ),\n }\n}\n\n/**\n * Quotes (AGL-4093): prices put to a customer before they buy, beside the\n * orders they become. A quote is an unpaid order on the `quote` channel, so\n * this reads `hosts/{hostId}/orders` for that channel alone; the Orders list\n * above never shows one until it is paid.\n */\nexport function HostQuotesCard(props: HostQuotesCardProps) {\n const { hostId, canEdit, canSend } = props\n const firestore = useFirestore()\n const { data: quoteDocs, status } = useFirestoreCollection<QuoteRow>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'orders'),\n where('channel', '==', 'quote'),\n limit(QUOTE_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const quotes = useMemo(() => ceilingedWindow<QuoteRow>(quoteDocs ?? undefined, QUOTE_CEILING), [quoteDocs])\n const rows = useMemo(() => Array.from(quotes.rows, quoteListRow), [quotes])\n\n const { data: productDocs } = useFirestoreCollection<Record<string, any>>(\n () => collectionCeiling(collection(firestore, 'hosts', hostId, 'products'), PRODUCT_WINDOW),\n [firestore, hostId],\n { idField: '$id' },\n )\n const products = useMemo(\n () => ceilingedWindow<Record<string, any>>(productDocs ?? undefined, PRODUCT_WINDOW).rows.filter((product) => !product['deletedAt']),\n [productDocs],\n )\n\n /** The quote open in the editor: its id, or `new`. */\n const [openId, setOpenId] = useState<string | null>(null)\n const openQuote = openId && openId !== 'new' ? (quotes.rows.find((row) => row.$id === openId) ?? null) : null\n\n // A manager notice opens its quote (AGL-4096): `?quote=<id>` on the Orders\n // page, once that quote has loaded. Read from the router, so a notice\n // followed while this page is already open opens its quote too; once per\n // id, so closing the dialog does not reopen it.\n const searchParams = useSearchParams()\n const linkedId = searchParams?.get('quote') ?? ''\n const opened = useRef('')\n useEffect(() => {\n if (!linkedId || opened.current === linkedId || !quotes.rows.some((row) => row.$id === linkedId)) return\n opened.current = linkedId\n setOpenId(linkedId)\n }, [linkedId, quotes.rows])\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'number',\n headerName: 'Quote',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {CommerceModel.formatQuoteNumber(row, row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.moreItems ? `${row.firstItem} and more` : row.firstItem}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'customerLabel',\n headerName: 'Customer',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Typography variant=\"body2\" noWrap>\n {row.customerLabel || '—'}\n </Typography>\n ),\n },\n {\n field: 'totalCents',\n headerName: 'Total',\n type: 'number',\n width: 140,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Stack sx={{ alignItems: 'flex-end', minWidth: 0, width: 1 }}>\n <Typography variant=\"body2\">{posMoney(row.totalCents)}</Typography>\n {row.quote?.tax?.kind === 'at-payment' && CommerceModel.isUnpaidQuote(row) ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {'plus tax at payment'}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'statusLabel',\n headerName: 'Status',\n width: 180,\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={row.statusLabel}\n color={CommerceModel.QUOTE_STATUS_COLOR[row.statusKey]}\n />\n ),\n },\n {\n field: 'updatedAtMs',\n headerName: 'Updated',\n type: 'number',\n width: 140,\n align: 'left',\n headerAlign: 'left',\n renderCell: ({ row }: { row: QuoteListRow }) => (\n <Typography variant=\"body2\" noWrap>\n {row.updatedAtMs ? new Date(row.updatedAtMs).toLocaleDateString() : '—'}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const empty = status === 'success' && rows.length === 0\n\n return (\n <CardDisplay\n header={'Quotes'}\n subheader={'Prices for a customer’s order before they buy'}\n help={pluginDocsHelp('quotes', {\n anchor: '#make-a-quote',\n title: 'Quotes',\n excerpt:\n 'Put a price to a customer with your products, items typed for the job and anything ' +\n 'included. A quote becomes an order when it is paid.',\n })}\n HeaderProps={{\n action: (\n <BlockedControl blocked={!canEdit} reason={REFUSAL}>\n <Button size=\"small\" variant=\"contained\" onClick={() => setOpenId('new')}>\n {'New quote'}\n </Button>\n </BlockedControl>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n {empty ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No quotes yet. A quote prices a customer’s order before they buy: your products, ' +\n 'items typed for the job, and anything you include at no charge.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n {quotes.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This store has more quotes than the list shows at once.'}\n </Typography>\n ) : null}\n <ListTable\n aria-label=\"Quotes\"\n rows={rows}\n columns={columns}\n loading={status === 'loading'}\n onOpen={(id) => setOpenId(id)}\n quickFilter\n initialState={{ sorting: { sortModel: [{ field: 'updatedAtMs', sort: 'desc' }] } }}\n noRowsLabel=\"No quotes match\"\n />\n </Stack>\n )}\n {/* A quote deleted while open closes its editor rather than turn it into a new one. */}\n {openId === 'new' || openQuote ? (\n <QuoteEditorDialog\n key={openId}\n hostId={hostId}\n quote={openQuote}\n products={products}\n canEdit={canEdit}\n canSend={canSend}\n onClose={() => setOpenId(null)}\n />\n ) : null}\n </CardDisplay>\n )\n}\nHostQuotesCard.displayName = 'HostQuotesCard'\n\nexport default HostQuotesCard\n"],"names":["CommerceModel","posMoney","commerceSitePermissionRefusal","pluginDocsHelp","CardDisplay","BlockedControl","ListTable","ceilingedWindow","collectionCeiling","useFirestore","useFirestoreCollection","Button","Chip","Stack","Typography","collection","limit","query","where","useSearchParams","useEffect","useMemo","useRef","useState","QuoteEditorDialog","QUOTE_CEILING","PRODUCT_WINDOW","REFUSAL","quoteListRow","quote","items","customer","status","quoteDisplayStatus","lineItems","customerLabel","name","company","email","firstItem","moreItems","Math","max","length","statusKey","key","statusLabel","label","totalCents","Number","totals","updatedAtMs","latestQuoteResponse","atMs","HostQuotesCard","props","quotes","hostId","canEdit","canSend","firestore","data","quoteDocs","idField","undefined","rows","Array","from","productDocs","products","filter","product","openId","setOpenId","openQuote","find","row","$id","searchParams","linkedId","get","opened","current","some","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","formatQuoteNumber","color","type","width","align","headerAlign","alignItems","tax","kind","isUnpaidQuote","size","QUOTE_STATUS_COLOR","Date","toLocaleDateString","empty","header","subheader","help","anchor","title","excerpt","HeaderProps","action","blocked","reason","onClick","contentGutterX","contentGutterY","spacing","truncated","aria-label","loading","onOpen","id","quickFilter","initialState","sorting","sortModel","sort","noRowsLabel","onClose","displayName"],"mappings":"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"}
|
|
@@ -24,6 +24,7 @@ import { registerClosureNoticeSource } from "@aglyn/aglyn/plugin-manager/plugin-
|
|
|
24
24
|
import { registerCustomerDisplayVerifier } from "@aglyn/aglyn/plugin-manager/plugin-customer-display-verifier";
|
|
25
25
|
import { registerPluginEventHandler } from "@aglyn/aglyn/plugin-manager/plugin-events";
|
|
26
26
|
import { registerPluginSearchSources } from "@aglyn/aglyn/plugin-manager/plugin-search-sources";
|
|
27
|
+
import { registerPluginConsoleCron } from "@aglyn/aglyn/plugin-manager/plugin-console-crons";
|
|
27
28
|
import { COMMERCE_SERVER_SEARCH_SOURCES } from "./model/commerce-server-search-sources.js";
|
|
28
29
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
29
30
|
import { ORDER_HISTORY_CONTRACT } from "./model/order-history.js";
|
|
@@ -50,6 +51,20 @@ import { registerCommerceTransferResources } from "./transfer/register-transfer-
|
|
|
50
51
|
* descriptions when the document is first built. Registering again replaces
|
|
51
52
|
* this plugin's own entries.
|
|
52
53
|
*/ export function registerCommerceConsoleServerDeclarations() {
|
|
54
|
+
// Files a customer attached to an answer on a quote and never sent
|
|
55
|
+
// (AGL-4096): deleted a day after their latest upload, on the same tick as
|
|
56
|
+
// the forms plugin's sweep of forms never sent.
|
|
57
|
+
registerPluginConsoleCron({
|
|
58
|
+
id: 'commerce-quote-upload-sweep',
|
|
59
|
+
label: 'Unsent quote files',
|
|
60
|
+
drives: 'Deletes the files a customer attached to an answer on a quote and never sent, a day after the last one went up. If it stops, those files stay in the site’s private storage.',
|
|
61
|
+
run: async (context)=>{
|
|
62
|
+
const { defaultQuoteUploadSweepDeps, sweepQuoteUploads } = await import("./server/quote-upload-sweep.js");
|
|
63
|
+
return sweepQuoteUploads(defaultQuoteUploadSweepDeps(), context);
|
|
64
|
+
}
|
|
65
|
+
}, {
|
|
66
|
+
pluginId: BUNDLE_ID
|
|
67
|
+
});
|
|
53
68
|
// Commerce's share of a person erasure (AGL-2623, AGL-3080): every order the
|
|
54
69
|
// person placed keeps its amounts, with the buyer taken off it. Required,
|
|
55
70
|
// and loaded with the first erasure.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/commerce/src/lib/declarations.console-server.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\n// The registry's own module, not the data layer's barrel: boot needs the\n// registry, not the whole server surface.\nimport { registerApiV1SiteResource } from '@aglyn/tenant-data-admin/server/api-v1-resources'\nimport { registerPluginOrgEraser } from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport { registerPluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { registerPluginUserEraser } from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { registerPluginService } from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport { registerClosureNoticeSource } from '@aglyn/aglyn/plugin-manager/plugin-closure-notices'\nimport { registerCustomerDisplayVerifier } from '@aglyn/aglyn/plugin-manager/plugin-customer-display-verifier'\nimport { registerPluginEventHandler } from '@aglyn/aglyn/plugin-manager/plugin-events'\nimport { registerPluginSearchSources } from '@aglyn/aglyn/plugin-manager/plugin-search-sources'\nimport { COMMERCE_SERVER_SEARCH_SOURCES } from './model/commerce-server-search-sources'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { ORDER_HISTORY_CONTRACT } from './model/order-history'\nimport { parsePosHardwareInPersonConfig } from './model/pos-hardware'\nimport { registerCommerceTransferResources } from './transfer/register-transfer-resources'\n\n/**\n * The commerce plugin's CONSOLE-ONLY server declarations, named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot. The tenant runtime registers none of it.\n *\n * It serves a site's products and orders on the customer REST API,\n * `/v1/sites/{siteId}/products/…` and `/v1/sites/{siteId}/orders/…`: the\n * console's router owns the pipeline in front — the key, the plan's API\n * access, the quota, the rate limit, the error envelope — and the site's\n * ownership, and hands every request under each resource here.\n *\n * No plan feature is named on the registrations, though both need\n * `commerce`: the handlers ask the key's scope FIRST and the plan second\n * (AGL-900, AGL-1928), so a key without the scope is told about the scope\n * whatever plan its organization is on, and the router would ask in the\n * opposite order.\n *\n * Light at boot: the handlers are imported with their first request and the\n * descriptions when the document is first built. Registering again replaces\n * this plugin's own entries.\n */\nexport function registerCommerceConsoleServerDeclarations(): void {\n // Commerce's share of a person erasure (AGL-2623, AGL-3080): every order the\n // person placed keeps its amounts, with the buyer taken off it. Required,\n // and loaded with the first erasure.\n registerPluginPersonEraser(\n async (request) => (await import('./server/person-eraser')).commercePersonEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // Commerce's share of an ACCOUNT erasure (AGL-4038): every POS hardware\n // order the person placed that has not shipped is canceled and refunded in\n // full, then every one keeps its amounts and its tax region, with the\n // buyer taken off it. Loaded with the first erasure.\n registerPluginUserEraser(\n async (request) => (await import('./server/pos-hardware-user-eraser')).posHardwareUserEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // Commerce's share of a WORKSPACE erasure (AGL-4038): every hardware order\n // the workspace bought that has not shipped is canceled and refunded in\n // full before the erasure deletes it, and the buyer and staff are told.\n // Never fails the erasure. Loaded with the first erasure.\n registerPluginOrgEraser(\n async (request) => (await import('./server/pos-hardware-closure')).posHardwareOrgEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // A workspace erasure turns 4G off at Stripe for every one of its stores\n // that had it (AGL-4038), so the platform stops paying for readers nobody\n // will be charged for. Never fails the erasure. Loaded with the first one.\n registerPluginOrgEraser(\n async (request) => (await import('./server/pos-cellular-billing')).posCellularOrgEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // …and says so BEFORE the closure (AGL-4038): the delete-workspace and\n // close-account screens list the unshipped hardware orders a closure will\n // cancel and refund. Loaded with the first screen that asks.\n registerClosureNoticeSource(\n {\n notices: async (subject) =>\n (await import('./server/pos-hardware-closure-notices')).posHardwareClosureNoticeSource.notices(subject),\n },\n { pluginId: BUNDLE_ID },\n )\n // A card reader bought in person before its buyer had a workspace\n // (AGL-4038) joins the workspace they create, by their account or their\n // verified address. Loaded with the first workspace created while the\n // in-person sale is on.\n registerPluginEventHandler(\n 'org.created',\n async (payload) => {\n const config = parsePosHardwareInPersonConfig(process.env)\n if (!config) return\n const { claimPosHardwareForNewWorkspace } = await import('./server/pos-hardware-attach')\n await claimPosHardwareForNewWorkspace({\n config,\n orgId: payload.orgId,\n ownerUid: payload.owner.uid,\n ownerEmail: payload.owner.email,\n emailVerified: payload.owner.emailVerified,\n })\n },\n { pluginId: BUNDLE_ID },\n )\n // Who a paired customer display is (AGL-4042), for another plugin's\n // server acting for the screen the register put on it: the display's own\n // token is the credential. Loaded with the first screen that asks.\n registerCustomerDisplayVerifier(\n { verify: async (credential) => (await import('./server/pos-display')).verifyPosCustomerDisplay(credential) },\n { pluginId: BUNDLE_ID },\n )\n // A site's earlier orders, for a plugin that reads history behind the\n // order events (AGL-3614); the reader is loaded with the first page asked.\n registerPluginService(\n ORDER_HISTORY_CONTRACT,\n { listOrders: async (request) => (await import('./server/order-history')).listOrderHistory(request) },\n { pluginId: BUNDLE_ID },\n )\n registerApiV1SiteResource(\n 'products',\n {\n handle: async (...args) =>\n (await import('./server/api-v1/orders-and-products')).handleProducts(...args),\n describe: async () =>\n (await import('./server/api-v1/openapi')).PRODUCTS_API_V1_DESCRIPTION,\n },\n { pluginId: BUNDLE_ID },\n )\n registerApiV1SiteResource(\n 'orders',\n {\n handle: async (...args) =>\n (await import('./server/api-v1/orders-and-products')).handleOrders(...args),\n describe: async () =>\n (await import('./server/api-v1/openapi')).ORDERS_API_V1_DESCRIPTION,\n },\n { pluginId: BUNDLE_ID },\n )\n // Products, categories, orders, discounts, coupons and gift cards on the\n // import wizard and the export dialog (AGL-3531): the transfer routes are\n // the console's alone, so they register here.\n registerCommerceTransferResources()\n // What the native apps' one box finds in a store (AGL-3943): products and\n // orders, each under its own plan flag and, for orders, the site role that\n // reads them.\n registerPluginSearchSources(COMMERCE_SERVER_SEARCH_SOURCES, { pluginId: BUNDLE_ID })\n}\n"],"names":["registerApiV1SiteResource","registerPluginOrgEraser","registerPluginPersonEraser","registerPluginUserEraser","registerPluginService","registerClosureNoticeSource","registerCustomerDisplayVerifier","registerPluginEventHandler","registerPluginSearchSources","COMMERCE_SERVER_SEARCH_SOURCES","BUNDLE_ID","ORDER_HISTORY_CONTRACT","parsePosHardwareInPersonConfig","registerCommerceTransferResources","registerCommerceConsoleServerDeclarations","request","commercePersonEraser","pluginId","posHardwareUserEraser","posHardwareOrgEraser","posCellularOrgEraser","notices","subject","posHardwareClosureNoticeSource","payload","config","process","env","claimPosHardwareForNewWorkspace","orgId","ownerUid","owner","uid","ownerEmail","email","emailVerified","verify","credential","verifyPosCustomerDisplay","listOrders","listOrderHistory","handle","args","handleProducts","describe","PRODUCTS_API_V1_DESCRIPTION","handleOrders","ORDERS_API_V1_DESCRIPTION"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,0CAA0C;AAC1C,SAASA,yBAAyB,QAAQ,mDAAkD;AAC5F,SAASC,uBAAuB,QAAQ,iDAAgD;AACxF,SAASC,0BAA0B,QAAQ,oDAAmD;AAC9F,SAASC,wBAAwB,QAAQ,kDAAiD;AAC1F,SAASC,qBAAqB,QAAQ,8CAA6C;AACnF,SAASC,2BAA2B,QAAQ,qDAAoD;AAChG,SAASC,+BAA+B,QAAQ,+DAA8D;AAC9G,SAASC,0BAA0B,QAAQ,4CAA2C;AACtF,SAASC,2BAA2B,QAAQ,oDAAmD;AAC/F,SAASC,8BAA8B,QAAQ,4CAAwC;AACvF,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,sBAAsB,QAAQ,2BAAuB;AAC9D,SAASC,8BAA8B,QAAQ,0BAAsB;AACrE,SAASC,iCAAiC,QAAQ,4CAAwC;AAE1F;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC;IACd,6EAA6E;IAC7E,0EAA0E;IAC1E,qCAAqC;IACrCZ,2BACE,OAAOa,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,4BAAwB,EAAGC,oBAAoB,CAACD,UACjF;QAAEE,UAAUP;IAAU;IAExB,wEAAwE;IACxE,2EAA2E;IAC3E,sEAAsE;IACtE,qDAAqD;IACrDP,yBACE,OAAOY,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,uCAAmC,EAAGG,qBAAqB,CAACH,UAC7F;QAAEE,UAAUP;IAAU;IAExB,2EAA2E;IAC3E,wEAAwE;IACxE,wEAAwE;IACxE,0DAA0D;IAC1DT,wBACE,OAAOc,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGI,oBAAoB,CAACJ,UACxF;QAAEE,UAAUP;IAAU;IAExB,yEAAyE;IACzE,0EAA0E;IAC1E,2EAA2E;IAC3ET,wBACE,OAAOc,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGK,oBAAoB,CAACL,UACxF;QAAEE,UAAUP;IAAU;IAExB,uEAAuE;IACvE,0EAA0E;IAC1E,6DAA6D;IAC7DL,4BACE;QACEgB,SAAS,OAAOC,UACd,AAAC,CAAA,MAAM,MAAM,CAAC,2CAAuC,EAAGC,8BAA8B,CAACF,OAAO,CAACC;IACnG,GACA;QAAEL,UAAUP;IAAU;IAExB,kEAAkE;IAClE,wEAAwE;IACxE,sEAAsE;IACtE,wBAAwB;IACxBH,2BACE,eACA,OAAOiB;QACL,MAAMC,SAASb,+BAA+Bc,QAAQC,GAAG;QACzD,IAAI,CAACF,QAAQ;QACb,MAAM,EAAEG,+BAA+B,EAAE,GAAG,MAAM,MAAM,CAAC;QACzD,MAAMA,gCAAgC;YACpCH;YACAI,OAAOL,QAAQK,KAAK;YACpBC,UAAUN,QAAQO,KAAK,CAACC,GAAG;YAC3BC,YAAYT,QAAQO,KAAK,CAACG,KAAK;YAC/BC,eAAeX,QAAQO,KAAK,CAACI,aAAa;QAC5C;IACF,GACA;QAAElB,UAAUP;IAAU;IAExB,oEAAoE;IACpE,yEAAyE;IACzE,mEAAmE;IACnEJ,gCACE;QAAE8B,QAAQ,OAAOC,aAAe,AAAC,CAAA,MAAM,MAAM,CAAC,0BAAsB,EAAGC,wBAAwB,CAACD;IAAY,GAC5G;QAAEpB,UAAUP;IAAU;IAExB,sEAAsE;IACtE,2EAA2E;IAC3EN,sBACEO,wBACA;QAAE4B,YAAY,OAAOxB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,4BAAwB,EAAGyB,gBAAgB,CAACzB;IAAS,GACpG;QAAEE,UAAUP;IAAU;IAExBV,0BACE,YACA;QACEyC,QAAQ,OAAO,GAAGC,OAChB,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGC,cAAc,IAAID;QAC1EE,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGC,2BAA2B;IACzE,GACA;QAAE5B,UAAUP;IAAU;IAExBV,0BACE,UACA;QACEyC,QAAQ,OAAO,GAAGC,OAChB,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGI,YAAY,IAAIJ;QACxEE,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGG,yBAAyB;IACvE,GACA;QAAE9B,UAAUP;IAAU;IAExB,yEAAyE;IACzE,0EAA0E;IAC1E,8CAA8C;IAC9CG;IACA,0EAA0E;IAC1E,2EAA2E;IAC3E,cAAc;IACdL,4BAA4BC,gCAAgC;QAAEQ,UAAUP;IAAU;AACpF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/commerce/src/lib/declarations.console-server.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\n// The registry's own module, not the data layer's barrel: boot needs the\n// registry, not the whole server surface.\nimport { registerApiV1SiteResource } from '@aglyn/tenant-data-admin/server/api-v1-resources'\nimport { registerPluginOrgEraser } from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport { registerPluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { registerPluginUserEraser } from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { registerPluginService } from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport { registerClosureNoticeSource } from '@aglyn/aglyn/plugin-manager/plugin-closure-notices'\nimport { registerCustomerDisplayVerifier } from '@aglyn/aglyn/plugin-manager/plugin-customer-display-verifier'\nimport { registerPluginEventHandler } from '@aglyn/aglyn/plugin-manager/plugin-events'\nimport { registerPluginSearchSources } from '@aglyn/aglyn/plugin-manager/plugin-search-sources'\nimport { registerPluginConsoleCron } from '@aglyn/aglyn/plugin-manager/plugin-console-crons'\nimport { COMMERCE_SERVER_SEARCH_SOURCES } from './model/commerce-server-search-sources'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { ORDER_HISTORY_CONTRACT } from './model/order-history'\nimport { parsePosHardwareInPersonConfig } from './model/pos-hardware'\nimport { registerCommerceTransferResources } from './transfer/register-transfer-resources'\n\n/**\n * The commerce plugin's CONSOLE-ONLY server declarations, named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot. The tenant runtime registers none of it.\n *\n * It serves a site's products and orders on the customer REST API,\n * `/v1/sites/{siteId}/products/…` and `/v1/sites/{siteId}/orders/…`: the\n * console's router owns the pipeline in front — the key, the plan's API\n * access, the quota, the rate limit, the error envelope — and the site's\n * ownership, and hands every request under each resource here.\n *\n * No plan feature is named on the registrations, though both need\n * `commerce`: the handlers ask the key's scope FIRST and the plan second\n * (AGL-900, AGL-1928), so a key without the scope is told about the scope\n * whatever plan its organization is on, and the router would ask in the\n * opposite order.\n *\n * Light at boot: the handlers are imported with their first request and the\n * descriptions when the document is first built. Registering again replaces\n * this plugin's own entries.\n */\nexport function registerCommerceConsoleServerDeclarations(): void {\n // Files a customer attached to an answer on a quote and never sent\n // (AGL-4096): deleted a day after their latest upload, on the same tick as\n // the forms plugin's sweep of forms never sent.\n registerPluginConsoleCron(\n {\n id: 'commerce-quote-upload-sweep',\n label: 'Unsent quote files',\n drives:\n 'Deletes the files a customer attached to an answer on a quote and never sent, a day after the last one went up. If it stops, those files stay in the site’s private storage.',\n run: async (context) => {\n const { defaultQuoteUploadSweepDeps, sweepQuoteUploads } = await import('./server/quote-upload-sweep')\n return sweepQuoteUploads(defaultQuoteUploadSweepDeps(), context)\n },\n },\n { pluginId: BUNDLE_ID },\n )\n // Commerce's share of a person erasure (AGL-2623, AGL-3080): every order the\n // person placed keeps its amounts, with the buyer taken off it. Required,\n // and loaded with the first erasure.\n registerPluginPersonEraser(\n async (request) => (await import('./server/person-eraser')).commercePersonEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // Commerce's share of an ACCOUNT erasure (AGL-4038): every POS hardware\n // order the person placed that has not shipped is canceled and refunded in\n // full, then every one keeps its amounts and its tax region, with the\n // buyer taken off it. Loaded with the first erasure.\n registerPluginUserEraser(\n async (request) => (await import('./server/pos-hardware-user-eraser')).posHardwareUserEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // Commerce's share of a WORKSPACE erasure (AGL-4038): every hardware order\n // the workspace bought that has not shipped is canceled and refunded in\n // full before the erasure deletes it, and the buyer and staff are told.\n // Never fails the erasure. Loaded with the first erasure.\n registerPluginOrgEraser(\n async (request) => (await import('./server/pos-hardware-closure')).posHardwareOrgEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // A workspace erasure turns 4G off at Stripe for every one of its stores\n // that had it (AGL-4038), so the platform stops paying for readers nobody\n // will be charged for. Never fails the erasure. Loaded with the first one.\n registerPluginOrgEraser(\n async (request) => (await import('./server/pos-cellular-billing')).posCellularOrgEraser(request),\n { pluginId: BUNDLE_ID },\n )\n // …and says so BEFORE the closure (AGL-4038): the delete-workspace and\n // close-account screens list the unshipped hardware orders a closure will\n // cancel and refund. Loaded with the first screen that asks.\n registerClosureNoticeSource(\n {\n notices: async (subject) =>\n (await import('./server/pos-hardware-closure-notices')).posHardwareClosureNoticeSource.notices(subject),\n },\n { pluginId: BUNDLE_ID },\n )\n // A card reader bought in person before its buyer had a workspace\n // (AGL-4038) joins the workspace they create, by their account or their\n // verified address. Loaded with the first workspace created while the\n // in-person sale is on.\n registerPluginEventHandler(\n 'org.created',\n async (payload) => {\n const config = parsePosHardwareInPersonConfig(process.env)\n if (!config) return\n const { claimPosHardwareForNewWorkspace } = await import('./server/pos-hardware-attach')\n await claimPosHardwareForNewWorkspace({\n config,\n orgId: payload.orgId,\n ownerUid: payload.owner.uid,\n ownerEmail: payload.owner.email,\n emailVerified: payload.owner.emailVerified,\n })\n },\n { pluginId: BUNDLE_ID },\n )\n // Who a paired customer display is (AGL-4042), for another plugin's\n // server acting for the screen the register put on it: the display's own\n // token is the credential. Loaded with the first screen that asks.\n registerCustomerDisplayVerifier(\n { verify: async (credential) => (await import('./server/pos-display')).verifyPosCustomerDisplay(credential) },\n { pluginId: BUNDLE_ID },\n )\n // A site's earlier orders, for a plugin that reads history behind the\n // order events (AGL-3614); the reader is loaded with the first page asked.\n registerPluginService(\n ORDER_HISTORY_CONTRACT,\n { listOrders: async (request) => (await import('./server/order-history')).listOrderHistory(request) },\n { pluginId: BUNDLE_ID },\n )\n registerApiV1SiteResource(\n 'products',\n {\n handle: async (...args) =>\n (await import('./server/api-v1/orders-and-products')).handleProducts(...args),\n describe: async () =>\n (await import('./server/api-v1/openapi')).PRODUCTS_API_V1_DESCRIPTION,\n },\n { pluginId: BUNDLE_ID },\n )\n registerApiV1SiteResource(\n 'orders',\n {\n handle: async (...args) =>\n (await import('./server/api-v1/orders-and-products')).handleOrders(...args),\n describe: async () =>\n (await import('./server/api-v1/openapi')).ORDERS_API_V1_DESCRIPTION,\n },\n { pluginId: BUNDLE_ID },\n )\n // Products, categories, orders, discounts, coupons and gift cards on the\n // import wizard and the export dialog (AGL-3531): the transfer routes are\n // the console's alone, so they register here.\n registerCommerceTransferResources()\n // What the native apps' one box finds in a store (AGL-3943): products and\n // orders, each under its own plan flag and, for orders, the site role that\n // reads them.\n registerPluginSearchSources(COMMERCE_SERVER_SEARCH_SOURCES, { pluginId: BUNDLE_ID })\n}\n"],"names":["registerApiV1SiteResource","registerPluginOrgEraser","registerPluginPersonEraser","registerPluginUserEraser","registerPluginService","registerClosureNoticeSource","registerCustomerDisplayVerifier","registerPluginEventHandler","registerPluginSearchSources","registerPluginConsoleCron","COMMERCE_SERVER_SEARCH_SOURCES","BUNDLE_ID","ORDER_HISTORY_CONTRACT","parsePosHardwareInPersonConfig","registerCommerceTransferResources","registerCommerceConsoleServerDeclarations","id","label","drives","run","context","defaultQuoteUploadSweepDeps","sweepQuoteUploads","pluginId","request","commercePersonEraser","posHardwareUserEraser","posHardwareOrgEraser","posCellularOrgEraser","notices","subject","posHardwareClosureNoticeSource","payload","config","process","env","claimPosHardwareForNewWorkspace","orgId","ownerUid","owner","uid","ownerEmail","email","emailVerified","verify","credential","verifyPosCustomerDisplay","listOrders","listOrderHistory","handle","args","handleProducts","describe","PRODUCTS_API_V1_DESCRIPTION","handleOrders","ORDERS_API_V1_DESCRIPTION"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,0CAA0C;AAC1C,SAASA,yBAAyB,QAAQ,mDAAkD;AAC5F,SAASC,uBAAuB,QAAQ,iDAAgD;AACxF,SAASC,0BAA0B,QAAQ,oDAAmD;AAC9F,SAASC,wBAAwB,QAAQ,kDAAiD;AAC1F,SAASC,qBAAqB,QAAQ,8CAA6C;AACnF,SAASC,2BAA2B,QAAQ,qDAAoD;AAChG,SAASC,+BAA+B,QAAQ,+DAA8D;AAC9G,SAASC,0BAA0B,QAAQ,4CAA2C;AACtF,SAASC,2BAA2B,QAAQ,oDAAmD;AAC/F,SAASC,yBAAyB,QAAQ,mDAAkD;AAC5F,SAASC,8BAA8B,QAAQ,4CAAwC;AACvF,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,sBAAsB,QAAQ,2BAAuB;AAC9D,SAASC,8BAA8B,QAAQ,0BAAsB;AACrE,SAASC,iCAAiC,QAAQ,4CAAwC;AAE1F;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC;IACd,mEAAmE;IACnE,2EAA2E;IAC3E,gDAAgD;IAChDN,0BACE;QACEO,IAAI;QACJC,OAAO;QACPC,QACE;QACFC,KAAK,OAAOC;YACV,MAAM,EAAEC,2BAA2B,EAAEC,iBAAiB,EAAE,GAAG,MAAM,MAAM,CAAC;YACxE,OAAOA,kBAAkBD,+BAA+BD;QAC1D;IACF,GACA;QAAEG,UAAUZ;IAAU;IAExB,6EAA6E;IAC7E,0EAA0E;IAC1E,qCAAqC;IACrCT,2BACE,OAAOsB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,4BAAwB,EAAGC,oBAAoB,CAACD,UACjF;QAAED,UAAUZ;IAAU;IAExB,wEAAwE;IACxE,2EAA2E;IAC3E,sEAAsE;IACtE,qDAAqD;IACrDR,yBACE,OAAOqB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,uCAAmC,EAAGE,qBAAqB,CAACF,UAC7F;QAAED,UAAUZ;IAAU;IAExB,2EAA2E;IAC3E,wEAAwE;IACxE,wEAAwE;IACxE,0DAA0D;IAC1DV,wBACE,OAAOuB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGG,oBAAoB,CAACH,UACxF;QAAED,UAAUZ;IAAU;IAExB,yEAAyE;IACzE,0EAA0E;IAC1E,2EAA2E;IAC3EV,wBACE,OAAOuB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGI,oBAAoB,CAACJ,UACxF;QAAED,UAAUZ;IAAU;IAExB,uEAAuE;IACvE,0EAA0E;IAC1E,6DAA6D;IAC7DN,4BACE;QACEwB,SAAS,OAAOC,UACd,AAAC,CAAA,MAAM,MAAM,CAAC,2CAAuC,EAAGC,8BAA8B,CAACF,OAAO,CAACC;IACnG,GACA;QAAEP,UAAUZ;IAAU;IAExB,kEAAkE;IAClE,wEAAwE;IACxE,sEAAsE;IACtE,wBAAwB;IACxBJ,2BACE,eACA,OAAOyB;QACL,MAAMC,SAASpB,+BAA+BqB,QAAQC,GAAG;QACzD,IAAI,CAACF,QAAQ;QACb,MAAM,EAAEG,+BAA+B,EAAE,GAAG,MAAM,MAAM,CAAC;QACzD,MAAMA,gCAAgC;YACpCH;YACAI,OAAOL,QAAQK,KAAK;YACpBC,UAAUN,QAAQO,KAAK,CAACC,GAAG;YAC3BC,YAAYT,QAAQO,KAAK,CAACG,KAAK;YAC/BC,eAAeX,QAAQO,KAAK,CAACI,aAAa;QAC5C;IACF,GACA;QAAEpB,UAAUZ;IAAU;IAExB,oEAAoE;IACpE,yEAAyE;IACzE,mEAAmE;IACnEL,gCACE;QAAEsC,QAAQ,OAAOC,aAAe,AAAC,CAAA,MAAM,MAAM,CAAC,0BAAsB,EAAGC,wBAAwB,CAACD;IAAY,GAC5G;QAAEtB,UAAUZ;IAAU;IAExB,sEAAsE;IACtE,2EAA2E;IAC3EP,sBACEQ,wBACA;QAAEmC,YAAY,OAAOvB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,4BAAwB,EAAGwB,gBAAgB,CAACxB;IAAS,GACpG;QAAED,UAAUZ;IAAU;IAExBX,0BACE,YACA;QACEiD,QAAQ,OAAO,GAAGC,OAChB,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGC,cAAc,IAAID;QAC1EE,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGC,2BAA2B;IACzE,GACA;QAAE9B,UAAUZ;IAAU;IAExBX,0BACE,UACA;QACEiD,QAAQ,OAAO,GAAGC,OAChB,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGI,YAAY,IAAIJ;QACxEE,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGG,yBAAyB;IACvE,GACA;QAAEhC,UAAUZ;IAAU;IAExB,yEAAyE;IACzE,0EAA0E;IAC1E,8CAA8C;IAC9CG;IACA,0EAA0E;IAC1E,2EAA2E;IAC3E,cAAc;IACdN,4BAA4BE,gCAAgC;QAAEa,UAAUZ;IAAU;AACpF"}
|
|
@@ -55,6 +55,13 @@ export interface QuoteRevisionSnapshot {
|
|
|
55
55
|
}
|
|
56
56
|
/** The current revision's proofs. */
|
|
57
57
|
export declare function currentQuoteProofs(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined): QuoteProof[];
|
|
58
|
+
/**
|
|
59
|
+
* One of the proofs the quote has shown its customer, by id (AGL-4097): the
|
|
60
|
+
* current revision's, else an earlier revision's as its snapshot keeps it
|
|
61
|
+
* (`orders/{id}/revisions/{n}`). A proof the business took off a revision
|
|
62
|
+
* before the next one started is in neither, so it is not found.
|
|
63
|
+
*/
|
|
64
|
+
export declare function quoteProofInHistory(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined, snapshots: ReadonlyArray<Partial<Pick<QuoteRevisionSnapshot, 'proofs'>> | null | undefined>, proofId: string): QuoteProof | null;
|
|
58
65
|
/** The customer's latest answer to the current revision, if they gave one. */
|
|
59
66
|
export declare function latestQuoteResponse(quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined): QuoteCustomerResponse | null;
|
|
60
67
|
/** Whether the customer has answered the current revision. */
|
|
@@ -43,6 +43,21 @@ export const QUOTE_ARTWORK_PURPOSE = 'quote-artwork';
|
|
|
43
43
|
const revision = Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1);
|
|
44
44
|
return ((_ref1 = quote == null ? void 0 : quote.proofs) != null ? _ref1 : []).filter((proof)=>Number(proof == null ? void 0 : proof.revision) === revision);
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* One of the proofs the quote has shown its customer, by id (AGL-4097): the
|
|
48
|
+
* current revision's, else an earlier revision's as its snapshot keeps it
|
|
49
|
+
* (`orders/{id}/revisions/{n}`). A proof the business took off a revision
|
|
50
|
+
* before the next one started is in neither, so it is not found.
|
|
51
|
+
*/ export function quoteProofInHistory(quote, snapshots, proofId) {
|
|
52
|
+
if (!proofId) return null;
|
|
53
|
+
const current = currentQuoteProofs(quote).find((proof)=>(proof == null ? void 0 : proof.id) === proofId);
|
|
54
|
+
if (current) return current;
|
|
55
|
+
for (const snapshot of snapshots){
|
|
56
|
+
const kept = (Array.isArray(snapshot == null ? void 0 : snapshot.proofs) ? snapshot.proofs : []).find((proof)=>(proof == null ? void 0 : proof.id) === proofId);
|
|
57
|
+
if (kept) return kept;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
46
61
|
/** The customer's latest answer to the current revision, if they gave one. */ export function latestQuoteResponse(quote) {
|
|
47
62
|
var _ref, _ref1;
|
|
48
63
|
const revision = Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-quote-proofs.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 { appendOrderEvent, type HostOrder, type OrderAddress, type OrderLineItem, type OrderTotals } from './commerce-orders'\nimport {\n formatQuoteNumber,\n isQuote,\n isUnpaidQuote,\n type OrderQuote,\n type QuoteApproval,\n type QuoteCustomer,\n type QuoteCustomerResponse,\n type QuoteDiscount,\n type QuoteProof,\n type QuoteResponseFile,\n type QuoteShipping,\n type QuoteStage,\n type QuoteTaxBasis,\n} from './commerce-quotes'\nimport type { StorefrontTaxMode } from './commerce-tax-decision'\n\n/*==========================================\n * PROOFS, REVISIONS AND THE CUSTOMER'S ANSWER (AGL-4096).\n *\n * A quote's REVISION is the version the customer is looking at. The business\n * attaches proofs to the current revision; the customer approves them under\n * a typed name or asks for changes; the business then starts the next\n * revision, which keeps the one before exactly as it stood in\n * `hosts/{hostId}/orders/{id}/revisions/{n}` and starts the new one with no\n * proofs and no answer. Every revision is kept.\n *\n * Whether paying waits on an approval is the business's control\n * (`requireProofApproval`, AGL-4093) and `quotePaymentBlockedByApproval`\n * answers it; nothing here makes paying wait when it is off.\n *\n * Pure: the console route, the customer's route and (slice 5) the send that\n * starts a revision all call these, and write what they answer.\n *=========================================*/\n\n/** The most proofs one revision holds. */\nexport const QUOTE_MAX_PROOFS = 10\n\n/** The most answers the customer gives one revision; plenty for a change of mind. */\nexport const QUOTE_MAX_RESPONSES = 20\n\n/** The longest comment a customer may leave. */\nexport const QUOTE_MAX_COMMENT = 5_000\n\n/** The longest name an approval is signed with. */\nexport const QUOTE_MAX_APPROVER_NAME = 120\n\n/** What the visitor uploads are called for a quote: its proofs, and the customer's artwork. */\nexport const QUOTE_PROOF_PURPOSE = 'quote-proof'\nexport const QUOTE_ARTWORK_PURPOSE = 'quote-artwork'\n\n/** A revision as it stood when the next one started. */\nexport interface QuoteRevisionSnapshot {\n revision: number\n /** Where it stood with the business when it closed. */\n stage: QuoteStage\n lineItems: OrderLineItem[]\n totals: OrderTotals | null\n taxMode: StorefrontTaxMode | null\n tax: QuoteTaxBasis | null\n discount: QuoteDiscount | null\n shipping: QuoteShipping | null\n notes: string\n terms: string\n customer: QuoteCustomer | null\n billTo: OrderAddress | null\n shipTo: OrderAddress | null\n proofs: QuoteProof[]\n responses: QuoteCustomerResponse[]\n approval: QuoteApproval | null\n /** When the revision was last written by the business. */\n updatedAtMs: number\n /** When the next revision started, and who started it. */\n closedAtMs: number\n closedBy: string\n}\n\n/** The current revision's proofs. */\nexport function currentQuoteProofs(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined): QuoteProof[] {\n const revision = Number(quote?.revision ?? 1)\n return (quote?.proofs ?? []).filter((proof) => Number(proof?.revision) === revision)\n}\n\n/** The customer's latest answer to the current revision, if they gave one. */\nexport function latestQuoteResponse(\n quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined,\n): QuoteCustomerResponse | null {\n const revision = Number(quote?.revision ?? 1)\n const answers = (quote?.responses ?? []).filter((response) => Number(response?.revision) === revision)\n return answers.length ? answers[answers.length - 1] : null\n}\n\n/** Whether the customer has answered the current revision. */\nexport function quoteRevisionAnswered(quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined): boolean {\n return latestQuoteResponse(quote) !== null\n}\n\n/*------------------------------------------\n * The business's proofs\n *-----------------------------------------*/\n\nexport type QuoteProofRefusal = 'not-a-quote' | 'paid' | 'answered' | 'full' | 'unknown-proof'\n\n/** Why the current revision's proofs cannot be changed now, if they cannot. */\nexport function quoteProofChangeRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n change: 'add' | 'remove',\n): QuoteProofRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (quoteRevisionAnswered(order.quote)) return 'answered'\n if (change === 'add' && currentQuoteProofs(order.quote).length >= QUOTE_MAX_PROOFS) return 'full'\n return null\n}\n\nexport function quoteProofRefusalMessage(reason: QuoteProofRefusal): string {\n switch (reason) {\n case 'not-a-quote':\n return 'Unknown quote'\n case 'paid':\n return 'This quote has been paid, so its proofs can no longer be changed.'\n case 'answered':\n return 'The customer has answered this revision. Start a new revision to send different proofs.'\n case 'full':\n return `A revision holds up to ${QUOTE_MAX_PROOFS} proofs. Remove one first.`\n case 'unknown-proof':\n return 'That proof is no longer on this quote.'\n }\n}\n\n/** The quote with a proof added to its current revision, edited by `by` at `atMs`. */\nexport function withQuoteProofAdded(\n order: HostOrder,\n proof: Omit<QuoteProof, 'revision' | 'addedAtMs' | 'addedBy'>,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'add')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const added: QuoteProof = {\n id: proof.id,\n revision: Number(quote.revision ?? 1),\n name: proof.name,\n contentType: proof.contentType,\n size: proof.size,\n path: proof.path,\n addedAtMs: options.atMs,\n addedBy: options.by,\n }\n return {\n ok: true,\n proof: added,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-added',\n `Proof ${added.name} added to revision ${added.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: [...currentQuoteProofs(quote).filter((entry) => entry.id !== added.id), added],\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/** The quote with one of the current revision's proofs taken off. */\nexport function withQuoteProofRemoved(\n order: HostOrder,\n proofId: string,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'remove')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const proofs = currentQuoteProofs(quote)\n const proof = proofs.find((entry) => entry.id === proofId)\n if (!proof) return { ok: false, reason: 'unknown-proof' }\n return {\n ok: true,\n proof,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-removed',\n `Proof ${proof.name} removed from revision ${proof.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: proofs.filter((entry) => entry.id !== proofId),\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * Revisions\n *-----------------------------------------*/\n\nexport type QuoteRevisionRefusal = 'not-a-quote' | 'paid'\n\n/**\n * Starts the quote's next revision. The current one is SNAPSHOTTED as it\n * stands (its lines, totals, notes, terms, proofs and the customer's\n * answers); the quote moves to revision N+1 with no proofs and no answer,\n * back at Draft, edited by `by` at `atMs`. A paid quote starts none.\n *\n * The caller writes `snapshot` to `hosts/{hostId}/orders/{id}/revisions/{revision}`\n * (`String(snapshot.revision)`, created, never overwritten) and `order` over\n * the quote, in one transaction. The pay link's version is not touched: the\n * send that hands the customer the new revision decides that.\n */\nexport function startQuoteRevision(\n order: HostOrder,\n options: { atMs: number; by: string },\n):\n | { ok: true; snapshot: QuoteRevisionSnapshot; order: HostOrder; revision: number }\n | { ok: false; reason: QuoteRevisionRefusal; error: string } {\n if (!isQuote(order) || !order.quote) return { ok: false, reason: 'not-a-quote', error: 'Unknown quote' }\n if (!isUnpaidQuote(order)) {\n return { ok: false, reason: 'paid', error: 'This quote has been paid, so it takes no new revision.' }\n }\n const quote = order.quote\n const current = Math.max(1, Math.floor(Number(quote.revision ?? 1)) || 1)\n const responses = (quote.responses ?? []).filter((response) => Number(response?.revision) === current)\n const approval = quote.approval && Number(quote.approval.revision) === current ? quote.approval : null\n const snapshot: QuoteRevisionSnapshot = {\n revision: current,\n stage: quote.stage ?? 'draft',\n lineItems: order.lineItems ?? [],\n totals: order.totals ?? null,\n taxMode: order.taxMode ?? null,\n tax: quote.tax ?? null,\n discount: quote.discount ?? null,\n shipping: quote.shipping ?? null,\n notes: quote.notes ?? '',\n terms: quote.terms ?? '',\n customer: quote.customer ?? null,\n billTo: quote.billTo ?? null,\n shipTo: quote.shipTo ?? null,\n proofs: currentQuoteProofs(quote),\n responses,\n approval,\n updatedAtMs: Number(quote.updatedAtMs) || 0,\n closedAtMs: options.atMs,\n closedBy: options.by,\n }\n const next = current + 1\n return {\n ok: true,\n revision: current,\n snapshot,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-revision-started',\n `Revision ${next} started by ${options.by}; revision ${current} is kept with its proofs and answers`,\n options.atMs,\n ),\n quote: {\n ...quote,\n revision: next,\n stage: 'draft',\n proofs: [],\n responses: [],\n approval: null,\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * The customer's answer\n *-----------------------------------------*/\n\nexport type QuoteRespondRefusal = 'not-a-quote' | 'paid' | 'no-proof' | 'too-many'\n\n/** Why the customer cannot answer the current revision now, if they cannot. */\nexport function quoteRespondRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n): QuoteRespondRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (currentQuoteProofs(order.quote).length === 0) return 'no-proof'\n const revision = Number(order.quote.revision ?? 1)\n const given = (order.quote.responses ?? []).filter((response) => Number(response?.revision) === revision)\n if (given.length >= QUOTE_MAX_RESPONSES) return 'too-many'\n return null\n}\n\nexport function quoteRespondRefusalMessage(reason: QuoteRespondRefusal, storeName: string): string {\n const business = storeName || 'the business'\n switch (reason) {\n case 'not-a-quote':\n return 'We could not find that quote. Check the link you were sent.'\n case 'paid':\n return 'This quote has been paid, so it takes no more answers.'\n case 'no-proof':\n return `There is no proof on this quote to answer yet. ${storeName || 'The business'} will send one.`\n case 'too-many':\n return `This proof has had a lot of answers already. Contact ${business} directly.`\n }\n}\n\n/** What the customer sends: approve under a name, or changes with a comment and any files. */\nexport type QuoteResponseInput =\n | { action: 'approve'; name: string }\n | { action: 'changes'; comment: string; fileIds: string[] }\n\n/** The customer's answer as the route received it, checked and trimmed, or what to fix. */\nexport function parseQuoteResponseInput(raw: unknown): { ok: true; input: QuoteResponseInput } | { ok: false; error: string } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n if (source['action'] === 'approve') {\n const name = typeof source['name'] === 'string' ? source['name'].replace(/\\s+/g, ' ').trim() : ''\n if (name.length < 2) return { ok: false, error: 'Type your name to approve the proof.' }\n return { ok: true, input: { action: 'approve', name: name.slice(0, QUOTE_MAX_APPROVER_NAME) } }\n }\n if (source['action'] === 'changes') {\n const comment = typeof source['comment'] === 'string' ? source['comment'].trim() : ''\n if (!comment) return { ok: false, error: 'Tell us what to change.' }\n const raws = Array.isArray(source['fileIds']) ? (source['fileIds'] as unknown[]) : []\n const named = raws.map((value) => String(value ?? '')).filter((value) => /^[A-Za-z0-9_-]{1,128}$/.test(value))\n if (named.length !== raws.length) return { ok: false, error: 'One of the files could not be attached. Send it again.' }\n const fileIds = [...new Set(named)]\n if (fileIds.length > QUOTE_MAX_PROOFS) return { ok: false, error: `Send up to ${QUOTE_MAX_PROOFS} files at a time.` }\n return { ok: true, input: { action: 'changes', comment: comment.slice(0, QUOTE_MAX_COMMENT), fileIds } }\n }\n return { ok: false, error: 'Approve the proof or ask for changes.' }\n}\n\n/**\n * The quote with the customer's answer recorded on its current revision.\n * Approving names the revision approved and moves the quote to Approved;\n * asking for changes moves it to Changes requested and withdraws an approval\n * of the same revision. The latest answer is the one that counts. The\n * business's edit stamp (`updatedAtMs`) is not touched: the quote's content\n * did not change, and the page that sent the answer can still pay it.\n */\nexport function applyQuoteResponse(\n order: HostOrder,\n input: QuoteResponseInput & { files?: QuoteResponseFile[] },\n context: { atMs: number; ip: string | null; userAgent: string | null },\n): { ok: true; order: HostOrder; response: QuoteCustomerResponse } | { ok: false; reason: QuoteRespondRefusal } {\n const refused = quoteRespondRefusal(order)\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const revision = Number(quote.revision ?? 1)\n const ip = context.ip ? context.ip.slice(0, 64) : null\n const userAgent = context.userAgent ? context.userAgent.slice(0, 400) : null\n const response: QuoteCustomerResponse =\n input.action === 'approve'\n ? { action: 'approve', revision, atMs: context.atMs, name: input.name, ip, userAgent }\n : {\n action: 'changes',\n revision,\n atMs: context.atMs,\n comment: input.comment,\n ...(input.files?.length ? { files: input.files } : {}),\n ip,\n userAgent,\n }\n const responses = [...(quote.responses ?? []).filter((entry) => Number(entry?.revision) === revision), response]\n const number = formatQuoteNumber(order)\n const next: OrderQuote =\n response.action === 'approve'\n ? {\n ...quote,\n responses,\n stage: 'approved',\n approvedRevision: revision,\n approval: { revision, name: response.name ?? '', atMs: context.atMs, ip, userAgent },\n }\n : {\n ...quote,\n responses,\n stage: 'changes_requested',\n approvedRevision: Number(quote.approvedRevision) === revision ? null : (quote.approvedRevision ?? null),\n approval: null,\n }\n return {\n ok: true,\n response,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n response.action === 'approve' ? 'quote-approved' : 'quote-changes-requested',\n response.action === 'approve'\n ? `${number} revision ${revision} approved by the customer as ${response.name}`\n : `The customer asked for changes to ${number} revision ${revision}` +\n (response.files?.length ? ` and sent ${response.files.length} file${response.files.length === 1 ? '' : 's'}` : ''),\n context.atMs,\n ),\n quote: next,\n },\n }\n}\n\n/** One line on what the customer last said: `Approved by Jordan Lee`, `Changes requested`. */\nexport function describeQuoteResponse(response: Pick<QuoteCustomerResponse, 'action' | 'name'>): string {\n return response.action === 'approve' ? `Approved by ${response.name || 'the customer'}` : 'Changes requested'\n}\n"],"names":["appendOrderEvent","formatQuoteNumber","isQuote","isUnpaidQuote","QUOTE_MAX_PROOFS","QUOTE_MAX_RESPONSES","QUOTE_MAX_COMMENT","QUOTE_MAX_APPROVER_NAME","QUOTE_PROOF_PURPOSE","QUOTE_ARTWORK_PURPOSE","currentQuoteProofs","quote","revision","Number","proofs","filter","proof","latestQuoteResponse","answers","responses","response","length","quoteRevisionAnswered","quoteProofChangeRefusal","order","change","quoteProofRefusalMessage","reason","withQuoteProofAdded","options","refused","ok","added","id","name","contentType","size","path","addedAtMs","atMs","addedBy","by","timeline","entry","updatedAtMs","updatedBy","withQuoteProofRemoved","proofId","find","startQuoteRevision","error","current","Math","max","floor","approval","snapshot","stage","lineItems","totals","taxMode","tax","discount","shipping","notes","terms","customer","billTo","shipTo","closedAtMs","closedBy","next","quoteRespondRefusal","given","quoteRespondRefusalMessage","storeName","business","parseQuoteResponseInput","raw","source","replace","trim","input","action","slice","comment","raws","Array","isArray","named","map","value","String","test","fileIds","Set","applyQuoteResponse","context","ip","userAgent","files","number","approvedRevision","describeQuoteResponse"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-quote-proofs.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 { appendOrderEvent, type HostOrder, type OrderAddress, type OrderLineItem, type OrderTotals } from './commerce-orders'\nimport {\n formatQuoteNumber,\n isQuote,\n isUnpaidQuote,\n type OrderQuote,\n type QuoteApproval,\n type QuoteCustomer,\n type QuoteCustomerResponse,\n type QuoteDiscount,\n type QuoteProof,\n type QuoteResponseFile,\n type QuoteShipping,\n type QuoteStage,\n type QuoteTaxBasis,\n} from './commerce-quotes'\nimport type { StorefrontTaxMode } from './commerce-tax-decision'\n\n/*==========================================\n * PROOFS, REVISIONS AND THE CUSTOMER'S ANSWER (AGL-4096).\n *\n * A quote's REVISION is the version the customer is looking at. The business\n * attaches proofs to the current revision; the customer approves them under\n * a typed name or asks for changes; the business then starts the next\n * revision, which keeps the one before exactly as it stood in\n * `hosts/{hostId}/orders/{id}/revisions/{n}` and starts the new one with no\n * proofs and no answer. Every revision is kept.\n *\n * Whether paying waits on an approval is the business's control\n * (`requireProofApproval`, AGL-4093) and `quotePaymentBlockedByApproval`\n * answers it; nothing here makes paying wait when it is off.\n *\n * Pure: the console route, the customer's route and (slice 5) the send that\n * starts a revision all call these, and write what they answer.\n *=========================================*/\n\n/** The most proofs one revision holds. */\nexport const QUOTE_MAX_PROOFS = 10\n\n/** The most answers the customer gives one revision; plenty for a change of mind. */\nexport const QUOTE_MAX_RESPONSES = 20\n\n/** The longest comment a customer may leave. */\nexport const QUOTE_MAX_COMMENT = 5_000\n\n/** The longest name an approval is signed with. */\nexport const QUOTE_MAX_APPROVER_NAME = 120\n\n/** What the visitor uploads are called for a quote: its proofs, and the customer's artwork. */\nexport const QUOTE_PROOF_PURPOSE = 'quote-proof'\nexport const QUOTE_ARTWORK_PURPOSE = 'quote-artwork'\n\n/** A revision as it stood when the next one started. */\nexport interface QuoteRevisionSnapshot {\n revision: number\n /** Where it stood with the business when it closed. */\n stage: QuoteStage\n lineItems: OrderLineItem[]\n totals: OrderTotals | null\n taxMode: StorefrontTaxMode | null\n tax: QuoteTaxBasis | null\n discount: QuoteDiscount | null\n shipping: QuoteShipping | null\n notes: string\n terms: string\n customer: QuoteCustomer | null\n billTo: OrderAddress | null\n shipTo: OrderAddress | null\n proofs: QuoteProof[]\n responses: QuoteCustomerResponse[]\n approval: QuoteApproval | null\n /** When the revision was last written by the business. */\n updatedAtMs: number\n /** When the next revision started, and who started it. */\n closedAtMs: number\n closedBy: string\n}\n\n/** The current revision's proofs. */\nexport function currentQuoteProofs(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined): QuoteProof[] {\n const revision = Number(quote?.revision ?? 1)\n return (quote?.proofs ?? []).filter((proof) => Number(proof?.revision) === revision)\n}\n\n/**\n * One of the proofs the quote has shown its customer, by id (AGL-4097): the\n * current revision's, else an earlier revision's as its snapshot keeps it\n * (`orders/{id}/revisions/{n}`). A proof the business took off a revision\n * before the next one started is in neither, so it is not found.\n */\nexport function quoteProofInHistory(\n quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined,\n snapshots: ReadonlyArray<Partial<Pick<QuoteRevisionSnapshot, 'proofs'>> | null | undefined>,\n proofId: string,\n): QuoteProof | null {\n if (!proofId) return null\n const current = currentQuoteProofs(quote).find((proof) => proof?.id === proofId)\n if (current) return current\n for (const snapshot of snapshots) {\n const kept = (Array.isArray(snapshot?.proofs) ? snapshot.proofs : []).find((proof) => proof?.id === proofId)\n if (kept) return kept\n }\n return null\n}\n\n/** The customer's latest answer to the current revision, if they gave one. */\nexport function latestQuoteResponse(\n quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined,\n): QuoteCustomerResponse | null {\n const revision = Number(quote?.revision ?? 1)\n const answers = (quote?.responses ?? []).filter((response) => Number(response?.revision) === revision)\n return answers.length ? answers[answers.length - 1] : null\n}\n\n/** Whether the customer has answered the current revision. */\nexport function quoteRevisionAnswered(quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined): boolean {\n return latestQuoteResponse(quote) !== null\n}\n\n/*------------------------------------------\n * The business's proofs\n *-----------------------------------------*/\n\nexport type QuoteProofRefusal = 'not-a-quote' | 'paid' | 'answered' | 'full' | 'unknown-proof'\n\n/** Why the current revision's proofs cannot be changed now, if they cannot. */\nexport function quoteProofChangeRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n change: 'add' | 'remove',\n): QuoteProofRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (quoteRevisionAnswered(order.quote)) return 'answered'\n if (change === 'add' && currentQuoteProofs(order.quote).length >= QUOTE_MAX_PROOFS) return 'full'\n return null\n}\n\nexport function quoteProofRefusalMessage(reason: QuoteProofRefusal): string {\n switch (reason) {\n case 'not-a-quote':\n return 'Unknown quote'\n case 'paid':\n return 'This quote has been paid, so its proofs can no longer be changed.'\n case 'answered':\n return 'The customer has answered this revision. Start a new revision to send different proofs.'\n case 'full':\n return `A revision holds up to ${QUOTE_MAX_PROOFS} proofs. Remove one first.`\n case 'unknown-proof':\n return 'That proof is no longer on this quote.'\n }\n}\n\n/** The quote with a proof added to its current revision, edited by `by` at `atMs`. */\nexport function withQuoteProofAdded(\n order: HostOrder,\n proof: Omit<QuoteProof, 'revision' | 'addedAtMs' | 'addedBy'>,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'add')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const added: QuoteProof = {\n id: proof.id,\n revision: Number(quote.revision ?? 1),\n name: proof.name,\n contentType: proof.contentType,\n size: proof.size,\n path: proof.path,\n addedAtMs: options.atMs,\n addedBy: options.by,\n }\n return {\n ok: true,\n proof: added,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-added',\n `Proof ${added.name} added to revision ${added.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: [...currentQuoteProofs(quote).filter((entry) => entry.id !== added.id), added],\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/** The quote with one of the current revision's proofs taken off. */\nexport function withQuoteProofRemoved(\n order: HostOrder,\n proofId: string,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'remove')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const proofs = currentQuoteProofs(quote)\n const proof = proofs.find((entry) => entry.id === proofId)\n if (!proof) return { ok: false, reason: 'unknown-proof' }\n return {\n ok: true,\n proof,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-removed',\n `Proof ${proof.name} removed from revision ${proof.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: proofs.filter((entry) => entry.id !== proofId),\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * Revisions\n *-----------------------------------------*/\n\nexport type QuoteRevisionRefusal = 'not-a-quote' | 'paid'\n\n/**\n * Starts the quote's next revision. The current one is SNAPSHOTTED as it\n * stands (its lines, totals, notes, terms, proofs and the customer's\n * answers); the quote moves to revision N+1 with no proofs and no answer,\n * back at Draft, edited by `by` at `atMs`. A paid quote starts none.\n *\n * The caller writes `snapshot` to `hosts/{hostId}/orders/{id}/revisions/{revision}`\n * (`String(snapshot.revision)`, created, never overwritten) and `order` over\n * the quote, in one transaction. The pay link's version is not touched: the\n * send that hands the customer the new revision decides that.\n */\nexport function startQuoteRevision(\n order: HostOrder,\n options: { atMs: number; by: string },\n):\n | { ok: true; snapshot: QuoteRevisionSnapshot; order: HostOrder; revision: number }\n | { ok: false; reason: QuoteRevisionRefusal; error: string } {\n if (!isQuote(order) || !order.quote) return { ok: false, reason: 'not-a-quote', error: 'Unknown quote' }\n if (!isUnpaidQuote(order)) {\n return { ok: false, reason: 'paid', error: 'This quote has been paid, so it takes no new revision.' }\n }\n const quote = order.quote\n const current = Math.max(1, Math.floor(Number(quote.revision ?? 1)) || 1)\n const responses = (quote.responses ?? []).filter((response) => Number(response?.revision) === current)\n const approval = quote.approval && Number(quote.approval.revision) === current ? quote.approval : null\n const snapshot: QuoteRevisionSnapshot = {\n revision: current,\n stage: quote.stage ?? 'draft',\n lineItems: order.lineItems ?? [],\n totals: order.totals ?? null,\n taxMode: order.taxMode ?? null,\n tax: quote.tax ?? null,\n discount: quote.discount ?? null,\n shipping: quote.shipping ?? null,\n notes: quote.notes ?? '',\n terms: quote.terms ?? '',\n customer: quote.customer ?? null,\n billTo: quote.billTo ?? null,\n shipTo: quote.shipTo ?? null,\n proofs: currentQuoteProofs(quote),\n responses,\n approval,\n updatedAtMs: Number(quote.updatedAtMs) || 0,\n closedAtMs: options.atMs,\n closedBy: options.by,\n }\n const next = current + 1\n return {\n ok: true,\n revision: current,\n snapshot,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-revision-started',\n `Revision ${next} started by ${options.by}; revision ${current} is kept with its proofs and answers`,\n options.atMs,\n ),\n quote: {\n ...quote,\n revision: next,\n stage: 'draft',\n proofs: [],\n responses: [],\n approval: null,\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * The customer's answer\n *-----------------------------------------*/\n\nexport type QuoteRespondRefusal = 'not-a-quote' | 'paid' | 'no-proof' | 'too-many'\n\n/** Why the customer cannot answer the current revision now, if they cannot. */\nexport function quoteRespondRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n): QuoteRespondRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (currentQuoteProofs(order.quote).length === 0) return 'no-proof'\n const revision = Number(order.quote.revision ?? 1)\n const given = (order.quote.responses ?? []).filter((response) => Number(response?.revision) === revision)\n if (given.length >= QUOTE_MAX_RESPONSES) return 'too-many'\n return null\n}\n\nexport function quoteRespondRefusalMessage(reason: QuoteRespondRefusal, storeName: string): string {\n const business = storeName || 'the business'\n switch (reason) {\n case 'not-a-quote':\n return 'We could not find that quote. Check the link you were sent.'\n case 'paid':\n return 'This quote has been paid, so it takes no more answers.'\n case 'no-proof':\n return `There is no proof on this quote to answer yet. ${storeName || 'The business'} will send one.`\n case 'too-many':\n return `This proof has had a lot of answers already. Contact ${business} directly.`\n }\n}\n\n/** What the customer sends: approve under a name, or changes with a comment and any files. */\nexport type QuoteResponseInput =\n | { action: 'approve'; name: string }\n | { action: 'changes'; comment: string; fileIds: string[] }\n\n/** The customer's answer as the route received it, checked and trimmed, or what to fix. */\nexport function parseQuoteResponseInput(raw: unknown): { ok: true; input: QuoteResponseInput } | { ok: false; error: string } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n if (source['action'] === 'approve') {\n const name = typeof source['name'] === 'string' ? source['name'].replace(/\\s+/g, ' ').trim() : ''\n if (name.length < 2) return { ok: false, error: 'Type your name to approve the proof.' }\n return { ok: true, input: { action: 'approve', name: name.slice(0, QUOTE_MAX_APPROVER_NAME) } }\n }\n if (source['action'] === 'changes') {\n const comment = typeof source['comment'] === 'string' ? source['comment'].trim() : ''\n if (!comment) return { ok: false, error: 'Tell us what to change.' }\n const raws = Array.isArray(source['fileIds']) ? (source['fileIds'] as unknown[]) : []\n const named = raws.map((value) => String(value ?? '')).filter((value) => /^[A-Za-z0-9_-]{1,128}$/.test(value))\n if (named.length !== raws.length) return { ok: false, error: 'One of the files could not be attached. Send it again.' }\n const fileIds = [...new Set(named)]\n if (fileIds.length > QUOTE_MAX_PROOFS) return { ok: false, error: `Send up to ${QUOTE_MAX_PROOFS} files at a time.` }\n return { ok: true, input: { action: 'changes', comment: comment.slice(0, QUOTE_MAX_COMMENT), fileIds } }\n }\n return { ok: false, error: 'Approve the proof or ask for changes.' }\n}\n\n/**\n * The quote with the customer's answer recorded on its current revision.\n * Approving names the revision approved and moves the quote to Approved;\n * asking for changes moves it to Changes requested and withdraws an approval\n * of the same revision. The latest answer is the one that counts. The\n * business's edit stamp (`updatedAtMs`) is not touched: the quote's content\n * did not change, and the page that sent the answer can still pay it.\n */\nexport function applyQuoteResponse(\n order: HostOrder,\n input: QuoteResponseInput & { files?: QuoteResponseFile[] },\n context: { atMs: number; ip: string | null; userAgent: string | null },\n): { ok: true; order: HostOrder; response: QuoteCustomerResponse } | { ok: false; reason: QuoteRespondRefusal } {\n const refused = quoteRespondRefusal(order)\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const revision = Number(quote.revision ?? 1)\n const ip = context.ip ? context.ip.slice(0, 64) : null\n const userAgent = context.userAgent ? context.userAgent.slice(0, 400) : null\n const response: QuoteCustomerResponse =\n input.action === 'approve'\n ? { action: 'approve', revision, atMs: context.atMs, name: input.name, ip, userAgent }\n : {\n action: 'changes',\n revision,\n atMs: context.atMs,\n comment: input.comment,\n ...(input.files?.length ? { files: input.files } : {}),\n ip,\n userAgent,\n }\n const responses = [...(quote.responses ?? []).filter((entry) => Number(entry?.revision) === revision), response]\n const number = formatQuoteNumber(order)\n const next: OrderQuote =\n response.action === 'approve'\n ? {\n ...quote,\n responses,\n stage: 'approved',\n approvedRevision: revision,\n approval: { revision, name: response.name ?? '', atMs: context.atMs, ip, userAgent },\n }\n : {\n ...quote,\n responses,\n stage: 'changes_requested',\n approvedRevision: Number(quote.approvedRevision) === revision ? null : (quote.approvedRevision ?? null),\n approval: null,\n }\n return {\n ok: true,\n response,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n response.action === 'approve' ? 'quote-approved' : 'quote-changes-requested',\n response.action === 'approve'\n ? `${number} revision ${revision} approved by the customer as ${response.name}`\n : `The customer asked for changes to ${number} revision ${revision}` +\n (response.files?.length ? ` and sent ${response.files.length} file${response.files.length === 1 ? '' : 's'}` : ''),\n context.atMs,\n ),\n quote: next,\n },\n }\n}\n\n/** One line on what the customer last said: `Approved by Jordan Lee`, `Changes requested`. */\nexport function describeQuoteResponse(response: Pick<QuoteCustomerResponse, 'action' | 'name'>): string {\n return response.action === 'approve' ? `Approved by ${response.name || 'the customer'}` : 'Changes requested'\n}\n"],"names":["appendOrderEvent","formatQuoteNumber","isQuote","isUnpaidQuote","QUOTE_MAX_PROOFS","QUOTE_MAX_RESPONSES","QUOTE_MAX_COMMENT","QUOTE_MAX_APPROVER_NAME","QUOTE_PROOF_PURPOSE","QUOTE_ARTWORK_PURPOSE","currentQuoteProofs","quote","revision","Number","proofs","filter","proof","quoteProofInHistory","snapshots","proofId","current","find","id","snapshot","kept","Array","isArray","latestQuoteResponse","answers","responses","response","length","quoteRevisionAnswered","quoteProofChangeRefusal","order","change","quoteProofRefusalMessage","reason","withQuoteProofAdded","options","refused","ok","added","name","contentType","size","path","addedAtMs","atMs","addedBy","by","timeline","entry","updatedAtMs","updatedBy","withQuoteProofRemoved","startQuoteRevision","error","Math","max","floor","approval","stage","lineItems","totals","taxMode","tax","discount","shipping","notes","terms","customer","billTo","shipTo","closedAtMs","closedBy","next","quoteRespondRefusal","given","quoteRespondRefusalMessage","storeName","business","parseQuoteResponseInput","raw","source","replace","trim","input","action","slice","comment","raws","named","map","value","String","test","fileIds","Set","applyQuoteResponse","context","ip","userAgent","files","number","approvedRevision","describeQuoteResponse"],"mappings":"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"}
|
|
@@ -41,8 +41,10 @@ export declare function quotePayLinkUrl(host: {
|
|
|
41
41
|
* The absolute address of one of the current revision's proofs, behind the
|
|
42
42
|
* same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),
|
|
43
43
|
* or null when the site has no public address or no signing secret. What the
|
|
44
|
-
* quote email draws a proof image from (AGL-4097)
|
|
45
|
-
*
|
|
44
|
+
* quote email draws a proof image from (AGL-4097). The email is a record the
|
|
45
|
+
* customer keeps, so the image keeps showing after the business starts the
|
|
46
|
+
* next revision or replaces the link (the page itself then says it was
|
|
47
|
+
* updated); it stops only if the business takes that proof off its revision.
|
|
46
48
|
*/
|
|
47
49
|
export declare function quoteProofLinkUrl(host: {
|
|
48
50
|
cname?: unknown;
|
|
@@ -97,8 +97,10 @@ function signQuoteLink(hostId, quoteId, version) {
|
|
|
97
97
|
* The absolute address of one of the current revision's proofs, behind the
|
|
98
98
|
* same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),
|
|
99
99
|
* or null when the site has no public address or no signing secret. What the
|
|
100
|
-
* quote email draws a proof image from (AGL-4097)
|
|
101
|
-
*
|
|
100
|
+
* quote email draws a proof image from (AGL-4097). The email is a record the
|
|
101
|
+
* customer keeps, so the image keeps showing after the business starts the
|
|
102
|
+
* next revision or replaces the link (the page itself then says it was
|
|
103
|
+
* updated); it stops only if the business takes that proof off its revision.
|
|
102
104
|
*/ export function quoteProofLinkUrl(host, hostId, quoteId, version, proofId) {
|
|
103
105
|
const origin = hostPublicOrigin({
|
|
104
106
|
cname: typeof (host == null ? void 0 : host.cname) === 'string' ? host.cname : null,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-link-token.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 { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { quotePageHref } from '../constants/quote-page'\nimport { tokenSigningSecret } from './download'\n\n/**\n * A quote's pay link (AGL-4094): `/pay-quote?q=<quoteId>&t=<token>` on the\n * business's own site, where the customer sees the quote and pays it.\n *\n * The token is `<version>.<signature>`: the quote's link version\n * (`quote.linkVersion`, absent reading as 1) and the first 32 hex characters of\n * `HMAC(quote-link:hostId:quoteId:version)` under the commerce\n * `TOKEN_SIGNING_SECRET`. A pure function of what the quote already holds, so\n * nothing is stored per link and every message that carried the link carries\n * the same working one. The `quote-link:` prefix keeps it from being replayed\n * as an order-status, download or supplier token, which sign other payloads\n * under the same secret.\n *\n * NO EXPIRY. A quote is paid when the customer is ready, and a link that died\n * first would be a quote they could not pay. The version is what retires a\n * link: replacing it bumps the version on the quote, and a link signed for an\n * older version still verifies, so its page can say the quote was replaced,\n * without showing any of it.\n */\nconst SIGNATURE_LENGTH = 32\nconst MAX_VERSION = 1_000_000\n\nfunction signQuoteLink(hostId: string, quoteId: string, version: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`quote-link:${hostId}:${quoteId}:${version}`)\n .digest('hex')\n .slice(0, SIGNATURE_LENGTH)\n}\n\n/** The token's two halves, or null for a token not shaped like one. */\nexport function parseQuoteLinkToken(token: string): { version: number; signature: string } | null {\n const match = /^([1-9]\\d{0,6})\\.([0-9a-f]{32})$/.exec(String(token ?? ''))\n if (!match) return null\n const version = Number(match[1])\n if (!Number.isSafeInteger(version) || version > MAX_VERSION) return null\n return { version, signature: match[2] }\n}\n\n/** The token for one version of a quote's link, or null when the signing secret is unset. */\nexport function mintQuoteLinkToken(hostId: string, quoteId: string, version: number): string | null {\n if (!hostId || !quoteId || !Number.isSafeInteger(version) || version < 1 || version > MAX_VERSION) {\n return null\n }\n try {\n return `${version}.${signQuoteLink(hostId, quoteId, version)}`\n } catch {\n return null\n }\n}\n\n/**\n * Checks a presented token in constant time and answers the link version it\n * was signed for, or null for a token that is wrong, malformed or unverifiable\n * (no secret). The caller compares the version with the quote's own.\n */\nexport function verifyQuoteLinkToken(hostId: string, quoteId: string, token: string): number | null {\n if (!hostId || !quoteId) return null\n const parsed = parseQuoteLinkToken(token)\n if (!parsed) return null\n let expected: string\n try {\n expected = signQuoteLink(hostId, quoteId, parsed.version)\n } catch {\n return null\n }\n const a = Buffer.from(parsed.signature)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return null\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b)) ? parsed.version : null\n}\n\n/**\n * The absolute pay link for a quote at a link version, or null when either\n * half is missing: a site with no public address, or no signing secret. What\n * the console copies, and what a quote or invoice email puts in front of the\n * customer.\n */\nexport function quotePayLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token) return null\n return `${origin}${quotePageHref(quoteId, token)}`\n}\n\n/**\n * The absolute address of one of the current revision's proofs, behind the\n * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),\n * or null when the site has no public address or no signing secret. What the\n * quote email draws a proof image from (AGL-4097)
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-link-token.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 { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { quotePageHref } from '../constants/quote-page'\nimport { tokenSigningSecret } from './download'\n\n/**\n * A quote's pay link (AGL-4094): `/pay-quote?q=<quoteId>&t=<token>` on the\n * business's own site, where the customer sees the quote and pays it.\n *\n * The token is `<version>.<signature>`: the quote's link version\n * (`quote.linkVersion`, absent reading as 1) and the first 32 hex characters of\n * `HMAC(quote-link:hostId:quoteId:version)` under the commerce\n * `TOKEN_SIGNING_SECRET`. A pure function of what the quote already holds, so\n * nothing is stored per link and every message that carried the link carries\n * the same working one. The `quote-link:` prefix keeps it from being replayed\n * as an order-status, download or supplier token, which sign other payloads\n * under the same secret.\n *\n * NO EXPIRY. A quote is paid when the customer is ready, and a link that died\n * first would be a quote they could not pay. The version is what retires a\n * link: replacing it bumps the version on the quote, and a link signed for an\n * older version still verifies, so its page can say the quote was replaced,\n * without showing any of it.\n */\nconst SIGNATURE_LENGTH = 32\nconst MAX_VERSION = 1_000_000\n\nfunction signQuoteLink(hostId: string, quoteId: string, version: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`quote-link:${hostId}:${quoteId}:${version}`)\n .digest('hex')\n .slice(0, SIGNATURE_LENGTH)\n}\n\n/** The token's two halves, or null for a token not shaped like one. */\nexport function parseQuoteLinkToken(token: string): { version: number; signature: string } | null {\n const match = /^([1-9]\\d{0,6})\\.([0-9a-f]{32})$/.exec(String(token ?? ''))\n if (!match) return null\n const version = Number(match[1])\n if (!Number.isSafeInteger(version) || version > MAX_VERSION) return null\n return { version, signature: match[2] }\n}\n\n/** The token for one version of a quote's link, or null when the signing secret is unset. */\nexport function mintQuoteLinkToken(hostId: string, quoteId: string, version: number): string | null {\n if (!hostId || !quoteId || !Number.isSafeInteger(version) || version < 1 || version > MAX_VERSION) {\n return null\n }\n try {\n return `${version}.${signQuoteLink(hostId, quoteId, version)}`\n } catch {\n return null\n }\n}\n\n/**\n * Checks a presented token in constant time and answers the link version it\n * was signed for, or null for a token that is wrong, malformed or unverifiable\n * (no secret). The caller compares the version with the quote's own.\n */\nexport function verifyQuoteLinkToken(hostId: string, quoteId: string, token: string): number | null {\n if (!hostId || !quoteId) return null\n const parsed = parseQuoteLinkToken(token)\n if (!parsed) return null\n let expected: string\n try {\n expected = signQuoteLink(hostId, quoteId, parsed.version)\n } catch {\n return null\n }\n const a = Buffer.from(parsed.signature)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return null\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b)) ? parsed.version : null\n}\n\n/**\n * The absolute pay link for a quote at a link version, or null when either\n * half is missing: a site with no public address, or no signing secret. What\n * the console copies, and what a quote or invoice email puts in front of the\n * customer.\n */\nexport function quotePayLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token) return null\n return `${origin}${quotePageHref(quoteId, token)}`\n}\n\n/**\n * The absolute address of one of the current revision's proofs, behind the\n * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),\n * or null when the site has no public address or no signing secret. What the\n * quote email draws a proof image from (AGL-4097). The email is a record the\n * customer keeps, so the image keeps showing after the business starts the\n * next revision or replaces the link (the page itself then says it was\n * updated); it stops only if the business takes that proof off its revision.\n */\nexport function quoteProofLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n proofId: string,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token || !proofId) return null\n const query = new URLSearchParams({ hostId, q: quoteId, t: token, file: proofId })\n return `${origin}/api/commerce/quote-proof?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","createHmac","timingSafeEqual","quotePageHref","tokenSigningSecret","SIGNATURE_LENGTH","MAX_VERSION","signQuoteLink","hostId","quoteId","version","update","digest","slice","parseQuoteLinkToken","token","match","exec","String","Number","isSafeInteger","signature","mintQuoteLinkToken","verifyQuoteLinkToken","parsed","expected","a","Buffer","from","b","length","Uint8Array","quotePayLinkUrl","host","origin","cname","subdomain","quoteProofLinkUrl","proofId","query","URLSearchParams","q","t","file","toString"],"mappings":"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"}
|
|
@@ -75,10 +75,14 @@ export interface LoadedQuote {
|
|
|
75
75
|
/**
|
|
76
76
|
* The quote a link names, after its token: the quote, the replaced answer for
|
|
77
77
|
* a link to an older version, or the one not-found answer for everything else.
|
|
78
|
-
* With `invoices`, a draft order's invoice too (AGL-4099).
|
|
78
|
+
* With `invoices`, a draft order's invoice too (AGL-4099). With `history`, a
|
|
79
|
+
* link to an older version opens the quote as well: only for a route that
|
|
80
|
+
* serves the quote's record and acts on nothing (its proofs, which an earlier
|
|
81
|
+
* email draws, AGL-4097); pay, answers and uploads keep the current version.
|
|
79
82
|
*/
|
|
80
83
|
export declare function loadLinkedQuote(deps: QuotePayDeps, hostId: string, quoteId: string, token: string, options?: {
|
|
81
84
|
invoices?: boolean;
|
|
85
|
+
history?: boolean;
|
|
82
86
|
}): Promise<{
|
|
83
87
|
loaded: LoadedQuote;
|
|
84
88
|
} | {
|
|
@@ -155,7 +155,10 @@ function headerOf(req, name) {
|
|
|
155
155
|
/**
|
|
156
156
|
* The quote a link names, after its token: the quote, the replaced answer for
|
|
157
157
|
* a link to an older version, or the one not-found answer for everything else.
|
|
158
|
-
* With `invoices`, a draft order's invoice too (AGL-4099).
|
|
158
|
+
* With `invoices`, a draft order's invoice too (AGL-4099). With `history`, a
|
|
159
|
+
* link to an older version opens the quote as well: only for a route that
|
|
160
|
+
* serves the quote's record and acts on nothing (its proofs, which an earlier
|
|
161
|
+
* email draws, AGL-4097); pay, answers and uploads keep the current version.
|
|
159
162
|
*/ export async function loadLinkedQuote(deps, hostId, quoteId, token, options = {}) {
|
|
160
163
|
var _hostSnapshot_data, _resolveHostToken;
|
|
161
164
|
const notFound = options.invoices ? NOT_FOUND_ANY : NOT_FOUND;
|
|
@@ -183,7 +186,7 @@ function headerOf(req, name) {
|
|
|
183
186
|
if (version > current) return {
|
|
184
187
|
outcome: notFound
|
|
185
188
|
};
|
|
186
|
-
if (version < current) {
|
|
189
|
+
if (version < current && !options.history) {
|
|
187
190
|
return {
|
|
188
191
|
outcome: {
|
|
189
192
|
status: 410,
|