@mohasinac/appkit 4.11.1 → 4.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +27 -23
  2. package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
  3. package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
  4. package/dist/_internal/server/features/products/index.d.ts +1 -0
  5. package/dist/_internal/server/features/products/index.js +1 -0
  6. package/dist/_internal/server/features/products/list-public.d.ts +111 -0
  7. package/dist/_internal/server/features/products/list-public.js +342 -0
  8. package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
  9. package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
  10. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
  11. package/dist/_internal/server/jobs/handlers/messages.js +5 -0
  12. package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
  13. package/dist/_internal/shared/checkout/lanes.js +106 -0
  14. package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
  15. package/dist/_internal/shared/checkout/order-math.js +30 -6
  16. package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
  17. package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
  18. package/dist/client.d.ts +2 -1
  19. package/dist/client.js +4 -1
  20. package/dist/features/account/components/NotificationBell.js +1 -0
  21. package/dist/features/account/components/UserOffersPanel.js +8 -1
  22. package/dist/features/admin/actions/notification-actions.js +1 -1
  23. package/dist/features/admin/schemas/firestore.d.ts +2 -1
  24. package/dist/features/admin/schemas/firestore.js +1 -0
  25. package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
  26. package/dist/features/auctions/actions/bid-actions.js +29 -15
  27. package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
  28. package/dist/features/auctions/components/AuctionsListView.js +12 -53
  29. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
  30. package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
  31. package/dist/features/auctions/repository/bid.repository.js +19 -0
  32. package/dist/features/auctions/schemas/firestore.d.ts +11 -1
  33. package/dist/features/auctions/schemas/firestore.js +1 -0
  34. package/dist/features/cart/actions/cart-actions.d.ts +11 -0
  35. package/dist/features/cart/actions/cart-actions.js +24 -0
  36. package/dist/features/cart/repository/cart.repository.d.ts +24 -1
  37. package/dist/features/cart/repository/cart.repository.js +71 -6
  38. package/dist/features/cart/schemas/firestore.d.ts +23 -2
  39. package/dist/features/contact/email.js +52 -1
  40. package/dist/features/orders/repository/orders.repository.d.ts +0 -15
  41. package/dist/features/orders/repository/orders.repository.js +0 -27
  42. package/dist/features/orders/utils/order-splitter.js +14 -2
  43. package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
  44. package/dist/features/products/components/ArtStickersListView.js +10 -49
  45. package/dist/features/products/components/ProductsIndexPageView.js +9 -62
  46. package/dist/features/products/repository/products.repository.js +41 -5
  47. package/dist/features/seller/actions/offer-actions.d.ts +6 -0
  48. package/dist/features/seller/actions/offer-actions.js +8 -9
  49. package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
  50. package/dist/features/seller/components/SellerOffersView.js +59 -8
  51. package/dist/features/seller/repository/offer.repository.d.ts +18 -2
  52. package/dist/features/seller/repository/offer.repository.js +38 -2
  53. package/dist/features/seller/schemas/firestore.d.ts +7 -2
  54. package/dist/features/seller/schemas/firestore.js +3 -0
  55. package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
  56. package/dist/features/seller/schemas/offer-forms.js +28 -0
  57. package/dist/index.d.ts +2 -1
  58. package/dist/index.js +4 -1
  59. package/dist/server-entry.d.ts +1 -0
  60. package/dist/server-entry.js +1 -0
  61. package/dist/server.d.ts +1 -0
  62. package/dist/server.js +3 -0
  63. package/package.json +1 -1
@@ -0,0 +1,342 @@
1
+ /*
2
+ * WHY: One implementation of "list products for a public listing page", shared by
3
+ * every SSR listing view AND `/api/products`. Before 2026-08-21 there were five
4
+ * hand-rolled copies of this filter logic (four SSR views + the route) and they
5
+ * had drifted: the route had learned — via `6fe4e0dd8` and `efb7d1b6a` — that an
6
+ * `inStock` (`stockQuantity>0`) or mismatched date-range inequality must NEVER be
7
+ * pushed into the Firestore query, while the SSR views still pushed it and
8
+ * swallowed the resulting FAILED_PRECONDITION as "no results". That is what made
9
+ * /art render empty until "Show sold" was clicked.
10
+ *
11
+ * WHAT: `parsePublicProductParams` (URL/searchParams -> typed input) and
12
+ * `listPublicProducts` (typed input -> page of documents). Firestore-safe
13
+ * clauses go into the query; `inStock` and unsafe date ranges are applied as
14
+ * in-memory predicates over one bounded fetch, then re-sorted into the caller's
15
+ * requested order and re-paginated.
16
+ *
17
+ * EXPORTS:
18
+ * PublicProductListInput, PublicProductListResult, PublicProductExecutor,
19
+ * PublicProductListOptions, parsePublicProductParams, listPublicProducts
20
+ *
21
+ * @tag domain:products
22
+ * @tag layer:server-data
23
+ * @tag pattern:none
24
+ * @tag access:server-only
25
+ * @tag consumers:ProductsIndexPageView,ArtStickersListView,AuctionsListView,PreOrdersListView,api/products
26
+ * @tag sideEffects:firestore-read
27
+ */
28
+ import { productRepository } from "../../../../repositories";
29
+ import { normalizeError } from "../../../../errors/normalize";
30
+ import { serverLogger } from "../../../../monitoring/server-logger";
31
+ import { PRODUCT_FIELDS } from "../../../../constants/field-names";
32
+ import { TABLE_KEYS } from "../../../../constants/table-keys";
33
+ import { sortBy } from "../../../../constants/sort";
34
+ import { SIEVE_OP, sieveAnd, sieveFilter, expandSieveParam, } from "../../../../utils/sieve-builder";
35
+ /** Vercel Hobby Fluid Compute ceiling (CLAUDE.md Rule #6) — never fetch more at once. */
36
+ export const PUBLIC_PRODUCT_MAX_PAGE_SIZE = 50;
37
+ const DEFAULT_PAGE = 1;
38
+ const DEFAULT_PAGE_SIZE = 24;
39
+ const DEFAULT_SORTS = sortBy(PRODUCT_FIELDS.CREATED_AT);
40
+ // ---------------------------------------------------------------------------
41
+ // Param parsing
42
+ // ---------------------------------------------------------------------------
43
+ function first(params, key) {
44
+ if (params instanceof URLSearchParams)
45
+ return params.get(key) ?? "";
46
+ const v = params[key];
47
+ return (Array.isArray(v) ? v[0] : v) ?? "";
48
+ }
49
+ function num(raw) {
50
+ if (!raw)
51
+ return undefined;
52
+ const n = Number(raw);
53
+ return Number.isFinite(n) ? n : undefined;
54
+ }
55
+ /**
56
+ * Read the shared public-listing query params. SSR views hand in Next's
57
+ * `searchParams` record; the API route hands in `url.searchParams`. Both produce
58
+ * the same typed input, which is the whole point — the two paths cannot drift.
59
+ */
60
+ export function parsePublicProductParams(params, defaults) {
61
+ const get = (k) => first(params, k);
62
+ // A single type chip narrows the page's span; anything else keeps the full span.
63
+ const typeParam = get(TABLE_KEYS.LISTING_TYPE);
64
+ const span = defaults?.listingTypes;
65
+ const listingTypes = typeParam
66
+ ? span && !span.includes(typeParam)
67
+ ? span // an unknown chip value must not widen the page beyond its own span
68
+ : [typeParam]
69
+ : span;
70
+ const featuresRaw = get(TABLE_KEYS.FEATURES);
71
+ return {
72
+ listingTypes,
73
+ q: get(TABLE_KEYS.QUERY) || undefined,
74
+ category: get(TABLE_KEYS.CATEGORY) || get(TABLE_KEYS.CATEGORY_SLUG) || undefined,
75
+ brand: get(TABLE_KEYS.BRAND) || undefined,
76
+ condition: get(TABLE_KEYS.CONDITION) || undefined,
77
+ storeId: get(TABLE_KEYS.STORE_ID) || get(TABLE_KEYS.SELLER) || undefined,
78
+ status: get(TABLE_KEYS.STATUS) || undefined,
79
+ minPrice: get(TABLE_KEYS.MIN_PRICE) || undefined,
80
+ maxPrice: get(TABLE_KEYS.MAX_PRICE) || undefined,
81
+ minBid: get(TABLE_KEYS.MIN_BID) || undefined,
82
+ maxBid: get(TABLE_KEYS.MAX_BID) || undefined,
83
+ featured: get(TABLE_KEYS.FEATURED) === "true" || undefined,
84
+ isPromoted: get("isPromoted") === "true" || undefined,
85
+ freeShipping: get(TABLE_KEYS.FREE_SHIPPING) === "true" || undefined,
86
+ isPartOfBundle: get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true" || undefined,
87
+ features: featuresRaw ? featuresRaw.split("|").filter(Boolean) : undefined,
88
+ preOrderProductionStatus: get(TABLE_KEYS.PREORDER_STATUS) || get("preOrderStatus") || undefined,
89
+ prizeRevealStatus: get(TABLE_KEYS.PRIZE_REVEAL_STATUS) || undefined,
90
+ // Three spellings of one intent — /products and /art call it "Show sold",
91
+ // /pre-orders calls it "Show closed", and the client hook sends `inStock`.
92
+ // Any of them turning the filter OFF wins, so SSR and the client refetch
93
+ // agree on what the default view means (Root Cause #30).
94
+ inStock: get(TABLE_KEYS.SHOW_SOLD) === "true" || get(TABLE_KEYS.SHOW_CLOSED) === "true"
95
+ ? undefined
96
+ : get(TABLE_KEYS.IN_STOCK)
97
+ ? get(TABLE_KEYS.IN_STOCK) === "true"
98
+ : defaults?.hideSoldByDefault
99
+ ? true
100
+ : undefined,
101
+ dateFrom: get(TABLE_KEYS.DATE_FROM) ||
102
+ (defaults?.hideEndedByDefault && get(TABLE_KEYS.SHOW_ENDED) !== "true"
103
+ ? new Date().toISOString()
104
+ : undefined),
105
+ dateTo: get(TABLE_KEYS.DATE_TO) || undefined,
106
+ page: num(get(TABLE_KEYS.PAGE)) ?? DEFAULT_PAGE,
107
+ pageSize: num(get(TABLE_KEYS.PAGE_SIZE)) ?? defaults?.pageSize ?? DEFAULT_PAGE_SIZE,
108
+ sorts: get(TABLE_KEYS.SORT) || defaults?.sorts || DEFAULT_SORTS,
109
+ };
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Filter building
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Only clauses Firestore can satisfy alongside an arbitrary `orderBy`. Notably
116
+ * ABSENT: `inStock` (a `stockQuantity` range) and, unless the sort matches, the
117
+ * auction/pre-order date ranges. Firestore appends an inequality field to the
118
+ * orderBy implicitly, so pairing one with an unrelated sort demands a composite
119
+ * index in an order nobody declares — it fails with FAILED_PRECONDITION, which
120
+ * upstream reads as a bare "no results".
121
+ */
122
+ function buildFirestoreSafeFilters(input) {
123
+ const parts = [];
124
+ // Published-only unless the caller explicitly asks otherwise. Without this
125
+ // default, any refetch that omits `status` leaks drafts (Root Cause #30).
126
+ parts.push(sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, input.status || PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED));
127
+ const types = input.listingTypes?.filter(Boolean) ?? [];
128
+ if (types.length > 0) {
129
+ parts.push(sieveFilter(PRODUCT_FIELDS.LISTING_TYPE, SIEVE_OP.EQ, types.join("|")));
130
+ }
131
+ if (input.category) {
132
+ parts.push(expandSieveParam(PRODUCT_FIELDS.CATEGORY_SLUGS, input.category, SIEVE_OP.CONTAINS));
133
+ }
134
+ if (input.brand)
135
+ parts.push(sieveFilter(PRODUCT_FIELDS.BRAND, SIEVE_OP.EQ, input.brand));
136
+ if (input.condition) {
137
+ // Pipe-joined OR-group, NOT `sieveMultiEq` — that emits
138
+ // `condition==new,condition==used`, an AND of two equalities on one field,
139
+ // which can never match a document.
140
+ const values = input.condition.split("|").filter(Boolean);
141
+ if (values.length > 0) {
142
+ parts.push(sieveFilter(PRODUCT_FIELDS.CONDITION, SIEVE_OP.EQ, values.join("|")));
143
+ }
144
+ }
145
+ if (input.storeId)
146
+ parts.push(sieveFilter(PRODUCT_FIELDS.STORE_ID, SIEVE_OP.EQ, input.storeId));
147
+ const minPrice = num(input.minPrice ?? "");
148
+ if (minPrice !== undefined)
149
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRICE, SIEVE_OP.GTE, minPrice));
150
+ const maxPrice = num(input.maxPrice ?? "");
151
+ if (maxPrice !== undefined)
152
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRICE, SIEVE_OP.LTE, maxPrice));
153
+ const minBid = num(input.minBid ?? "");
154
+ if (minBid !== undefined)
155
+ parts.push(sieveFilter(PRODUCT_FIELDS.CURRENT_BID, SIEVE_OP.GTE, minBid));
156
+ const maxBid = num(input.maxBid ?? "");
157
+ if (maxBid !== undefined)
158
+ parts.push(sieveFilter(PRODUCT_FIELDS.CURRENT_BID, SIEVE_OP.LTE, maxBid));
159
+ if (input.featured)
160
+ parts.push(sieveFilter(PRODUCT_FIELDS.FEATURED, SIEVE_OP.EQ, true));
161
+ if (input.isPromoted)
162
+ parts.push(sieveFilter(PRODUCT_FIELDS.IS_PROMOTED, SIEVE_OP.EQ, true));
163
+ if (input.freeShipping) {
164
+ parts.push(sieveFilter(PRODUCT_FIELDS.SHIPPING_PAID_BY, SIEVE_OP.EQ, PRODUCT_FIELDS.SHIPPING_PAID_BY_VALUES.SELLER));
165
+ }
166
+ if (input.isPartOfBundle)
167
+ parts.push(sieveFilter("isPartOfBundle", SIEVE_OP.EQ, true));
168
+ if (input.preOrderProductionStatus) {
169
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRE_ORDER_PRODUCTION_STATUS, SIEVE_OP.EQ, input.preOrderProductionStatus));
170
+ }
171
+ if (input.prizeRevealStatus) {
172
+ parts.push(sieveFilter(PRODUCT_FIELDS.PRIZE_REVEAL_STATUS, SIEVE_OP.EQ, input.prizeRevealStatus));
173
+ }
174
+ if (input.q)
175
+ parts.push(sieveFilter(PRODUCT_FIELDS.TITLE, SIEVE_OP.CONTAINS_CI, input.q));
176
+ // `array-contains-any` isn't supported by the Sieve Firebase adapter, so only a
177
+ // single feature can be pushed down; multi-select falls through to the caller.
178
+ if (input.features?.length === 1) {
179
+ parts.push(sieveFilter(PRODUCT_FIELDS.FEATURES, SIEVE_OP.CONTAINS, input.features[0]));
180
+ }
181
+ if (input.rawFilters)
182
+ parts.push(input.rawFilters);
183
+ return sieveAnd(...parts.filter(Boolean));
184
+ }
185
+ /** Which Timestamp field a date range refers to, given the requested listing types. */
186
+ function dateFieldFor(types) {
187
+ if (!types || types.length !== 1)
188
+ return null;
189
+ if (types[0] === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION) {
190
+ return PRODUCT_FIELDS.AUCTION_END_DATE;
191
+ }
192
+ if (types[0] === PRODUCT_FIELDS.LISTING_TYPE_VALUES.PRE_ORDER) {
193
+ return PRODUCT_FIELDS.PRE_ORDER_DELIVERY_DATE;
194
+ }
195
+ return null;
196
+ }
197
+ async function defaultExecutor(query) {
198
+ const result = await productRepository.list({
199
+ filters: query.filters,
200
+ sorts: query.sorts,
201
+ page: query.page,
202
+ pageSize: query.pageSize,
203
+ });
204
+ return {
205
+ items: result.items,
206
+ total: result.total,
207
+ page: result.page,
208
+ totalPages: result.totalPages,
209
+ hasMore: result.hasMore,
210
+ cursor: null,
211
+ };
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // The one public listing query
215
+ // ---------------------------------------------------------------------------
216
+ export async function listPublicProducts(input, opts) {
217
+ const page = Math.max(1, input.page ?? DEFAULT_PAGE);
218
+ const pageSize = Math.min(PUBLIC_PRODUCT_MAX_PAGE_SIZE, Math.max(1, input.pageSize ?? DEFAULT_PAGE_SIZE));
219
+ const sorts = input.sorts || DEFAULT_SORTS;
220
+ const requestedSortField = sorts.replace(/^-/, "");
221
+ const dateField = dateFieldFor(input.listingTypes);
222
+ const hasDateRange = Boolean((input.dateFrom || input.dateTo) && dateField);
223
+ // Firestore DOES accept one inequality when the query sorts by that same field —
224
+ // the auctions "Ending Soon" default is exactly that shape, so push it down.
225
+ const canPushDate = hasDateRange && !input.inStock && requestedSortField === dateField;
226
+ const safeFilters = buildFirestoreSafeFilters(input);
227
+ const filters = sieveAnd(safeFilters, ...(canPushDate && input.dateFrom
228
+ ? [sieveFilter(dateField, SIEVE_OP.GTE, input.dateFrom)]
229
+ : []), ...(canPushDate && input.dateTo
230
+ ? [sieveFilter(dateField, SIEVE_OP.LTE, input.dateTo)]
231
+ : []));
232
+ const hasUnsafeFilter = Boolean(input.inStock) || (hasDateRange && !canPushDate);
233
+ // When a date range stays in memory, fetch in whichever direction front-loads
234
+ // the rows that will PASS it: `dateFrom` (">=", still live) wants the most
235
+ // future dates first; `dateTo` ("<=", ends by) wants the earliest first.
236
+ // Fetching in the client's own order instead is what made live auctions
237
+ // invisible once ~50 had already ended.
238
+ const fetchSorts = hasUnsafeFilter && hasDateRange && dateField
239
+ ? input.dateFrom
240
+ ? sortBy(dateField, "DESC")
241
+ : sortBy(dateField, "ASC")
242
+ : sorts;
243
+ const executor = opts?.executor ?? defaultExecutor;
244
+ let result;
245
+ try {
246
+ result = await executor({
247
+ filters,
248
+ sorts: fetchSorts,
249
+ page: hasUnsafeFilter ? 1 : page,
250
+ pageSize: hasUnsafeFilter ? PUBLIC_PRODUCT_MAX_PAGE_SIZE : pageSize,
251
+ cursor: hasUnsafeFilter ? null : (input.cursor ?? null),
252
+ });
253
+ }
254
+ catch (error) {
255
+ void normalizeError(error);
256
+ // Loud. A swallowed query failure here is indistinguishable from an empty
257
+ // catalogue at every call site above, which is exactly how the /art bug hid.
258
+ serverLogger.error("listPublicProducts query failed", {
259
+ filters,
260
+ sorts: fetchSorts,
261
+ error: error instanceof Error ? error.message : String(error),
262
+ });
263
+ return null;
264
+ }
265
+ let items = result.items;
266
+ let total = result.total;
267
+ let totalPages = result.totalPages;
268
+ let resultPage = result.page;
269
+ let hasMore = result.hasMore;
270
+ let cursor = result.cursor ?? null;
271
+ if (hasUnsafeFilter) {
272
+ const filtered = items.filter((item) => {
273
+ if (input.inStock) {
274
+ const stock = item[PRODUCT_FIELDS.STOCK_QUANTITY];
275
+ if (!(typeof stock === "number" && stock > 0))
276
+ return false;
277
+ }
278
+ if (hasDateRange && dateField) {
279
+ const raw = item[dateField];
280
+ if (!raw)
281
+ return false;
282
+ const value = raw instanceof Date ? raw.toISOString() : String(raw);
283
+ if (input.dateFrom && value < input.dateFrom)
284
+ return false;
285
+ if (input.dateTo && value > input.dateTo)
286
+ return false;
287
+ }
288
+ return true;
289
+ });
290
+ const ordered = fetchSorts === sorts ? filtered : sortRows(filtered, requestedSortField, sorts.startsWith("-"));
291
+ total = ordered.length;
292
+ totalPages = Math.max(1, Math.ceil(total / pageSize));
293
+ resultPage = page;
294
+ const start = (page - 1) * pageSize;
295
+ items = ordered.slice(start, start + pageSize);
296
+ hasMore = start + pageSize < total;
297
+ cursor = null;
298
+ }
299
+ // Feature-flagged-off types never reach a public surface.
300
+ const enabled = opts?.enabledListingTypes;
301
+ if (enabled && enabled.size > 0) {
302
+ const before = items.length;
303
+ items = items.filter((it) => {
304
+ const lt = typeof it.listingType === "string" ? it.listingType : "standard";
305
+ return enabled.has(lt);
306
+ });
307
+ const removed = before - items.length;
308
+ if (removed > 0)
309
+ total = Math.max(0, total - removed);
310
+ }
311
+ return {
312
+ items,
313
+ total,
314
+ page: resultPage,
315
+ pageSize,
316
+ totalPages,
317
+ hasMore,
318
+ cursor,
319
+ filters,
320
+ sorts,
321
+ };
322
+ }
323
+ function sortRows(rows, field, desc) {
324
+ return [...rows].sort((a, b) => {
325
+ const av = a[field];
326
+ const bv = b[field];
327
+ if (av == null && bv == null)
328
+ return 0;
329
+ if (av == null)
330
+ return 1;
331
+ if (bv == null)
332
+ return -1;
333
+ const cmp = typeof av === "number" && typeof bv === "number"
334
+ ? av - bv
335
+ : String(av) < String(bv)
336
+ ? -1
337
+ : String(av) > String(bv)
338
+ ? 1
339
+ : 0;
340
+ return desc ? -cmp : cmp;
341
+ });
342
+ }
@@ -1,36 +1,105 @@
1
- import { bidRepository, orderRepository, productRepository, } from "../../../../repositories";
1
+ import { bidRepository, cartRepository, productRepository, storeRepository, } from "../../../../repositories";
2
2
  import { sendNotification } from "../../../../features/admin/actions/notification-actions";
3
3
  import { AUCTION_MESSAGES } from "../handlers/messages";
4
+ import { ROUTES } from "../../../../next/routing/route-map";
5
+ import { CART_LANE } from "../../../shared/checkout/lanes";
6
+ /**
7
+ * How long the winner has to pay before the locked cart line lapses. Mirrors
8
+ * the accepted-offer window so both locked lanes behave the same.
9
+ */
10
+ const AUCTION_CHECKOUT_WINDOW_MS = 48 * 60 * 60 * 1000;
11
+ /** Deep link the winner straight into the auction checkout lane. */
12
+ const AUCTION_CHECKOUT_URL = `${String(ROUTES.USER.CHECKOUT)}?lane=${CART_LANE.AUCTION}`;
4
13
  async function settleAuction(ctx, product) {
5
14
  const activeBids = await bidRepository.getActiveByProduct(product.id);
6
- const batch = ctx.db.batch();
7
15
  if (activeBids.length === 0) {
16
+ const batch = ctx.db.batch();
8
17
  productRepository.updateStatusInBatch(batch, product.id, "archived");
9
18
  await batch.commit();
10
19
  ctx.logger.info(AUCTION_MESSAGES.NO_BIDS_LOG(product.id));
11
20
  return;
12
21
  }
13
22
  const [winnerEntry, ...loserEntries] = activeBids;
23
+ // Reserve price. It exists on the product, is editable by the seller, is
24
+ // shown in both dashboards, and the buyer guide promises bids below it don't
25
+ // win — but settlement awarded activeBids[0] unconditionally until
26
+ // 2026-08-21. A below-reserve auction ends with NO winner.
27
+ const reserve = product.reservePrice;
28
+ if (typeof reserve === "number" && reserve > 0 && winnerEntry.data.bidAmount < reserve) {
29
+ const batch = ctx.db.batch();
30
+ activeBids.forEach(({ ref }) => bidRepository.markLost(batch, ref));
31
+ productRepository.updateStatusInBatch(batch, product.id, "archived");
32
+ await batch.commit();
33
+ const reserveStore = product.storeId
34
+ ? await storeRepository.findById(product.storeId).catch(() => null)
35
+ : null;
36
+ if (reserveStore?.ownerId) {
37
+ await sendNotification({
38
+ userId: reserveStore.ownerId,
39
+ type: "auction_ended",
40
+ priority: "normal",
41
+ title: AUCTION_MESSAGES.RESERVE_NOT_MET_TITLE,
42
+ message: AUCTION_MESSAGES.RESERVE_NOT_MET_MESSAGE(product.title, winnerEntry.data.currency, winnerEntry.data.bidAmount, reserve),
43
+ relatedId: product.id,
44
+ relatedType: "product",
45
+ });
46
+ }
47
+ await Promise.allSettled(activeBids.slice(0, 50).map(({ data: bid }) => sendNotification({
48
+ userId: bid.userId,
49
+ type: "bid_lost",
50
+ priority: "normal",
51
+ title: AUCTION_MESSAGES.LOST_TITLE,
52
+ message: AUCTION_MESSAGES.RESERVE_NOT_MET_BIDDER_MESSAGE(product.title),
53
+ relatedId: product.id,
54
+ relatedType: "product",
55
+ })));
56
+ ctx.logger.info(`Auction ${product.id} ended below reserve — no winner`, {
57
+ highestBid: winnerEntry.data.bidAmount,
58
+ reservePrice: reserve,
59
+ });
60
+ return;
61
+ }
62
+ const batch = ctx.db.batch();
14
63
  bidRepository.markWon(batch, winnerEntry.ref);
15
64
  loserEntries.forEach(({ ref }) => bidRepository.markLost(batch, ref));
16
- const orderRef = orderRepository.createFromAuction(batch, {
17
- productId: product.id,
18
- productTitle: product.title,
19
- userId: winnerEntry.data.userId,
20
- userName: winnerEntry.data.userName,
21
- userEmail: winnerEntry.data.userEmail,
22
- storeId: product.storeId,
23
- amount: winnerEntry.data.bidAmount,
24
- currency: winnerEntry.data.currency,
25
- auctionProductId: product.id,
26
- });
27
- // Two-axis model: mark as sold without changing publish status
65
+ // Two-axis model: mark as sold without changing publish status.
28
66
  batch.update(ctx.db.collection("products").doc(product.id), {
29
67
  isSold: true,
30
68
  availableQuantity: 0,
31
69
  });
32
70
  await batch.commit();
33
- // Fan-out notifications after batch commits.
71
+ // The win becomes a LOCKED CART LINE, not an order.
72
+ //
73
+ // Settlement used to call `orderRepository.createFromAuction`, which wrote a
74
+ // document that was not an OrderDocument at all — flat productId/userId/
75
+ // unitPrice, no items[], no buyerId, no paymentMethod, no shippingAddress,
76
+ // and a Firestore auto-ID instead of the `order-…` semantic id. No orders UI
77
+ // could render it and the manual-payment panel returned null for it, so an
78
+ // auction winner had literally no way to pay, anywhere in the product.
79
+ //
80
+ // Routing the win through the cart instead means the winner goes through the
81
+ // ONE checkout that already knows how to collect an address, charge a payment
82
+ // method, run the high-value OTP, split per store and produce a real order.
83
+ // Auctions stay `canAddToCart: false` for user-initiated adds — this writes
84
+ // through the repository directly, the same deliberate bypass `checkoutOffer`
85
+ // uses for accepted offers.
86
+ await cartRepository.addItem(winnerEntry.data.userId, {
87
+ productId: product.id,
88
+ productTitle: product.title,
89
+ productImage: product.mainImage ?? "",
90
+ price: winnerEntry.data.bidAmount,
91
+ currency: winnerEntry.data.currency,
92
+ quantity: 1,
93
+ storeId: product.storeId,
94
+ storeName: product.storeName ?? "",
95
+ listingType: "auction",
96
+ isAuctionWin: true,
97
+ auctionId: product.id,
98
+ bidId: winnerEntry.data.id,
99
+ lockedPrice: winnerEntry.data.bidAmount,
100
+ checkoutDeadline: new Date(ctx.now.getTime() + AUCTION_CHECKOUT_WINDOW_MS),
101
+ locked: true,
102
+ });
34
103
  await sendNotification({
35
104
  userId: winnerEntry.data.userId,
36
105
  type: "bid_won",
@@ -39,6 +108,10 @@ async function settleAuction(ctx, product) {
39
108
  message: AUCTION_MESSAGES.WON_MESSAGE(product.title, winnerEntry.data.currency, winnerEntry.data.bidAmount),
40
109
  relatedId: product.id,
41
110
  relatedType: "product",
111
+ // Previously the winner's notification linked back to the product page and
112
+ // carried no CTA — the one place it should never send them.
113
+ actionUrl: AUCTION_CHECKOUT_URL,
114
+ actionLabel: AUCTION_MESSAGES.WON_ACTION_LABEL,
42
115
  });
43
116
  await Promise.allSettled(loserEntries.slice(0, 50).map(({ data: bid }) => sendNotification({
44
117
  userId: bid.userId,
@@ -53,7 +126,7 @@ async function settleAuction(ctx, product) {
53
126
  winner: winnerEntry.data.userId,
54
127
  winningBid: winnerEntry.data.bidAmount,
55
128
  losersCount: loserEntries.length,
56
- orderId: orderRef.id,
129
+ bidId: winnerEntry.data.id,
57
130
  });
58
131
  }
59
132
  export async function runAuctionSettlement(ctx) {
@@ -1,5 +1,5 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
- import { offerRepository } from "../../../../repositories";
2
+ import { bidRepository, cartRepository, offerRepository } from "../../../../repositories";
3
3
  import { sendNotification } from "../../../../features/admin/actions/notification-actions";
4
4
  export async function runOfferExpiry(ctx) {
5
5
  ctx.logger.info("Starting offer expiry sweep");
@@ -11,11 +11,15 @@ export async function runOfferExpiry(ctx) {
11
11
  ctx.logger.error("Failed to query expired offers", err);
12
12
  throw err;
13
13
  }
14
+ // NOTE: no early return here. This job sweeps three distinct things and the
15
+ // other two (accepted-past-deadline offers, unpaid auction wins) must still
16
+ // run when there happen to be no expired pending/countered offers.
14
17
  if (expiredOffers.length === 0) {
15
- ctx.logger.info("No expired offers found");
16
- return;
18
+ ctx.logger.info("No expired pending/countered offers found");
19
+ }
20
+ else {
21
+ ctx.logger.info(`Found ${expiredOffers.length} expired offer(s) to process`);
17
22
  }
18
- ctx.logger.info(`Found ${expiredOffers.length} expired offer(s) to process`);
19
23
  const expiredIds = [];
20
24
  for (const offer of expiredOffers) {
21
25
  try {
@@ -46,8 +50,116 @@ export async function runOfferExpiry(ctx) {
46
50
  throw err;
47
51
  }
48
52
  }
53
+ await expireAcceptedPastCheckoutDeadline(ctx);
54
+ await lapseUnpaidAuctionWins(ctx);
49
55
  ctx.logger.info("Offer expiry complete", {
50
56
  processed: expiredIds.length,
51
57
  skipped: expiredOffers.length - expiredIds.length,
52
58
  });
53
59
  }
60
+ /**
61
+ * The second half of the sweep, missing until 2026-08-21.
62
+ *
63
+ * `findExpiredActive` only looks at pending/countered offers past `expiresAt`.
64
+ * An offer the seller ACCEPTED but the buyer never paid for stayed "accepted"
65
+ * forever: its locked price remained claimable long after the 48h checkout
66
+ * window the buyer was shown, and the seller had no way to take the listing
67
+ * back. This lapses those offers and clears the matching locked cart lines, so
68
+ * the lane gate stops blocking a buyer on an offer that is no longer live.
69
+ */
70
+ async function expireAcceptedPastCheckoutDeadline(ctx) {
71
+ let stale;
72
+ try {
73
+ stale = await offerRepository.findExpiredAccepted(ctx.now);
74
+ }
75
+ catch (err) {
76
+ void normalizeError(err);
77
+ ctx.logger.error("Failed to query accepted-but-unpaid offers", err);
78
+ return;
79
+ }
80
+ if (stale.length === 0)
81
+ return;
82
+ ctx.logger.info(`Found ${stale.length} accepted offer(s) past their checkout deadline`);
83
+ const ids = [];
84
+ for (const offer of stale) {
85
+ try {
86
+ // Drop the locked cart line first — leaving it behind would keep the
87
+ // buyer's offer lane non-empty and block their standard checkout on an
88
+ // offer they can no longer act on.
89
+ await cartRepository.removeItemsByOfferId(offer.buyerUid, offer.id);
90
+ ids.push(offer.id);
91
+ await sendNotification({
92
+ userId: offer.buyerUid,
93
+ type: "offer_expired",
94
+ priority: "normal",
95
+ title: "Accepted offer expired",
96
+ message: `The checkout window for your accepted offer on "${offer.productTitle}" has closed. You can make a new offer if it's still listed.`,
97
+ relatedId: offer.id,
98
+ relatedType: "offer",
99
+ });
100
+ }
101
+ catch (err) {
102
+ void normalizeError(err);
103
+ ctx.logger.warn(`Failed to lapse accepted offer ${offer.id}`, {
104
+ error: err instanceof Error ? err.message : String(err),
105
+ });
106
+ }
107
+ }
108
+ if (ids.length > 0) {
109
+ try {
110
+ await offerRepository.expireMany(ids);
111
+ }
112
+ catch (err) {
113
+ void normalizeError(err);
114
+ ctx.logger.error("Failed to batch-expire accepted offers", err);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * A won auction the buyer never paid for.
120
+ *
121
+ * Folded into this job rather than given its own scheduled function: both are
122
+ * "a locked cart line whose claim has lapsed", they want the same cadence, and
123
+ * Cloud Scheduler bills per registered job (see CLAUDE.md's Firebase budget
124
+ * table — 27 jobs is already an accepted, documented cost).
125
+ *
126
+ * The listing is NOT silently relisted. A forfeited win is a seller decision
127
+ * (relist, offer to the runner-up, or ban the non-payer), so this clears the
128
+ * line, marks the bid forfeited, and notifies both sides.
129
+ */
130
+ async function lapseUnpaidAuctionWins(ctx) {
131
+ let stale;
132
+ try {
133
+ stale = await cartRepository.findExpiredLockedLines(ctx.now);
134
+ }
135
+ catch (err) {
136
+ void normalizeError(err);
137
+ ctx.logger.error("Failed to scan for expired locked cart lines", err);
138
+ return;
139
+ }
140
+ const auctionLines = stale.filter(({ item }) => item.bidId && item.isAuctionWin);
141
+ if (auctionLines.length === 0)
142
+ return;
143
+ ctx.logger.info(`Found ${auctionLines.length} unpaid auction win(s) past deadline`);
144
+ for (const { userId, item } of auctionLines) {
145
+ try {
146
+ await cartRepository.removeItemsByBidId(userId, item.bidId);
147
+ await bidRepository.markForfeited(item.bidId);
148
+ await sendNotification({
149
+ userId,
150
+ type: "auction_ended",
151
+ priority: "high",
152
+ title: "Auction win forfeited",
153
+ message: `You didn't complete payment for "${item.productTitle}" in time, so the win has been forfeited. Repeated non-payment can restrict your account.`,
154
+ relatedId: item.auctionId ?? item.productId,
155
+ relatedType: "product",
156
+ });
157
+ }
158
+ catch (err) {
159
+ void normalizeError(err);
160
+ ctx.logger.warn(`Failed to lapse auction win ${item.bidId}`, {
161
+ error: err instanceof Error ? err.message : String(err),
162
+ });
163
+ }
164
+ }
165
+ }
@@ -10,6 +10,11 @@ export declare const AUCTION_MESSAGES: {
10
10
  LOST_TITLE: string;
11
11
  LOST_MESSAGE: (productTitle: string) => string;
12
12
  NO_BIDS_LOG: (productId: string) => string;
13
+ /** CTA on the winner's notification — links into the auction checkout lane. */
14
+ WON_ACTION_LABEL: string;
15
+ RESERVE_NOT_MET_TITLE: string;
16
+ RESERVE_NOT_MET_MESSAGE: (productTitle: string, currency: string, highestBid: number, reservePrice: number) => string;
17
+ RESERVE_NOT_MET_BIDDER_MESSAGE: (productTitle: string) => string;
13
18
  };
14
19
  export declare const BID_MESSAGES: {
15
20
  OUTBID_TITLE: string;
@@ -10,6 +10,11 @@ export const AUCTION_MESSAGES = {
10
10
  LOST_TITLE: "Auction ended",
11
11
  LOST_MESSAGE: (productTitle) => `The auction for "${productTitle}" has ended. You were outbid.`,
12
12
  NO_BIDS_LOG: (productId) => `Auction ${productId} ended with no bids`,
13
+ /** CTA on the winner's notification — links into the auction checkout lane. */
14
+ WON_ACTION_LABEL: "Pay now",
15
+ RESERVE_NOT_MET_TITLE: "Auction ended below reserve",
16
+ RESERVE_NOT_MET_MESSAGE: (productTitle, currency, highestBid, reservePrice) => `"${productTitle}" ended at ${currency} ${highestBid}, below your reserve of ${currency} ${reservePrice}. No winner was declared and the listing was archived.`,
17
+ RESERVE_NOT_MET_BIDDER_MESSAGE: (productTitle) => `The auction for "${productTitle}" ended without meeting the seller's reserve price, so it did not sell.`,
13
18
  };
14
19
  export const BID_MESSAGES = {
15
20
  OUTBID_TITLE: "You've been outbid",