@aglyn/plugins-commerce 1.0.0-beta.199 → 1.0.0-beta.201
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/package.json +13 -13
- package/src/lib/components/console/catalog-organization-card.component.js +48 -3
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/gift-cards-card.component.d.ts +9 -0
- package/src/lib/components/console/gift-cards-card.component.js +53 -28
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.d.ts +26 -0
- package/src/lib/components/console/host-orders-card.component.js +264 -194
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/order-detail-dialog.component.js +12 -0
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/pos-page.component.d.ts +14 -0
- package/src/lib/components/console/pos-page.component.js +86 -45
- package/src/lib/components/console/pos-page.component.js.map +1 -1
- package/src/lib/components/console/product-editor-dialog.component.js +30 -6
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/product-slugs.d.ts +43 -0
- package/src/lib/components/console/product-slugs.js +60 -0
- package/src/lib/components/console/product-slugs.js.map +1 -0
- package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
- package/src/lib/components/console/products-hub-card.component.js +229 -282
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
- package/src/lib/components/console/products-hub-zone.component.js +32 -9
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
- package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
- package/src/lib/components/console/recovery-queue-card.component.js +107 -86
- package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
- package/src/lib/components/console/reservations-card.component.js +15 -3
- package/src/lib/components/console/reservations-card.component.js.map +1 -1
- package/src/lib/components/console/smart-collections.d.ts +32 -0
- package/src/lib/components/console/smart-collections.js +57 -0
- package/src/lib/components/console/smart-collections.js.map +1 -0
- package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
- package/src/lib/components/console/stock-movements-card.component.js +142 -180
- package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
- package/src/lib/components/product-grid.d.ts +5 -0
- package/src/lib/components/product-grid.js +95 -34
- package/src/lib/components/product-grid.js.map +1 -1
- package/src/lib/constants/orders-list-query.d.ts +40 -0
- package/src/lib/constants/orders-list-query.js +205 -0
- package/src/lib/constants/orders-list-query.js.map +1 -0
- package/src/lib/constants/product-list-query.d.ts +42 -0
- package/src/lib/constants/product-list-query.js +217 -0
- package/src/lib/constants/product-list-query.js.map +1 -0
- package/src/lib/constants/stock-movement-query.d.ts +27 -0
- package/src/lib/constants/stock-movement-query.js +110 -0
- package/src/lib/constants/stock-movement-query.js.map +1 -0
- package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
- package/src/lib/constants/storefront-catalog-query.js +312 -0
- package/src/lib/constants/storefront-catalog-query.js.map +1 -0
- package/src/lib/model/checkout-recovery.d.ts +37 -0
- package/src/lib/model/checkout-recovery.js +44 -0
- package/src/lib/model/checkout-recovery.js.map +1 -0
- package/src/lib/model/commerce-orders.d.ts +7 -0
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/commerce.d.ts +80 -0
- package/src/lib/model/commerce.js +67 -6
- package/src/lib/model/commerce.js.map +1 -1
- package/src/lib/model/gift-card-search.d.ts +24 -0
- package/src/lib/model/gift-card-search.js +48 -0
- package/src/lib/model/gift-card-search.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/order-list-fields.d.ts +80 -0
- package/src/lib/model/order-list-fields.js +112 -0
- package/src/lib/model/order-list-fields.js.map +1 -0
- package/src/lib/server/billing-webhook.js +180 -99
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cancel-order.js +6 -6
- package/src/lib/server/cancel-order.js.map +1 -1
- package/src/lib/server/cart-checkout.js +9 -2
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/catalog.d.ts +46 -30
- package/src/lib/server/catalog.js +507 -170
- package/src/lib/server/catalog.js.map +1 -1
- package/src/lib/server/checkout.js +3 -1
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/collection-membership.d.ts +35 -0
- package/src/lib/server/collection-membership.js +172 -0
- package/src/lib/server/collection-membership.js.map +1 -0
- package/src/lib/server/draft-order.js +5 -3
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/gift-cards.js +7 -1
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/member-name-search.d.ts +17 -0
- package/src/lib/server/member-name-search.js +23 -1
- package/src/lib/server/member-name-search.js.map +1 -1
- package/src/lib/server/membership-account.js +7 -3
- package/src/lib/server/membership-account.js.map +1 -1
- package/src/lib/server/membership-admin-password.js +8 -2
- package/src/lib/server/membership-admin-password.js.map +1 -1
- package/src/lib/server/membership-recover.js +4 -1
- package/src/lib/server/membership-recover.js.map +1 -1
- package/src/lib/server/membership-register.js +3 -1
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/pos-order.js +9 -5
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +20 -7
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +6 -2
- package/src/lib/server/process-restock.js.map +1 -1
- package/src/lib/server/reserve-stock.js +3 -4
- package/src/lib/server/reserve-stock.js.map +1 -1
- package/src/lib/server/reserve.js +3 -1
- package/src/lib/server/reserve.js.map +1 -1
- package/src/lib/server/site-page-enricher.js +3 -3
- package/src/lib/server/site-page-enricher.js.map +1 -1
- package/src/lib/server.js +4 -0
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
|
|
18
|
+
import type { ListQueryDeclaration, ListQueryFilter, ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
19
|
+
import type { HostCollection, ProductType } from '../model/commerce';
|
|
20
|
+
/** The product types a visitor can filter by, in the grid's own order. */
|
|
21
|
+
export declare const STOREFRONT_PRODUCT_TYPES: readonly ProductType[];
|
|
22
|
+
/** The grid's sort values, each the one order it puts the catalog in. */
|
|
23
|
+
export declare const STOREFRONT_CATALOG_SORTS: {
|
|
24
|
+
readonly name: {
|
|
25
|
+
readonly path: "nameLower";
|
|
26
|
+
readonly direction: "asc";
|
|
27
|
+
};
|
|
28
|
+
readonly 'price-asc': {
|
|
29
|
+
readonly path: "priceFromCents";
|
|
30
|
+
readonly direction: "asc";
|
|
31
|
+
};
|
|
32
|
+
readonly 'price-desc': {
|
|
33
|
+
readonly path: "priceFromCents";
|
|
34
|
+
readonly direction: "desc";
|
|
35
|
+
};
|
|
36
|
+
readonly newest: {
|
|
37
|
+
readonly path: "createdAtMs";
|
|
38
|
+
readonly direction: "desc";
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export type StorefrontCatalogSort = keyof typeof STOREFRONT_CATALOG_SORTS;
|
|
42
|
+
/** A grid's `sort` value as its order; anything unknown reads as Name. */
|
|
43
|
+
export declare function storefrontCatalogSort(sort: string | undefined): ListQuerySort;
|
|
44
|
+
/**
|
|
45
|
+
* What the storefront catalog can be narrowed by, and how it is ordered.
|
|
46
|
+
*
|
|
47
|
+
* Columns are the catalog's own controls, so a refusal names the control the
|
|
48
|
+
* visitor touched. `category` and `tag` match whole members of their arrays,
|
|
49
|
+
* byte for byte (`verbatimTokens`): a category id is an opaque id and a tag
|
|
50
|
+
* is matched exactly as the merchant typed it, as the grid always matched it.
|
|
51
|
+
* `stock` is the In stock chip, `soldOut == false`.
|
|
52
|
+
*
|
|
53
|
+
* The four sorts are the grid's sort select — every one a visitor or an author
|
|
54
|
+
* can choose — with Name, the author default, first.
|
|
55
|
+
*/
|
|
56
|
+
export declare const STOREFRONT_CATALOG_DECLARATION: ListQueryDeclaration;
|
|
57
|
+
/** Each control as a refusal names it to a visitor. */
|
|
58
|
+
export declare const STOREFRONT_CATALOG_HEADERS: Readonly<Record<string, string>>;
|
|
59
|
+
/** Picked values as a refusal names them; categories are added by the grid. */
|
|
60
|
+
export declare const STOREFRONT_CATALOG_OPTIONS: Readonly<Record<string, readonly ListFilterOption[]>>;
|
|
61
|
+
/**
|
|
62
|
+
* The scope every storefront read carries: products a shopper can buy — live
|
|
63
|
+
* (`deletedAt == null`; a delete stamps a timestamp and leaves `status`
|
|
64
|
+
* alone) and active. Both are equalities, merged with every other predicate.
|
|
65
|
+
*/
|
|
66
|
+
export declare const STOREFRONT_CATALOG_BASE: readonly ListQueryFilter[];
|
|
67
|
+
/**
|
|
68
|
+
* {@link STOREFRONT_CATALOG_BASE} as `listQueryIndexes` takes it, with the
|
|
69
|
+
* scope a smart collection no query can express adds to it: the membership
|
|
70
|
+
* every product writer stores (`collectionIds array-contains <id>`,
|
|
71
|
+
* AGL-3321), one `(collectionIds, order)` composite per sort under index
|
|
72
|
+
* merging.
|
|
73
|
+
*/
|
|
74
|
+
export declare const STOREFRONT_CATALOG_BASE_PATHS: readonly {
|
|
75
|
+
path: string;
|
|
76
|
+
array?: boolean;
|
|
77
|
+
}[];
|
|
78
|
+
/** The smart-collection rule predicates a query can hold, or why it cannot. */
|
|
79
|
+
export type SmartCollectionScope = {
|
|
80
|
+
filters: ListQueryFilter[];
|
|
81
|
+
} | {
|
|
82
|
+
unservable: string;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A SMART collection's rules as query predicates (AGL-3321), or the reason
|
|
86
|
+
* they cannot be one.
|
|
87
|
+
*
|
|
88
|
+
* Membership is `matchesCollection` in the model. What a query can ask of it:
|
|
89
|
+
*
|
|
90
|
+
* tag / category is → `array-contains` (one array clause per query)
|
|
91
|
+
* type is / not → `==` / `in` the other types
|
|
92
|
+
* price under → `priceFromCents <`, the low end being what
|
|
93
|
+
* `under` compares
|
|
94
|
+
*
|
|
95
|
+
* and what it cannot: a tag or category NOT held (no negated array query), a
|
|
96
|
+
* price `over` or `equals` (they compare the HIGH end, which nothing stores),
|
|
97
|
+
* a name rule (substring, which no index answers), and any-of rules (an OR the
|
|
98
|
+
* visitor's own filters would multiply). Those collections are read by the
|
|
99
|
+
* membership each product stores instead — `collectionIds array-contains
|
|
100
|
+
* <id>`, computed from the same rules by `productCollectionIds` — never from a
|
|
101
|
+
* window of the catalog.
|
|
102
|
+
*/
|
|
103
|
+
export declare function smartCollectionScope(collection: Pick<HostCollection, 'rules' | 'matchAll'>): SmartCollectionScope;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /*
|
|
17
|
+
* THE STOREFRONT CATALOG, ON THE QUERY (AGL-3321).
|
|
18
|
+
*
|
|
19
|
+
* The Product grid a visitor browses — its search box, category and tag
|
|
20
|
+
* chips, type chips, In stock chip, price slider and sort select — used to be
|
|
21
|
+
* answered by reading `limit(500)` products in no particular order and
|
|
22
|
+
* narrowing that in memory. A catalog past five hundred products listed an
|
|
23
|
+
* arbitrary five hundred, and a search, a category or a price range answered
|
|
24
|
+
* "no products match" for every product outside them: the one answer a
|
|
25
|
+
* storefront must never give wrongly, to the one person trying to buy the
|
|
26
|
+
* thing.
|
|
27
|
+
*
|
|
28
|
+
* Every predicate is on the Firestore query now, through the list query plan
|
|
29
|
+
* (`planListQuery`), and pages come from the query's own cursor. The catalog
|
|
30
|
+
* is read on the server with the Admin SDK (`server/catalog.ts`), under the
|
|
31
|
+
* scope below, so an anonymous shopper never queries the collection itself.
|
|
32
|
+
*
|
|
33
|
+
* ## What one query can hold, as a shopper meets it
|
|
34
|
+
*
|
|
35
|
+
* One array clause: the search word (`nameTokens`), a category (`categoryIds`)
|
|
36
|
+
* or a tag (`tags`). A grid pinned to a category that a visitor also searches
|
|
37
|
+
* serves the search and says the category is not applied — the plan's rule,
|
|
38
|
+
* shown by the grid rather than papered over by matching the page in memory.
|
|
39
|
+
*
|
|
40
|
+
* One range: the price slider. A range must lead the order, so while the
|
|
41
|
+
* slider is set the grid is ordered by price — high to low when that is the
|
|
42
|
+
* sort chosen, low to high otherwise — and the grid says that Name or Newest
|
|
43
|
+
* waits until the slider is cleared.
|
|
44
|
+
*
|
|
45
|
+
* Search is WORD-PREFIX over the product's name — "cof" finds "Acme Coffee",
|
|
46
|
+
* "offee" does not — which is the honest edge of searching without a search
|
|
47
|
+
* service. Descriptions and tags are not searched: neither has a token array,
|
|
48
|
+
* and a tag is a chip of its own.
|
|
49
|
+
*
|
|
50
|
+
* ## The keys, and who stamps them
|
|
51
|
+
*
|
|
52
|
+
* `nameLower` / `nameTokens` / `priceFromCents` (`productSearchFields`),
|
|
53
|
+
* `soldOut` (`productStockFields`), `status`, `type`, `createdAtMs` and
|
|
54
|
+
* `deletedAt: null` are written by every product writer, and
|
|
55
|
+
* `tools/scripts/backfill-products-list-fields.mjs` stamps them onto the
|
|
56
|
+
* products written before. A product lacking the field a query orders by is
|
|
57
|
+
* not in that order at all.
|
|
58
|
+
*/ /** The product types a visitor can filter by, in the grid's own order. */ export const STOREFRONT_PRODUCT_TYPES = [
|
|
59
|
+
'physical',
|
|
60
|
+
'digital',
|
|
61
|
+
'service'
|
|
62
|
+
];
|
|
63
|
+
/** The grid's sort values, each the one order it puts the catalog in. */ export const STOREFRONT_CATALOG_SORTS = {
|
|
64
|
+
name: {
|
|
65
|
+
path: 'nameLower',
|
|
66
|
+
direction: 'asc'
|
|
67
|
+
},
|
|
68
|
+
'price-asc': {
|
|
69
|
+
path: 'priceFromCents',
|
|
70
|
+
direction: 'asc'
|
|
71
|
+
},
|
|
72
|
+
'price-desc': {
|
|
73
|
+
path: 'priceFromCents',
|
|
74
|
+
direction: 'desc'
|
|
75
|
+
},
|
|
76
|
+
newest: {
|
|
77
|
+
path: 'createdAtMs',
|
|
78
|
+
direction: 'desc'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/** A grid's `sort` value as its order; anything unknown reads as Name. */ export function storefrontCatalogSort(sort) {
|
|
82
|
+
var _STOREFRONT_CATALOG_SORTS_sort;
|
|
83
|
+
return (_STOREFRONT_CATALOG_SORTS_sort = STOREFRONT_CATALOG_SORTS[sort]) != null ? _STOREFRONT_CATALOG_SORTS_sort : STOREFRONT_CATALOG_SORTS.name;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* What the storefront catalog can be narrowed by, and how it is ordered.
|
|
87
|
+
*
|
|
88
|
+
* Columns are the catalog's own controls, so a refusal names the control the
|
|
89
|
+
* visitor touched. `category` and `tag` match whole members of their arrays,
|
|
90
|
+
* byte for byte (`verbatimTokens`): a category id is an opaque id and a tag
|
|
91
|
+
* is matched exactly as the merchant typed it, as the grid always matched it.
|
|
92
|
+
* `stock` is the In stock chip, `soldOut == false`.
|
|
93
|
+
*
|
|
94
|
+
* The four sorts are the grid's sort select — every one a visitor or an author
|
|
95
|
+
* can choose — with Name, the author default, first.
|
|
96
|
+
*/ export const STOREFRONT_CATALOG_DECLARATION = {
|
|
97
|
+
fields: [
|
|
98
|
+
{
|
|
99
|
+
column: 'category',
|
|
100
|
+
kind: 'exact',
|
|
101
|
+
path: 'categoryIds',
|
|
102
|
+
tokensPath: 'categoryIds',
|
|
103
|
+
verbatimTokens: true,
|
|
104
|
+
operators: [
|
|
105
|
+
'contains'
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
column: 'tag',
|
|
110
|
+
kind: 'exact',
|
|
111
|
+
path: 'tags',
|
|
112
|
+
tokensPath: 'tags',
|
|
113
|
+
verbatimTokens: true,
|
|
114
|
+
operators: [
|
|
115
|
+
'contains'
|
|
116
|
+
]
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
column: 'type',
|
|
120
|
+
kind: 'exact',
|
|
121
|
+
path: 'type',
|
|
122
|
+
presence: 'always',
|
|
123
|
+
operators: [
|
|
124
|
+
'equals',
|
|
125
|
+
'isAnyOf'
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
column: 'price',
|
|
130
|
+
kind: 'number',
|
|
131
|
+
path: 'priceFromCents',
|
|
132
|
+
presence: 'always',
|
|
133
|
+
operators: [
|
|
134
|
+
'>=',
|
|
135
|
+
'<=',
|
|
136
|
+
'<'
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
column: 'stock',
|
|
141
|
+
kind: 'boolean',
|
|
142
|
+
path: 'soldOut',
|
|
143
|
+
operators: [
|
|
144
|
+
'is'
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
sorts: [
|
|
149
|
+
STOREFRONT_CATALOG_SORTS.name,
|
|
150
|
+
STOREFRONT_CATALOG_SORTS['price-asc'],
|
|
151
|
+
STOREFRONT_CATALOG_SORTS['price-desc'],
|
|
152
|
+
STOREFRONT_CATALOG_SORTS.newest
|
|
153
|
+
],
|
|
154
|
+
search: {
|
|
155
|
+
tokensPath: 'nameTokens'
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/** Each control as a refusal names it to a visitor. */ export const STOREFRONT_CATALOG_HEADERS = {
|
|
159
|
+
category: 'Category',
|
|
160
|
+
tag: 'Tag',
|
|
161
|
+
type: 'Type',
|
|
162
|
+
price: 'Price',
|
|
163
|
+
stock: 'Availability'
|
|
164
|
+
};
|
|
165
|
+
/** Picked values as a refusal names them; categories are added by the grid. */ export const STOREFRONT_CATALOG_OPTIONS = {
|
|
166
|
+
type: [
|
|
167
|
+
{
|
|
168
|
+
value: 'physical',
|
|
169
|
+
label: 'Physical'
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
value: 'digital',
|
|
173
|
+
label: 'Digital'
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
value: 'service',
|
|
177
|
+
label: 'Service'
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
stock: [
|
|
181
|
+
{
|
|
182
|
+
value: 'false',
|
|
183
|
+
label: 'In stock'
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* The scope every storefront read carries: products a shopper can buy — live
|
|
189
|
+
* (`deletedAt == null`; a delete stamps a timestamp and leaves `status`
|
|
190
|
+
* alone) and active. Both are equalities, merged with every other predicate.
|
|
191
|
+
*/ export const STOREFRONT_CATALOG_BASE = [
|
|
192
|
+
{
|
|
193
|
+
path: 'deletedAt',
|
|
194
|
+
op: '==',
|
|
195
|
+
value: null
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
path: 'status',
|
|
199
|
+
op: '==',
|
|
200
|
+
value: 'active'
|
|
201
|
+
}
|
|
202
|
+
];
|
|
203
|
+
/**
|
|
204
|
+
* {@link STOREFRONT_CATALOG_BASE} as `listQueryIndexes` takes it, with the
|
|
205
|
+
* scope a smart collection no query can express adds to it: the membership
|
|
206
|
+
* every product writer stores (`collectionIds array-contains <id>`,
|
|
207
|
+
* AGL-3321), one `(collectionIds, order)` composite per sort under index
|
|
208
|
+
* merging.
|
|
209
|
+
*/ export const STOREFRONT_CATALOG_BASE_PATHS = [
|
|
210
|
+
{
|
|
211
|
+
path: 'deletedAt'
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
path: 'status'
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
path: 'collectionIds',
|
|
218
|
+
array: true
|
|
219
|
+
}
|
|
220
|
+
];
|
|
221
|
+
/**
|
|
222
|
+
* A SMART collection's rules as query predicates (AGL-3321), or the reason
|
|
223
|
+
* they cannot be one.
|
|
224
|
+
*
|
|
225
|
+
* Membership is `matchesCollection` in the model. What a query can ask of it:
|
|
226
|
+
*
|
|
227
|
+
* tag / category is → `array-contains` (one array clause per query)
|
|
228
|
+
* type is / not → `==` / `in` the other types
|
|
229
|
+
* price under → `priceFromCents <`, the low end being what
|
|
230
|
+
* `under` compares
|
|
231
|
+
*
|
|
232
|
+
* and what it cannot: a tag or category NOT held (no negated array query), a
|
|
233
|
+
* price `over` or `equals` (they compare the HIGH end, which nothing stores),
|
|
234
|
+
* a name rule (substring, which no index answers), and any-of rules (an OR the
|
|
235
|
+
* visitor's own filters would multiply). Those collections are read by the
|
|
236
|
+
* membership each product stores instead — `collectionIds array-contains
|
|
237
|
+
* <id>`, computed from the same rules by `productCollectionIds` — never from a
|
|
238
|
+
* window of the catalog.
|
|
239
|
+
*/ export function smartCollectionScope(collection) {
|
|
240
|
+
var _collection_rules;
|
|
241
|
+
const rules = (_collection_rules = collection.rules) != null ? _collection_rules : [];
|
|
242
|
+
if (!rules.length) return {
|
|
243
|
+
filters: []
|
|
244
|
+
};
|
|
245
|
+
if (collection.matchAll === false && rules.length > 1) {
|
|
246
|
+
return {
|
|
247
|
+
unservable: 'matches ANY of its rules'
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const filters = [];
|
|
251
|
+
let arrays = 0;
|
|
252
|
+
for (const rule of rules){
|
|
253
|
+
const shape = ruleFilter(rule);
|
|
254
|
+
if (typeof shape === 'string') return {
|
|
255
|
+
unservable: shape
|
|
256
|
+
};
|
|
257
|
+
if (shape.op === 'array-contains') arrays += 1;
|
|
258
|
+
if (arrays > 1) return {
|
|
259
|
+
unservable: 'names more than one tag or category'
|
|
260
|
+
};
|
|
261
|
+
filters.push(shape);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
filters
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function ruleFilter(rule) {
|
|
268
|
+
const value = rule.value;
|
|
269
|
+
switch(rule.field){
|
|
270
|
+
case 'tag':
|
|
271
|
+
case 'categoryId':
|
|
272
|
+
if (rule.op !== 'eq') return `excludes a ${rule.field === 'tag' ? 'tag' : 'category'}`;
|
|
273
|
+
return {
|
|
274
|
+
path: rule.field === 'tag' ? 'tags' : 'categoryIds',
|
|
275
|
+
op: 'array-contains',
|
|
276
|
+
value: String(value)
|
|
277
|
+
};
|
|
278
|
+
case 'type':
|
|
279
|
+
{
|
|
280
|
+
if (rule.op === 'eq') return {
|
|
281
|
+
path: 'type',
|
|
282
|
+
op: '==',
|
|
283
|
+
value: String(value)
|
|
284
|
+
};
|
|
285
|
+
if (rule.op === 'neq') {
|
|
286
|
+
return {
|
|
287
|
+
path: 'type',
|
|
288
|
+
op: 'in',
|
|
289
|
+
value: STOREFRONT_PRODUCT_TYPES.filter((type)=>type !== value)
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return `compares a type by ${rule.op}`;
|
|
293
|
+
}
|
|
294
|
+
case 'priceUsd':
|
|
295
|
+
{
|
|
296
|
+
const dollars = Number(value);
|
|
297
|
+
if (!Number.isFinite(dollars)) return 'compares a price that is not a number';
|
|
298
|
+
if (rule.op === 'lt') {
|
|
299
|
+
return {
|
|
300
|
+
path: 'priceFromCents',
|
|
301
|
+
op: '<',
|
|
302
|
+
value: Math.round(dollars * 100)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return `compares the price by ${rule.op}`;
|
|
306
|
+
}
|
|
307
|
+
default:
|
|
308
|
+
return `matches on ${rule.field}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
//# sourceMappingURL=storefront-catalog-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/constants/storefront-catalog-query.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 type { ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport type {\n CollectionRule,\n HostCollection,\n ProductType,\n} from '../model/commerce'\n\n/*\n * THE STOREFRONT CATALOG, ON THE QUERY (AGL-3321).\n *\n * The Product grid a visitor browses — its search box, category and tag\n * chips, type chips, In stock chip, price slider and sort select — used to be\n * answered by reading `limit(500)` products in no particular order and\n * narrowing that in memory. A catalog past five hundred products listed an\n * arbitrary five hundred, and a search, a category or a price range answered\n * \"no products match\" for every product outside them: the one answer a\n * storefront must never give wrongly, to the one person trying to buy the\n * thing.\n *\n * Every predicate is on the Firestore query now, through the list query plan\n * (`planListQuery`), and pages come from the query's own cursor. The catalog\n * is read on the server with the Admin SDK (`server/catalog.ts`), under the\n * scope below, so an anonymous shopper never queries the collection itself.\n *\n * ## What one query can hold, as a shopper meets it\n *\n * One array clause: the search word (`nameTokens`), a category (`categoryIds`)\n * or a tag (`tags`). A grid pinned to a category that a visitor also searches\n * serves the search and says the category is not applied — the plan's rule,\n * shown by the grid rather than papered over by matching the page in memory.\n *\n * One range: the price slider. A range must lead the order, so while the\n * slider is set the grid is ordered by price — high to low when that is the\n * sort chosen, low to high otherwise — and the grid says that Name or Newest\n * waits until the slider is cleared.\n *\n * Search is WORD-PREFIX over the product's name — \"cof\" finds \"Acme Coffee\",\n * \"offee\" does not — which is the honest edge of searching without a search\n * service. Descriptions and tags are not searched: neither has a token array,\n * and a tag is a chip of its own.\n *\n * ## The keys, and who stamps them\n *\n * `nameLower` / `nameTokens` / `priceFromCents` (`productSearchFields`),\n * `soldOut` (`productStockFields`), `status`, `type`, `createdAtMs` and\n * `deletedAt: null` are written by every product writer, and\n * `tools/scripts/backfill-products-list-fields.mjs` stamps them onto the\n * products written before. A product lacking the field a query orders by is\n * not in that order at all.\n */\n\n/** The product types a visitor can filter by, in the grid's own order. */\nexport const STOREFRONT_PRODUCT_TYPES: readonly ProductType[] = [\n 'physical',\n 'digital',\n 'service',\n]\n\n/** The grid's sort values, each the one order it puts the catalog in. */\nexport const STOREFRONT_CATALOG_SORTS = {\n name: { path: 'nameLower', direction: 'asc' },\n 'price-asc': { path: 'priceFromCents', direction: 'asc' },\n 'price-desc': { path: 'priceFromCents', direction: 'desc' },\n newest: { path: 'createdAtMs', direction: 'desc' },\n} as const satisfies Record<string, ListQuerySort>\n\nexport type StorefrontCatalogSort = keyof typeof STOREFRONT_CATALOG_SORTS\n\n/** A grid's `sort` value as its order; anything unknown reads as Name. */\nexport function storefrontCatalogSort(sort: string | undefined): ListQuerySort {\n return (\n STOREFRONT_CATALOG_SORTS[sort as StorefrontCatalogSort] ??\n STOREFRONT_CATALOG_SORTS.name\n )\n}\n\n/**\n * What the storefront catalog can be narrowed by, and how it is ordered.\n *\n * Columns are the catalog's own controls, so a refusal names the control the\n * visitor touched. `category` and `tag` match whole members of their arrays,\n * byte for byte (`verbatimTokens`): a category id is an opaque id and a tag\n * is matched exactly as the merchant typed it, as the grid always matched it.\n * `stock` is the In stock chip, `soldOut == false`.\n *\n * The four sorts are the grid's sort select — every one a visitor or an author\n * can choose — with Name, the author default, first.\n */\nexport const STOREFRONT_CATALOG_DECLARATION: ListQueryDeclaration = {\n fields: [\n {\n column: 'category',\n kind: 'exact',\n path: 'categoryIds',\n tokensPath: 'categoryIds',\n verbatimTokens: true,\n operators: ['contains'],\n },\n {\n column: 'tag',\n kind: 'exact',\n path: 'tags',\n tokensPath: 'tags',\n verbatimTokens: true,\n operators: ['contains'],\n },\n {\n column: 'type',\n kind: 'exact',\n path: 'type',\n presence: 'always',\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: 'price',\n kind: 'number',\n path: 'priceFromCents',\n presence: 'always',\n operators: ['>=', '<=', '<'],\n },\n { column: 'stock', kind: 'boolean', path: 'soldOut', operators: ['is'] },\n ],\n sorts: [\n STOREFRONT_CATALOG_SORTS.name,\n STOREFRONT_CATALOG_SORTS['price-asc'],\n STOREFRONT_CATALOG_SORTS['price-desc'],\n STOREFRONT_CATALOG_SORTS.newest,\n ],\n search: { tokensPath: 'nameTokens' },\n}\n\n/** Each control as a refusal names it to a visitor. */\nexport const STOREFRONT_CATALOG_HEADERS: Readonly<Record<string, string>> = {\n category: 'Category',\n tag: 'Tag',\n type: 'Type',\n price: 'Price',\n stock: 'Availability',\n}\n\n/** Picked values as a refusal names them; categories are added by the grid. */\nexport const STOREFRONT_CATALOG_OPTIONS: Readonly<Record<string, readonly ListFilterOption[]>> = {\n type: [\n { value: 'physical', label: 'Physical' },\n { value: 'digital', label: 'Digital' },\n { value: 'service', label: 'Service' },\n ],\n stock: [{ value: 'false', label: 'In stock' }],\n}\n\n/**\n * The scope every storefront read carries: products a shopper can buy — live\n * (`deletedAt == null`; a delete stamps a timestamp and leaves `status`\n * alone) and active. Both are equalities, merged with every other predicate.\n */\nexport const STOREFRONT_CATALOG_BASE: readonly ListQueryFilter[] = [\n { path: 'deletedAt', op: '==', value: null },\n { path: 'status', op: '==', value: 'active' },\n]\n\n/**\n * {@link STOREFRONT_CATALOG_BASE} as `listQueryIndexes` takes it, with the\n * scope a smart collection no query can express adds to it: the membership\n * every product writer stores (`collectionIds array-contains <id>`,\n * AGL-3321), one `(collectionIds, order)` composite per sort under index\n * merging.\n */\nexport const STOREFRONT_CATALOG_BASE_PATHS: readonly { path: string; array?: boolean }[] = [\n { path: 'deletedAt' },\n { path: 'status' },\n { path: 'collectionIds', array: true },\n]\n\n/** The smart-collection rule predicates a query can hold, or why it cannot. */\nexport type SmartCollectionScope =\n | { filters: ListQueryFilter[] }\n | { unservable: string }\n\n/**\n * A SMART collection's rules as query predicates (AGL-3321), or the reason\n * they cannot be one.\n *\n * Membership is `matchesCollection` in the model. What a query can ask of it:\n *\n * tag / category is → `array-contains` (one array clause per query)\n * type is / not → `==` / `in` the other types\n * price under → `priceFromCents <`, the low end being what\n * `under` compares\n *\n * and what it cannot: a tag or category NOT held (no negated array query), a\n * price `over` or `equals` (they compare the HIGH end, which nothing stores),\n * a name rule (substring, which no index answers), and any-of rules (an OR the\n * visitor's own filters would multiply). Those collections are read by the\n * membership each product stores instead — `collectionIds array-contains\n * <id>`, computed from the same rules by `productCollectionIds` — never from a\n * window of the catalog.\n */\nexport function smartCollectionScope(\n collection: Pick<HostCollection, 'rules' | 'matchAll'>,\n): SmartCollectionScope {\n const rules = collection.rules ?? []\n if (!rules.length) return { filters: [] }\n if (collection.matchAll === false && rules.length > 1) {\n return { unservable: 'matches ANY of its rules' }\n }\n const filters: ListQueryFilter[] = []\n let arrays = 0\n for (const rule of rules) {\n const shape = ruleFilter(rule)\n if (typeof shape === 'string') return { unservable: shape }\n if (shape.op === 'array-contains') arrays += 1\n if (arrays > 1) return { unservable: 'names more than one tag or category' }\n filters.push(shape)\n }\n return { filters }\n}\n\nfunction ruleFilter(rule: CollectionRule): ListQueryFilter | string {\n const value = rule.value\n switch (rule.field) {\n case 'tag':\n case 'categoryId':\n if (rule.op !== 'eq') return `excludes a ${rule.field === 'tag' ? 'tag' : 'category'}`\n return {\n path: rule.field === 'tag' ? 'tags' : 'categoryIds',\n op: 'array-contains',\n value: String(value),\n }\n case 'type': {\n if (rule.op === 'eq') return { path: 'type', op: '==', value: String(value) }\n if (rule.op === 'neq') {\n return {\n path: 'type',\n op: 'in',\n value: STOREFRONT_PRODUCT_TYPES.filter((type) => type !== value),\n }\n }\n return `compares a type by ${rule.op}`\n }\n case 'priceUsd': {\n const dollars = Number(value)\n if (!Number.isFinite(dollars)) return 'compares a price that is not a number'\n if (rule.op === 'lt') {\n return { path: 'priceFromCents', op: '<', value: Math.round(dollars * 100) }\n }\n return `compares the price by ${rule.op}`\n }\n default:\n return `matches on ${rule.field}`\n }\n}\n"],"names":["STOREFRONT_PRODUCT_TYPES","STOREFRONT_CATALOG_SORTS","name","path","direction","newest","storefrontCatalogSort","sort","STOREFRONT_CATALOG_DECLARATION","fields","column","kind","tokensPath","verbatimTokens","operators","presence","sorts","search","STOREFRONT_CATALOG_HEADERS","category","tag","type","price","stock","STOREFRONT_CATALOG_OPTIONS","value","label","STOREFRONT_CATALOG_BASE","op","STOREFRONT_CATALOG_BASE_PATHS","array","smartCollectionScope","collection","rules","length","filters","matchAll","unservable","arrays","rule","shape","ruleFilter","push","field","String","filter","dollars","Number","isFinite","Math","round"],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** `scanAbandonedCheckouts` waits this long before it will remind a checkout. */
|
|
18
|
+
export declare const CHECKOUT_REMIND_AFTER_MS: number;
|
|
19
|
+
/** …and gives up after this, marking the checkout `expired`. */
|
|
20
|
+
export declare const CHECKOUT_GIVE_UP_AFTER_MS: number;
|
|
21
|
+
/**
|
|
22
|
+
* - `pending` — carries an email and has not been reminded: the scan's work.
|
|
23
|
+
* - `reminded` — the scan has sent (or settled) its one reminder.
|
|
24
|
+
* - `none` — no email, so there is nobody to remind.
|
|
25
|
+
*/
|
|
26
|
+
export type CheckoutRecoveryState = 'pending' | 'reminded' | 'none';
|
|
27
|
+
/** The field every checkout writer stamps. */
|
|
28
|
+
export declare const CHECKOUT_RECOVERY_STATE_FIELD = "recoveryState";
|
|
29
|
+
/**
|
|
30
|
+
* A checkout's recovery state from what it stores, read the way
|
|
31
|
+
* `scanAbandonedCheckouts` reads it: a truthy `remindedAtMs` retires it, and
|
|
32
|
+
* without an email there is nobody to remind.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkoutRecoveryState(checkout: {
|
|
35
|
+
email?: unknown;
|
|
36
|
+
remindedAtMs?: unknown;
|
|
37
|
+
}): CheckoutRecoveryState;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /*
|
|
17
|
+
* WHERE A CHECKOUT STANDS IN ABANDONED-CART RECOVERY, WRITTEN ON IT (AGL-3321).
|
|
18
|
+
*
|
|
19
|
+
* `scanAbandonedCheckouts` acts on an open checkout that carries an email and
|
|
20
|
+
* has not been reminded yet. The console's recovery queue counts the same
|
|
21
|
+
* set with Firestore count queries, and a query cannot ask for a field a
|
|
22
|
+
* document lacks — "no email" and "not reminded yet" are both absences on a
|
|
23
|
+
* checkout written before this field existed. So every writer stamps the
|
|
24
|
+
* state explicitly:
|
|
25
|
+
*
|
|
26
|
+
* - `cart-checkout.ts` on create: `pending` with an email, `none` without.
|
|
27
|
+
* - `process-abandoned.ts` beside `remindedAtMs`: `reminded`.
|
|
28
|
+
*
|
|
29
|
+
* `status` still says whether the checkout is open; this says only where it
|
|
30
|
+
* stands in recovery. `tools/scripts/backfill-checkout-recovery-state.mjs`
|
|
31
|
+
* stamps the checkouts written before, from the same two fields.
|
|
32
|
+
*/ /** `scanAbandonedCheckouts` waits this long before it will remind a checkout. */ export const CHECKOUT_REMIND_AFTER_MS = 60 * 60 * 1000;
|
|
33
|
+
/** …and gives up after this, marking the checkout `expired`. */ export const CHECKOUT_GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
34
|
+
/** The field every checkout writer stamps. */ export const CHECKOUT_RECOVERY_STATE_FIELD = 'recoveryState';
|
|
35
|
+
/**
|
|
36
|
+
* A checkout's recovery state from what it stores, read the way
|
|
37
|
+
* `scanAbandonedCheckouts` reads it: a truthy `remindedAtMs` retires it, and
|
|
38
|
+
* without an email there is nobody to remind.
|
|
39
|
+
*/ export function checkoutRecoveryState(checkout) {
|
|
40
|
+
if (!checkout.email) return 'none';
|
|
41
|
+
return checkout.remindedAtMs ? 'reminded' : 'pending';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=checkout-recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/checkout-recovery.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/*\n * WHERE A CHECKOUT STANDS IN ABANDONED-CART RECOVERY, WRITTEN ON IT (AGL-3321).\n *\n * `scanAbandonedCheckouts` acts on an open checkout that carries an email and\n * has not been reminded yet. The console's recovery queue counts the same\n * set with Firestore count queries, and a query cannot ask for a field a\n * document lacks — \"no email\" and \"not reminded yet\" are both absences on a\n * checkout written before this field existed. So every writer stamps the\n * state explicitly:\n *\n * - `cart-checkout.ts` on create: `pending` with an email, `none` without.\n * - `process-abandoned.ts` beside `remindedAtMs`: `reminded`.\n *\n * `status` still says whether the checkout is open; this says only where it\n * stands in recovery. `tools/scripts/backfill-checkout-recovery-state.mjs`\n * stamps the checkouts written before, from the same two fields.\n */\n\n/** `scanAbandonedCheckouts` waits this long before it will remind a checkout. */\nexport const CHECKOUT_REMIND_AFTER_MS = 60 * 60 * 1000\n\n/** …and gives up after this, marking the checkout `expired`. */\nexport const CHECKOUT_GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000\n\n/**\n * - `pending` — carries an email and has not been reminded: the scan's work.\n * - `reminded` — the scan has sent (or settled) its one reminder.\n * - `none` — no email, so there is nobody to remind.\n */\nexport type CheckoutRecoveryState = 'pending' | 'reminded' | 'none'\n\n/** The field every checkout writer stamps. */\nexport const CHECKOUT_RECOVERY_STATE_FIELD = 'recoveryState'\n\n/**\n * A checkout's recovery state from what it stores, read the way\n * `scanAbandonedCheckouts` reads it: a truthy `remindedAtMs` retires it, and\n * without an email there is nobody to remind.\n */\nexport function checkoutRecoveryState(checkout: {\n email?: unknown\n remindedAtMs?: unknown\n}): CheckoutRecoveryState {\n if (!checkout.email) return 'none'\n return checkout.remindedAtMs ? 'reminded' : 'pending'\n}\n"],"names":["CHECKOUT_REMIND_AFTER_MS","CHECKOUT_GIVE_UP_AFTER_MS","CHECKOUT_RECOVERY_STATE_FIELD","checkoutRecoveryState","checkout","email","remindedAtMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,+EAA+E,GAC/E,OAAO,MAAMA,2BAA2B,KAAK,KAAK,KAAI;AAEtD,8DAA8D,GAC9D,OAAO,MAAMC,4BAA4B,IAAI,KAAK,KAAK,KAAK,KAAI;AAShE,4CAA4C,GAC5C,OAAO,MAAMC,gCAAgC,gBAAe;AAE5D;;;;CAIC,GACD,OAAO,SAASC,sBAAsBC,QAGrC;IACC,IAAI,CAACA,SAASC,KAAK,EAAE,OAAO;IAC5B,OAAOD,SAASE,YAAY,GAAG,aAAa;AAC9C"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { PaymentRisk } from '@aglyn/aglyn/app-utils/payment-risk';
|
|
17
18
|
import type { ProductType } from './commerce';
|
|
18
19
|
import type { StorefrontTaxMode } from './commerce-tax-decision';
|
|
19
20
|
export type OrderStatus = 'pending' | 'paid' | 'partially_fulfilled' | 'fulfilled' | 'delivered' | 'cancelled' | 'refunded';
|
|
@@ -341,6 +342,12 @@ export interface HostOrder {
|
|
|
341
342
|
refundedLineItemIds?: number[];
|
|
342
343
|
/** The card dispute against this charge, open or settled (AGL-1787). */
|
|
343
344
|
dispute?: OrderDispute;
|
|
345
|
+
/**
|
|
346
|
+
* Stripe's fraud signals on this order's payment (AGL-3360): an issuer's
|
|
347
|
+
* early fraud warning or a Radar review, stamped by the billing webhook
|
|
348
|
+
* through the shared `paymentRisk` shape. A dispute lives in `dispute`.
|
|
349
|
+
*/
|
|
350
|
+
paymentRisk?: PaymentRisk;
|
|
344
351
|
/** Stock a reversal left off the shelf, awaiting the merchant (AGL-1797). */
|
|
345
352
|
restockCheck?: OrderRestockCheck;
|
|
346
353
|
/**
|