@blamejs/blamejs-shop 0.0.56 → 0.0.58

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/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.58 (2026-05-22) — **Five new primitives: sales reports, quantity discounts, subscription controls, gift options, support tickets.** Five primitives ship in one release. `salesReports` aggregates orders for operator dashboards with cursor-paginated detail views + opt-in memoization. `quantityDiscounts` adds automatic tier pricing (buy N for X% off) scoped per SKU / product / collection / vendor / category / global, distinct from coupon codes. `subscriptionControls` layers pause / resume / skip-next / change-quantity / change-frequency / cancel / reactivate on top of the existing `subscriptions` surface with a full audit ledger. `giftOptions` adds gift wrapping (operator-configured wrap SKUs with fees) + gift message + recipient-name + gift-receipt toggle, with HTML-escape at every rendered field. `supportTickets` is a broader customer-service surface than `orderNotes` — pre-sale questions, account problems, complaints — with threaded messages, priority FSM, SLA timer, and per-operator assignment. **Added:** *`salesReports` primitive — order aggregations for operator dashboards* — `bShop.salesReports.create({ query?, cursorSecret? })` returns `{ revenueByDay, revenueByWeek, revenueByMonth, topProducts, topCustomers, revenueByCountry, revenueByPaymentMethod, customerCohort, aov, refundRate, funnel, purgeExpired }`. Each surface accepts `{ from, to, currency? }`; top-N surfaces add `{ limit, cursor? }` for HMAC-paginated detail. Opt-in memoization (`cache: true`) writes to a `sales_report_cache` row keyed on `(report_key, params_hash)` with operator-tuned TTL; default is fresh recompute so first-load dashboards never serve stale data. `customerCohort(cohort_month)` returns per-month retention for the cohort that first ordered in `cohort_month`. `funnel({ from, to })` counts checkout-started / payment-intent-created / paid / fulfilled / refunded. Migration `0042_sales_report_cache.sql` (id, report_key, params_hash, result_json, computed_at, expires_at + indexes on (report_key, expires_at), (params_hash)). · *`quantityDiscounts` primitive — automatic tier pricing per SKU / product / collection / vendor / category / global* — `bShop.quantityDiscounts.create({ query?, catalog })` returns `{ defineTier, getTiersForLine, applyToLine, applyToCart, update, archive, unarchive, list, tierBreakdown }`. Discount kinds: `percent_off (bps)`, `amount_off_each (minor)`, `amount_off_total (minor)`, `fixed_each_price (minor)`. `applyToLine` returns `{ original_unit_minor, discounted_unit_minor, line_subtotal_minor, line_discount_minor, applied_tier_id }` after walking applicable rules ordered by scope-specificity (sku > product > collection > vendor > category > global) and picking the best one. `exclusive: true` on a tier set short-circuits stacking. `defineTier` refuses overlapping `min_quantity` values within a single tier set so the schedule is unambiguous. Per-unit math composes `b.money.fromMinorUnits(...).multiply([num, den])` for half-even rounding consistent with the framework Money class. Migration `0044_quantity_discounts.sql` (`qd_tier_sets` + `qd_tiers` tables). · *`subscriptionControls` primitive — pause / resume / skip / change-quantity / change-frequency / cancel / reactivate* — `bShop.subscriptionControls.create({ query?, subscriptions })` returns `{ pause, resume, skipNext, changeQuantity, changeFrequency, cancel, reactivate, historyForSubscription, actorReport, scanAutoResume }`. `pause({ until?, reason })` flips active → paused; an `until` value schedules auto-resume via `scanAutoResume(now)`. `skipNext({ count, reason })` bumps `next_billing_at` by N periods without changing status; `changeFrequency({ new_frequency, reason })` recomputes `next_billing_at` on the new schedule (weekly / biweekly / monthly / quarterly / semiannual / annual). `cancel({ immediate? })` defaults to end-of-period (customer keeps access through the paid period); `immediate: true` ends now. `reactivate` allows cancelled → active within a 90-day grace window; refuses past. Every state change writes a `subscription_control_events` row with `actor_type`, `actor_id`, `before_json`, `after_json`, and a 280-char operator-prose `reason` so customer service can replay the full state arc. Migration `0045_subscription_controls.sql` (new event table + ALTER TABLE additions to `subscriptions` for `paused_at` / `paused_until` / `cancelled_at` / `quantity` / `frequency` / `next_billing_at`). · *`giftOptions` primitive — gift wrapping, gift message, gift receipt* — `bShop.giftOptions.create({ query?, catalog })` returns `{ defineWrap, listWraps, getWrap, updateWrap, archiveWrap, setForOrder, getForOrder, clearForOrder, feeForOrder, renderPackingSlipLine, analytics }`. `wrap_sku` references a real catalog SKU so wrap inventory + cost flow through normal channels. `setForOrder({ order_id, wrap_sku?, gift_message?, recipient_name?, hide_prices? })` validates `gift_message` (≤ 500 chars, control-byte-free, zero-width-char-free) + `recipient_name` (≤ 120 chars) + refuses unknown / archived wrap_sku. `renderPackingSlipLine({ order_id, locale })` returns `{ message_lines, recipient_name, hide_prices }` with every operator-input field HTML-escaped via `b.template.escapeHtml` (script-in-message / img-onerror payloads are rendered inert). `analytics({ from, to })` powers the operator dashboard (gift-option order count, top wrap_skus, gift-message rate). Migration `0046_gift_options.sql` (`gift_wraps` + per-order `gift_options` rows). · *`supportTickets` primitive — threaded customer-service surface with SLA timers* — `bShop.supportTickets.create({ query?, cursorSecret? })` returns `{ open, reply, transition, assign, unassign, addTag, removeTag, get, listForCustomer, listOpenAssignedTo, listUnassigned, thread, slaCheck, metrics }`. Distinct from `orderNotes` — a ticket isn't necessarily tied to an order. Categories: `pre_sale / order_issue / shipping / billing / refund / account / complaint / feature_request / other`. Priority: `low / normal / high / urgent`. Status FSM: `new → in_progress → waiting_customer → in_progress → resolved → closed`, with `reopened` re-entering `in_progress` and `closed` reachable from any state. Operator reply auto-flips `new → in_progress`. Email always hashed via `namespaceHash('support-ticket-email', ...)` before write. `slaCheck()` returns tickets breaching SLA (urgent 1h / high 4h / normal 24h / low 72h since last operator action). `metrics({ from, to })` covers per-category counts, average first-response time, resolution rate, escalation rate. Migration `0047_support_tickets.sql` (`support_tickets` + `support_messages` + `support_status_history` tables).
12
+
13
+ - v0.0.57 (2026-05-22) — **Ten new primitives: bundles, variants, multi-warehouse inventory, backorder, payment methods, order notes, fraud screening, order export, print-on-demand, save-for-later.** Ten primitives ship in one release. `bundles` registers kit-products that expand at cart resolution time. `variants` is the option-axis layer that lets a single product carry the cartesian product of color × size × style (etc.) with per-variant overrides. `inventoryLocations` splits stock across N warehouses + retail stores + drop-ship suppliers with a routing strategy. `backorder` lets out-of-stock SKUs still be ordered with an expected-ship date carried to the PDP. `paymentMethods` stores per-customer processor tokens (Stripe payment-method ids, PayPal billing-agreement ids, etc.) with strict refuses on PAN / CVV-shaped fields. `orderNotes` adds a threaded customer-service surface attached to each order with `internal` and `customer_visible` visibility. `fraudScreen` returns a `{score, decision, signals}` triage at checkout from a 13-signal heuristic. `orderExport` streams CSV / NDJSON / scheduled-export rows with CSV-injection refusal at every cell. `printOnDemand` is the supplier-agnostic POD passthrough (Printful / Printify / Gelato / Lulu / Gooten / Cloudprinter + `custom`). `saveForLater` parks a cart line on the customer's account without losing the snapshot price. **Added:** *`bundles` primitive — kit / multi-pack products that expand at cart resolution* — `bShop.bundles.create({ query?, catalog })` returns `{ defineBundle, getBundle, listBundles, expand, priceBundle, updateBundle, deleteBundle }`. Bundle SKUs reference real catalog SKUs (or nested bundle SKUs up to 2 levels). `expand({ bundle_sku, quantity })` returns the flattened component list with multipliers applied. `priceBundle` sums child priceMinor and applies an optional `bundle_discount_bps` override. Cycle detection refuses bundles that eventually point back to themselves. `deleteBundle` refuses while any active cart line references the bundle. Migration `0032_bundles.sql` (`bundles` + `bundle_components` tables with UNIQUE(bundle_sku, sku) + FK CASCADE). · *`variants` primitive — product option matrix with per-variant overrides* — `bShop.variants.create({ query?, catalog })` returns `{ defineAxis, generateMatrix, materializeMatrix, getVariant, variantsForProduct, findVariant, updateVariant, archiveVariant, unarchiveVariant, archiveAxisOption }`. Operators register axes (`color`, `size`, etc.) with positions and option labels. `generateMatrix` previews the cartesian product without writing — the operator validates before `materializeMatrix` commits. SKU shape: `<prefix>-<axis1-opt>-<axis2-opt>-...` lowercase ASCII slug. `findVariant({ product_id, axis_values: { color: 'red', size: 'L' } })` resolves a specific cell. `image_url` validates via `b.safeUrl` so `javascript:` / `data:` schemes are refused at write. Migration `0033_product_variants.sql` (`product_variant_axes` + `product_variant_axis_options` + `product_variants` tables). · *`inventoryLocations` primitive — multi-warehouse stock + order routing* — `bShop.inventoryLocations.create({ query?, catalog })` returns `{ defineLocation, listLocations, getLocation, updateLocation, deactivateLocation, setStock, adjustStock, transferStock, stockForSku, totalForSku, availableLocations, routeOrder }`. Each location has a type (`warehouse` / `retail` / `dropship`) + priority + active flag. `transferStock` is an atomic two-row write so no stock is created or lost. `routeOrder` strategies: `priority-fill` (default — walks active locations in priority order), `single-location-cheapest`, `nearest-by-postal-prefix`. Returns `{ allocation: [{ location_code, lines }], unfulfillable }`. `inventory_adjustments` ledger is append-only — every `adjustStock` writes a reason-tagged row for audit replay. Migration `0034_inventory_locations.sql`. · *`backorder` primitive — orderable when out-of-stock with expected-ship dates* — `bShop.backorder.create({ query?, catalog })` returns `{ markBackorderable, markNotBackorderable, getStatus, listBackorderable, recordBackorder, fulfillBackorder, cancelBackorder, availabilityFor, customerBackorders, pendingForSku, arrivalsThisWeek }`. `availabilityFor(sku)` returns `{ status: 'in_stock' | 'backorderable' | 'out_of_stock', expected_ship_date?, message? }` for the PDP. `recordBackorder` enforces the per-sku `max_backorder_quantity` cap (NULL = unlimited). `arrivalsThisWeek` returns backorders with `expected_ship_date <= now + 7d` for operator-facing dashboards. Migration `0035_backorder.sql` (`backorder_skus` config table + `backorder_lines` per-order rows with status FSM (pending / fulfilled / cancelled)). · *`paymentMethods` primitive — saved processor tokens with PAN / CVV refusal* — `bShop.paymentMethods.create({ query? })` returns `{ add, get, listForCustomer, setDefault, archive, markExpired, defaultForCustomer, byProcessorToken, audit }`. Add refuses any field that looks like a raw PAN (regex: 13-19 digits in a row, including hyphen / space-collapsed forms), refuses CVV-shaped fields, validates processor enum (stripe / paypal / square / braintree / authorize_net), validates last4 (exactly 4 digits), validates expiry not in the past. `setDefault` enforces write-side default-uniqueness — promoting clears sibling default in the same transaction. `markExpired` is operator-scheduler-callable and writes an audit row per archived method. `audit(payment_method_id)` returns the row's full state-change history. Migration `0036_payment_methods.sql` (`payment_methods` + `payment_method_audit` tables with partial UNIQUE index on `(customer_id) WHERE is_default = 1 AND archived_at IS NULL`). · *`orderNotes` primitive — threaded customer-service notes per order* — `bShop.orderNotes.create({ query?, cursorSecret? })` returns `{ add, get, listForOrder, thread, markRead, pin, unpin, customerVisibleForOrder, internalForOrder, resolve, reopen }`. Visibility enum: `internal` (operator-only) / `customer_visible` (rendered on the customer's order page + emailed). Body cap 8000 chars, refuses control bytes + zero-width chars + empty-after-trim. `thread` returns a tree-shaped reply graph. `pin` floats a note to the top of the listing; resolve / reopen runs an internal-thread workflow with a 280-char resolution summary. Migration `0037_order_notes.sql` (`order_notes` + `order_note_reads` tables with indexes on (order_id, visibility, created_at desc) and (order_id, pinned desc, created_at desc)). · *`fraudScreen` primitive — heuristic risk score + decision at checkout* — `bShop.fraudScreen.create({ query?, customers?, addresses?, paymentMethods?, emailSuppressions? })` returns `{ screen, recordOutcome, recordChargeback, flagEmail, unflagEmail, customerRiskHistory, recentScreenings, hashEmail }`. `screen({ order_draft })` returns `{ score: 0..100, decision: 'approve' | 'review' | 'step_up' | 'refuse', signals }`. Decision thresholds: 0-39 approve, 40-69 review, 70-89 step_up, 90+ refuse. 13 signals fire independently and stack with operator-tunable weights: velocity (>N orders/24h from same email hash), high-value-new-customer, address-mismatch, free-email-domain, disposable-email-domain, session-too-fast (< 15s), session-too-old (> 24h), ua-curl-class, large-line-count, mismatched-bin-country (operator-supplied BIN → country map), prior-chargeback (looks up chargebacks ledger by email hash), suppressed-email, manually-flagged. Emails always hashed via `namespaceHash('fraud-email', ...)` before write. Migration `0038_fraud_screenings.sql` (`fraud_screenings` + `fraud_chargebacks` + `fraud_email_flags` tables). · *`orderExport` primitive — CSV / NDJSON streaming export with CSV-injection refusal* — `bShop.orderExport.create({ query?, order, cursorSecret? })` returns `{ csvForRange, ndjsonForRange, summaryForRange, scheduleExport, cancelExport, getExport, listExports, markExportRunning, markExportComplete, markExportFailed }`. Built-in 24-column schema covering order id / status / customer hash / addresses / line counts / monetary totals / payment method / fulfillment status / refund / return / chargeback flags. RFC-4180 quoting with CRLF; cell content starting with `=`, `+`, `-`, `@` gets prefixed with `'` per OWASP guidance (signed numerics like `+15.00` are exempt). Email is hashed via `namespaceHash('order-export-email', ...)` — raw email never reaches the export. `scheduleExport` writes a queued row + the operator's worker walks the `pod_fulfillments`-style FSM (queued → running → complete | failed | cancelled). Migration `0039_scheduled_exports.sql`. · *`printOnDemand` primitive — supplier-agnostic POD passthrough* — `bShop.printOnDemand.create({ query?, catalog })` returns `{ bindSku, unbindSku, getBinding, listBindings, updateBinding, costForOrder, forwardOrder, markFulfillmentSubmitted, markFulfillmentShipped, markFulfillmentFailed, markFulfillmentCancelled, getFulfillment, fulfillmentsForOrder, pendingFulfillments }`. Supplier enum: `printful` / `printify` / `gelato` / `lulu` / `gooten` / `cloudprinter` / `custom`. The primitive stores the SKU → supplier-product binding (artwork URL + variant id + position config + colorway + operator's wholesale `cost_minor`) and the per-order pending-fulfillment row. The actual HTTP push to each supplier is the operator's worker (each has its own auth + endpoint). `costForOrder(order_lines)` returns `{ total_cost_minor, by_supplier, unbound_skus }` for margin reporting. Migration `0040_print_on_demand.sql` (`pod_bindings` + `pod_fulfillments` tables with FSM-driven status column). · *`saveForLater` primitive — park a cart line on the customer account* — `bShop.saveForLater.create({ query?, catalog, currency?, cursorSecret? })` returns `{ moveFromCart, moveToCart, add, remove, clear, listForCustomer, countForCustomer, staleCheck, repriceAll, expireOlderThan }`. Distinct from `wishlist` — `wishlist` is "I'm interested", `saveForLater` is "I was about to buy this but not now". Lines carry their original cart context (price-at-save, variant, quantity, notes) so the customer can re-add with the snapshot price or the current price (`moveToCart` `use_price` parameter). `staleCheck` flags `is_stale` (price changed), `is_unavailable` (SKU gone), `is_low_stock` (catalog quantity < quantity-saved) per row. Refuses re-add to cart when the SKU is now out-of-stock and not backorderable. `expireOlderThan(days)` is the operator-scheduler sweep. Migration `0041_save_for_later.sql` with UNIQUE(customer_id, sku, COALESCE(variant_id, '')). **Fixed:** *`orderNotes` — monotonic `created_at` so back-to-back inserts always sort newest-first* — `Date.now()` on Windows resolves at ~15ms — three notes inserted in the same millisecond shared a `created_at` value, and the secondary `id DESC` tiebreaker fell back to UUID v7's random suffix (unstable since the time prefix collapsed). The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value, so the listing's `pinned DESC, created_at DESC, id DESC` ordering reliably places the most recent note first regardless of insert burstiness.
14
+
11
15
  - v0.0.56 (2026-05-22) — **Republish of the eleven-primitive batch to npm.** 0.0.55 source landed on `main` but the npm publish workflow ran against the prior commit and didn't reach the registry. 0.0.56 republishes the same eleven primitives (`orderTracking`, `returns`, `loyalty`, `referrals`, `notifications`, `addresses`, `taxExempt`, `emailSuppressions`, `currencyDisplay`, `searchSuggestions`, `cartAbandonment`) plus migrations `0021`-`0031` so `npm install @blamejs/blamejs-shop@0.0.56` pulls the full surface. No code changes from 0.0.55 — only the version bump + this changelog entry. **Fixed:** *Republish to npm — 0.0.55 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.55` would resolve to a missing version. 0.0.56 ships the identical surface so operators upgrading by version number get the full eleven-primitive batch + the eleven new migrations. Tag `v0.0.55` remains on the repo; operators referring to either version see equivalent code.
12
16
 
13
17
  - v0.0.55 (2026-05-22) — **Eleven new primitives: order tracking, returns, loyalty, referrals, notifications, addresses, tax exemptions, email suppressions, multi-currency display, search suggestions, cart abandonment.** Eleven primitives land in one release, each composed on the vendored blamejs surface (`b.crypto.namespaceHash`, `b.guardEmail`, `b.guardUuid`, `b.uuid.v7`, `b.pagination`, `b.safeSql`, `b.money`). Order tracking + shipments now drive carrier deep-links (UPS / FedEx / USPS / DHL / Royal Mail / Canada Post / Australia Post). Returns ship a full RMA FSM (pending → approved → received → refunded). Loyalty awards points + tier bands. Referrals issue two-sided rewards. Notifications queue with retry + preferences. Saved addresses give customers a per-account book with default-shipping/billing uniqueness. Tax exemption certificates carry an approve/reject/revoke pipeline + a region-aware `isExempt` check. Email suppressions gate outbound mail per scope (transactional / marketing / all). Multi-currency display caches FX rates as integer basis-points with a staleness flag. Search suggestions blend operator-featured rows with popular queries. Cart abandonment scans idle carts and emits per-detection rows for downstream reminder fan-out. **Added:** *`orderTracking` primitive — shipments + per-event log + carrier deep-links* — `bShop.orderTracking.create({ query?, cursorSecret? })` returns `{ createShipment, addEvent, getShipment, listForOrder, byTrackingNumber, carrierTrackingUrl, statusFromCarrier, latestStatus }`. Shipment status flows `label_created → in_transit → out_for_delivery → delivered`, with `exception` + `returned` terminal branches. `carrierTrackingUrl(carrier, tracking_number)` returns the carrier deep-link from a built-in URL template table for UPS / FedEx / USPS / DHL / Royal Mail / Canada Post / Australia Post; unknown carriers return `null` rather than guessing. `addEvent` appends a `shipment_events` row with the carrier-reported status string + raw timestamp + free-form description. Migration `0021_shipments.sql` (shipments + shipment_events tables, indexes on (order_id, created_at), (tracking_number), (status, created_at)). · *`returns` primitive — RMA workflow with per-line reason codes* — `bShop.returns.create({ query?, cursorSecret? })` returns `{ open, approve, reject, markReceived, refund, get, listForCustomer, listForOrder, byStatus }`. The RMA FSM is `pending → approved → received → refunded` with a `rejected` terminal branch from `pending`. Each line carries a reason code from the operator-config'd enum (default: `defective`, `wrong_item`, `wrong_size`, `changed_mind`, `damaged_in_transit`, `not_as_described`, `arrived_late`, `other`) + an optional 280-char note. `refund` writes the refund amount in minor units + the operator's payment-processor refund id. Migration `0023_returns.sql` (returns + return_lines tables, FK CASCADE, indexes on (customer_id, created_at desc), (order_id), (status, created_at desc)). · *`loyalty` primitive — tier bands + point ledger with earn / redeem / expire* — `bShop.loyalty.create({ query?, cursorSecret? })` returns `{ award, redeem, expire, balance, tierForCustomer, history, customersInTier, recomputeTier }`. Default tiers are `bronze (0)`, `silver (250)`, `gold (1000)`, `platinum (5000)` with operator-overridable thresholds. Point ledger is append-only (`earn` / `redeem` / `expire` / `adjust` row types) so totals are always derivable + audit-replayable. `expire` walks rows whose `expires_at <= now` and emits an offsetting `expire` row equal to the unconsumed remainder. Migration `0022_loyalty.sql` (loyalty_ledger + customer_loyalty_summary cached-aggregate tables, indexes on (customer_id, occurred_at desc), (expires_at) where expires_at IS NOT NULL). · *`referrals` primitive — two-sided reward with single-use codes* — `bShop.referrals.create({ query?, cursorSecret? })` returns `{ issueCode, redeem, getCode, listForReferrer, statusForCode, expireScan, statsForReferrer }`. `issueCode` mints a per-referrer code (8-char crockford-base32) and stores it lowercase-normalised; one referrer can hold N codes. `redeem(code, referee_customer_id)` is single-use, refuses self-referral, refuses re-redemption, and emits both the referrer reward row + the referee reward row in one transaction. Reward shape is operator-config'd (`{ kind: 'percent_off' | 'amount_off' | 'points', value }`). Migration `0025_referrals.sql` (referral_codes + referral_redemptions tables, UNIQUE(referee_customer_id) so a customer can only be referred once, indexes on (referrer_customer_id), (status, created_at desc)). · *`notifications` primitive — queue with retry + per-customer channel preferences* — `bShop.notifications.create({ query?, cursorSecret? })` returns `{ enqueue, dispatch, markDelivered, markFailed, setPreference, getPreferences, listForCustomer, listPending }`. Channels: `email`, `sms`, `webhook`. A `notifications_preferences` row stores per-(customer_id, channel, category) opt-in state — `enqueue` consults the matrix and refuses with `{ skipped: true, reason: 'opted-out' }` when the matching row is opt-out. `dispatch` reads `next_retry_at <= now` rows in `pending` and walks the operator-supplied dispatcher callback. Failure schedules an exponential-backoff retry on `[60s, 5m, 30m, 4h]` before terminal `failed`. Migration `0024_notifications.sql` (notifications + notifications_preferences tables, indexes on (status, next_retry_at), (customer_id, created_at desc), (status, created_at desc)). · *`addresses` primitive — per-customer address book with default flags* — `bShop.addresses.create({ query? })` returns `{ add, update, get, listForCustomer, defaultShipping, defaultBilling, archive, unarchive, setDefaultShipping, setDefaultBilling, matchByContent }`. Default-flag uniqueness is enforced write-side — promoting an address to default clears the flag on its sibling rows in the same transaction. Archive drops both default flags so a stale archived row can't accidentally remain default. `matchByContent` collapses casing + whitespace on the address fields so dedup catches near-duplicates at submit time. Migration `0026_customer_addresses.sql` (id, customer_id, full_name, line1/line2, city, region, postal_code, country (ISO-3166-1 alpha-2), phone, is_default_shipping, is_default_billing, archived_at, created_at, updated_at). · *`taxExempt` primitive — exemption certificates with approve / reject / revoke pipeline* — `bShop.taxExempt.create({ query? })` returns `{ submit, approve, reject, revoke, expireScan, get, activeForCustomer, listPendingReview, isExempt }`. Cert numbers persist plaintext for operator display + dedup-hashed via `b.crypto.namespaceHash('tax-cert-number', uppercase(trim(...)))`. Submit is idempotent on `(customer_id, jurisdiction, hash)` — re-submitting an approved cert returns the existing row's status. `isExempt(customer_id, jurisdiction)` honours region-aware ancestry — an `US` certificate covers `US-CA` subdivision lookups. Read-path filters `expires_at <= now` so a slow `expireScan` scheduler can't honor a stale row at checkout. Migration `0027_tax_exempt_certs.sql` (certs + the FSM status column, indexes on (customer_id, status, expires_at), (status, submitted_at), (jurisdiction, status)). · *`emailSuppressions` primitive — bounce / complaint / unsubscribe / opt-out gate* — `bShop.emailSuppressions.create({ query?, cursorSecret? })` returns `{ add, isSuppressed, remove, byHash, list, cleanupExpired, stats }`. Suppression types: `bounce` (hard / soft), `complaint`, `unsubscribe`, `operator_manual`, `rate_limit_block`. Default scope is operator-friendly per type — bounce/complaint suppress `transactional`, unsubscribe suppresses `marketing`, operator-manual + rate-limit suppress `all`. `isSuppressed(email, scope)` honours scope hierarchy — an `all` row blocks every requested scope. Email never persists raw; only the lowercased + trimmed form + the `namespaceHash('email-suppression', ...)` digest. Migration `0028_email_suppressions.sql` (id, email_hash UNIQUE, email_normalised, type, scope, reason, occurrences, expires_at, created_at, updated_at). · *`currencyDisplay` primitive — FX rate cache + safe conversion + locale-aware format* — `bShop.currencyDisplay.create({ query? })` returns `{ setRate, getRate, convert, format, convertAndFormat, cleanupExpired, bulkSetRates }`. Rates encode as integer basis-points (`rate * 10000`) so storage stays integer-clean. Conversion composes `b.money.convert` — rounding is half-to-even via the framework's BigInt path, so zero-decimal targets (JPY, KRW) work without per-currency casing. Stale rows surface `{ stale: true, converted_minor: null }` instead of returning an out-of-date number — the storefront falls back to native currency. `bulkSetRates` validates every row before writing any (pre-flight, since D1 has no client transactions). Migration `0029_fx_rates.sql` (id, base_currency + quote_currency UNIQUE pair, rate_bps, source, fetched_at, expires_at). · *`searchSuggestions` primitive — operator-featured rows + popular-query log + product matches* — `bShop.searchSuggestions.create({ query?, catalog })` returns `{ recordQuery, suggest, addFeatured, updateFeatured, deleteFeatured, popularQueries, cleanupOldQueries }`. `suggest(prefix)` returns a three-array shape (`featured`, `popular`, `products`) so the storefront renders sectioned dropdowns without re-querying. Featured rows carry an FSM (`active` / `paused` / `expired`) + a `(starts_at, expires_at)` window so an operator can time a promotion. `link_url` on featured rows refuses `javascript:` / `data:` / `vbscript:` schemes. Session ids on `recordQuery` are hashed via `namespaceHash('search-suggestions-session', ...)` before write — the popular-query log never holds a recoverable identifier. Migrations `0030_search_suggestions.sql` (featured_search_suggestions + search_query_log tables, indexes on (active + window), (query_normalised, recorded_at desc), (priority desc)). · *`cartAbandonment` primitive — scheduled scanner over idle carts + reminder fan-out feed* — `bShop.cartAbandonment.create({ query?, cursorSecret? })` returns `{ scan, markReminderSent, markReminderSkipped, markReminderFailed, recentDetections, statsForRun, cleanupOld }`. Each `scan` writes a `cart_abandonment_runs` row + N `cart_abandonment_detections` rows for carts whose last update falls in the idle window. Idempotent — a cart already detected within the current idle window skips via a `last detection > idleCutoff` guard + a `UNIQUE(cart_id, detected_at)` collision catch. Session ids are namespace-hashed (`cart-abandonment-session`) before write. Reminder skip reasons route to `skipped-suppressed` (consult `emailSuppressions`) vs `skipped-no-email` (anonymous cart). Migration `0031_cart_abandonment_runs.sql` (detections + runs tables, indexes on (status, detected_at desc), (cart_id), (run_id)).
@@ -0,0 +1,452 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.backorder
4
+ * @title Backorder — out-of-stock SKUs that can still be ordered
5
+ *
6
+ * @intro
7
+ * The catalog's inventory bucket says "we have N units on the
8
+ * shelf". A backorderable SKU says "we don't have it on the shelf,
9
+ * but the operator commits to ship by <date>" — and the storefront
10
+ * PDP renders "Ships by <date>" instead of "Out of stock" so the
11
+ * customer can still complete the purchase.
12
+ *
13
+ * Lifecycle:
14
+ *
15
+ * markBackorderable({ sku, max_backorder_quantity?,
16
+ * expected_ship_date, message? })
17
+ * → operator opts a SKU in. Upserts the per-SKU config row.
18
+ * `max_backorder_quantity: null` (or omitted) means
19
+ * unlimited; a positive integer caps the in-flight pending
20
+ * quantity so the operator doesn't commit beyond their
21
+ * supplier's capacity. `pending_quantity` is preserved across
22
+ * re-marks so re-marking the same SKU doesn't reset the
23
+ * counter.
24
+ *
25
+ * markNotBackorderable(sku)
26
+ * → flips `active` to 0. The row is preserved so historical
27
+ * lines still resolve their config; future `availabilityFor`
28
+ * calls return `out_of_stock` once stock_on_hand is also 0.
29
+ *
30
+ * recordBackorder({ order_id, sku, quantity, customer_id })
31
+ * → writes one `backorder_lines` row per backordered line at
32
+ * checkout time. Refuses if the SKU isn't currently
33
+ * backorderable, or if the requested quantity would push
34
+ * `pending_quantity` past the configured cap. Increments the
35
+ * per-SKU counter as part of the same logical operation.
36
+ * Idempotent on `(order_id, sku)` via the UNIQUE constraint —
37
+ * the same line replayed returns `{ status: "dedup" }` and
38
+ * does not double-increment the counter.
39
+ *
40
+ * fulfillBackorder({ order_id, sku }) /
41
+ * cancelBackorder({ order_id, sku, reason })
42
+ * → flip the row to `fulfilled` / `cancelled` and decrement
43
+ * the counter. Refuse on missing row or non-pending status
44
+ * so the counter can never under-flow.
45
+ *
46
+ * availabilityFor(sku) — pure read used by the PDP
47
+ * → returns one of:
48
+ * { status: "in_stock" }
49
+ * { status: "backorderable",
50
+ * expected_ship_date, message }
51
+ * { status: "out_of_stock" }
52
+ * Reads catalog inventory + the per-SKU backorder config;
53
+ * `in_stock` wins whenever stock_on_hand > 0 (the
54
+ * backorderable flag is irrelevant — fulfill from the shelf
55
+ * first).
56
+ *
57
+ * customerBackorders(customer_id) /
58
+ * pendingForSku(sku) /
59
+ * arrivalsThisWeek()
60
+ * → operator + customer dashboard reads.
61
+ *
62
+ * Composition:
63
+ * - b.guardUuid — every order_id / customer_id is UUID-shape
64
+ * validated at the entry point; SKU shape is validated via a
65
+ * local regex matching the rest of the shop primitives.
66
+ * - b.uuid.v7 — backorder_lines.id (sortable; customer dashboard
67
+ * reads sort by id desc to get newest-first without a second
68
+ * index).
69
+ * - catalog.inventory.get(sku) — the single source of truth for
70
+ * on-shelf stock. `availabilityFor` reads it but never mutates
71
+ * it; the catalog stays the owner of stock_on_hand.
72
+ *
73
+ * The factory accepts an optional `query` (defaults to
74
+ * b.externalDb.query) and a required `catalog` handle so the
75
+ * primitive stays decoupled from any specific catalog binding —
76
+ * tests inject an in-memory-SQLite-backed catalog; production wires
77
+ * the real catalog created at boot.
78
+ */
79
+
80
+ var bShop;
81
+ function _b() {
82
+ if (!bShop) bShop = require("./index");
83
+ return bShop.framework;
84
+ }
85
+
86
+ // ---- constants ----------------------------------------------------------
87
+
88
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
89
+ var MAX_MESSAGE_LEN = 280;
90
+ var MAX_REASON_LEN = 280;
91
+ var WEEK_MS = 7 * 24 * 60 * 60 * 1000;
92
+
93
+ var BACKORDER_STATUSES = Object.freeze(["pending", "fulfilled", "cancelled"]);
94
+
95
+ // ---- validators ---------------------------------------------------------
96
+
97
+ function _uuid(s, label) {
98
+ try {
99
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
100
+ } catch (e) {
101
+ throw new TypeError("backorder: " + label + " — " + (e && e.message || "invalid UUID"));
102
+ }
103
+ }
104
+
105
+ function _sku(s) {
106
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
107
+ throw new TypeError("backorder: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
108
+ }
109
+ }
110
+
111
+ function _shortText(s, label, max) {
112
+ if (s == null) return "";
113
+ if (typeof s !== "string" || s.length > max) {
114
+ throw new TypeError("backorder: " + label + " must be a string ≤ " + max + " chars");
115
+ }
116
+ return s;
117
+ }
118
+
119
+ function _positiveInt(n, label) {
120
+ if (!Number.isInteger(n) || n <= 0) {
121
+ throw new TypeError("backorder: " + label + " must be a positive integer");
122
+ }
123
+ }
124
+
125
+ function _nonNegIntOrNull(n, label) {
126
+ if (n === null || n === undefined) return null;
127
+ if (!Number.isInteger(n) || n < 0) {
128
+ throw new TypeError("backorder: " + label + " must be a non-negative integer or null");
129
+ }
130
+ return n;
131
+ }
132
+
133
+ function _epochMs(n, label) {
134
+ if (!Number.isInteger(n) || n < 0) {
135
+ throw new TypeError("backorder: " + label + " must be a non-negative integer (epoch ms)");
136
+ }
137
+ }
138
+
139
+ function _now() { return Date.now(); }
140
+
141
+ // ---- factory ------------------------------------------------------------
142
+
143
+ function create(opts) {
144
+ opts = opts || {};
145
+ if (!opts.catalog || !opts.catalog.inventory || typeof opts.catalog.inventory.get !== "function") {
146
+ throw new TypeError("backorder.create: opts.catalog with inventory.get(sku) required");
147
+ }
148
+ var catalog = opts.catalog;
149
+ var query = opts.query;
150
+ if (!query) {
151
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
152
+ }
153
+
154
+ // Read the per-SKU config row. Returns null on miss so callers can
155
+ // map cleanly to the "not configured" branch without an exception
156
+ // round-trip.
157
+ async function _getConfig(sku) {
158
+ var r = await query("SELECT * FROM backorder_skus WHERE sku = ?1", [sku]);
159
+ return r.rows[0] || null;
160
+ }
161
+
162
+ return {
163
+ // Operator opts a SKU in. Upserts the config row so re-marking
164
+ // updates expected_ship_date / message / cap without resetting
165
+ // the counter. `max_backorder_quantity: null` (or omitted) means
166
+ // unlimited — operators that want a strict cap pass a positive
167
+ // integer; passing 0 means "no new backorders accepted" (the
168
+ // existing pending rows remain).
169
+ markBackorderable: async function (input) {
170
+ if (!input || typeof input !== "object") {
171
+ throw new TypeError("backorder.markBackorderable: input object required");
172
+ }
173
+ _sku(input.sku);
174
+ var max = _nonNegIntOrNull(input.max_backorder_quantity, "max_backorder_quantity");
175
+ _epochMs(input.expected_ship_date, "expected_ship_date");
176
+ var message = _shortText(input.message, "message", MAX_MESSAGE_LEN);
177
+ var ts = _now();
178
+ var existing = await _getConfig(input.sku);
179
+ if (!existing) {
180
+ await query(
181
+ "INSERT INTO backorder_skus (sku, max_quantity, expected_ship_date, message, " +
182
+ "pending_quantity, active, created_at, updated_at) " +
183
+ "VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5)",
184
+ [input.sku, max, input.expected_ship_date, message, ts],
185
+ );
186
+ } else {
187
+ await query(
188
+ "UPDATE backorder_skus SET max_quantity = ?1, expected_ship_date = ?2, " +
189
+ "message = ?3, active = 1, updated_at = ?4 WHERE sku = ?5",
190
+ [max, input.expected_ship_date, message, ts, input.sku],
191
+ );
192
+ }
193
+ return await _getConfig(input.sku);
194
+ },
195
+
196
+ // Flip a SKU's backorder config off. The row is preserved so the
197
+ // pending_quantity counter + historical line resolution still
198
+ // work; future availabilityFor calls fall through to in_stock /
199
+ // out_of_stock without the backorderable branch.
200
+ markNotBackorderable: async function (sku) {
201
+ _sku(sku);
202
+ var ts = _now();
203
+ var r = await query(
204
+ "UPDATE backorder_skus SET active = 0, updated_at = ?1 WHERE sku = ?2",
205
+ [ts, sku],
206
+ );
207
+ if (r.rowCount === 0) return null;
208
+ return await _getConfig(sku);
209
+ },
210
+
211
+ getStatus: async function (sku) {
212
+ _sku(sku);
213
+ return await _getConfig(sku);
214
+ },
215
+
216
+ listBackorderable: async function (listOpts) {
217
+ listOpts = listOpts || {};
218
+ var activeOnly = listOpts.active_only === true;
219
+ var sql, params;
220
+ if (activeOnly) {
221
+ sql = "SELECT * FROM backorder_skus WHERE active = 1 ORDER BY expected_ship_date ASC, sku ASC";
222
+ params = [];
223
+ } else {
224
+ sql = "SELECT * FROM backorder_skus ORDER BY expected_ship_date ASC, sku ASC";
225
+ params = [];
226
+ }
227
+ var r = await query(sql, params);
228
+ return r.rows;
229
+ },
230
+
231
+ // Record one backorder line at checkout time. Refuses if:
232
+ // - the SKU isn't currently backorderable (no config / active=0)
233
+ // - the quantity would push pending_quantity past max_quantity
234
+ // Idempotent on (order_id, sku) via the UNIQUE constraint — the
235
+ // same line replayed returns { status: 'dedup' } and does not
236
+ // double-increment the counter.
237
+ recordBackorder: async function (input) {
238
+ if (!input || typeof input !== "object") {
239
+ throw new TypeError("backorder.recordBackorder: input object required");
240
+ }
241
+ _uuid(input.order_id, "order_id");
242
+ _uuid(input.customer_id, "customer_id");
243
+ _sku(input.sku);
244
+ _positiveInt(input.quantity, "quantity");
245
+
246
+ // Idempotency — same (order_id, sku) returns dedup without
247
+ // mutating the counter. The UNIQUE constraint backstops a race
248
+ // window where two callers raced past the SELECT below. Dedup
249
+ // is checked BEFORE the cap so a replay of an already-recorded
250
+ // line doesn't trip the cap refusal when the existing pending
251
+ // total is at the cap.
252
+ var dup = await query(
253
+ "SELECT id, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
254
+ [input.order_id, input.sku],
255
+ );
256
+ if (dup.rows.length) {
257
+ return { id: dup.rows[0].id, status: "dedup", line_status: dup.rows[0].status };
258
+ }
259
+
260
+ var config = await _getConfig(input.sku);
261
+ if (!config || config.active !== 1) {
262
+ throw new TypeError("backorder.recordBackorder: sku " + JSON.stringify(input.sku) + " is not currently backorderable");
263
+ }
264
+ // Cap enforcement. NULL max_quantity = unlimited; a configured
265
+ // cap refuses any line that would push the in-flight pending
266
+ // total past it.
267
+ if (config.max_quantity !== null && config.max_quantity !== undefined) {
268
+ if (config.pending_quantity + input.quantity > config.max_quantity) {
269
+ throw new TypeError("backorder.recordBackorder: would exceed max_backorder_quantity " +
270
+ "(cap=" + config.max_quantity + ", pending=" + config.pending_quantity +
271
+ ", requested=" + input.quantity + ")");
272
+ }
273
+ }
274
+
275
+ var id = _b().uuid.v7();
276
+ var ts = _now();
277
+ try {
278
+ await query(
279
+ "INSERT INTO backorder_lines (id, order_id, customer_id, sku, quantity, status, " +
280
+ "created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)",
281
+ [id, input.order_id, input.customer_id, input.sku, input.quantity, ts],
282
+ );
283
+ } catch (e) {
284
+ // Race: another caller landed the same (order_id, sku) tuple
285
+ // between the dup SELECT above and this INSERT. Re-read the
286
+ // existing row and return the dedup shape instead of
287
+ // bubbling a raw SQLITE_CONSTRAINT.
288
+ var redup = await query(
289
+ "SELECT id, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
290
+ [input.order_id, input.sku],
291
+ );
292
+ if (redup.rows.length) {
293
+ return { id: redup.rows[0].id, status: "dedup", line_status: redup.rows[0].status };
294
+ }
295
+ throw e;
296
+ }
297
+ await query(
298
+ "UPDATE backorder_skus SET pending_quantity = pending_quantity + ?1, updated_at = ?2 " +
299
+ "WHERE sku = ?3",
300
+ [input.quantity, ts, input.sku],
301
+ );
302
+ return { id: id, status: "recorded" };
303
+ },
304
+
305
+ // Flip a pending backorder line to fulfilled. Decrements the
306
+ // per-SKU counter. Refuses on missing row or non-pending status
307
+ // so the counter can never under-flow.
308
+ fulfillBackorder: async function (input) {
309
+ if (!input || typeof input !== "object") {
310
+ throw new TypeError("backorder.fulfillBackorder: input object required");
311
+ }
312
+ _uuid(input.order_id, "order_id");
313
+ _sku(input.sku);
314
+ var r = await query(
315
+ "SELECT id, quantity, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
316
+ [input.order_id, input.sku],
317
+ );
318
+ if (!r.rows.length) {
319
+ throw new TypeError("backorder.fulfillBackorder: no backorder line for order=" +
320
+ input.order_id + " sku=" + JSON.stringify(input.sku));
321
+ }
322
+ var line = r.rows[0];
323
+ if (line.status !== "pending") {
324
+ throw new TypeError("backorder.fulfillBackorder: line is " + line.status +
325
+ ", only pending lines can be fulfilled");
326
+ }
327
+ var ts = _now();
328
+ await query(
329
+ "UPDATE backorder_lines SET status = 'fulfilled', fulfilled_at = ?1 WHERE id = ?2",
330
+ [ts, line.id],
331
+ );
332
+ // MAX(0, ...) clamps the counter so an out-of-band counter
333
+ // corruption (operator hand-edited a row) can't drive it
334
+ // negative. The CHECK constraint on pending_quantity would
335
+ // otherwise refuse the UPDATE outright.
336
+ await query(
337
+ "UPDATE backorder_skus SET pending_quantity = MAX(0, pending_quantity - ?1), " +
338
+ "updated_at = ?2 WHERE sku = ?3",
339
+ [line.quantity, ts, input.sku],
340
+ );
341
+ return { id: line.id, status: "fulfilled" };
342
+ },
343
+
344
+ cancelBackorder: async function (input) {
345
+ if (!input || typeof input !== "object") {
346
+ throw new TypeError("backorder.cancelBackorder: input object required");
347
+ }
348
+ _uuid(input.order_id, "order_id");
349
+ _sku(input.sku);
350
+ var reason = _shortText(input.reason, "reason", MAX_REASON_LEN);
351
+ var r = await query(
352
+ "SELECT id, quantity, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
353
+ [input.order_id, input.sku],
354
+ );
355
+ if (!r.rows.length) {
356
+ throw new TypeError("backorder.cancelBackorder: no backorder line for order=" +
357
+ input.order_id + " sku=" + JSON.stringify(input.sku));
358
+ }
359
+ var line = r.rows[0];
360
+ if (line.status !== "pending") {
361
+ throw new TypeError("backorder.cancelBackorder: line is " + line.status +
362
+ ", only pending lines can be cancelled");
363
+ }
364
+ var ts = _now();
365
+ await query(
366
+ "UPDATE backorder_lines SET status = 'cancelled', reason = ?1, cancelled_at = ?2 " +
367
+ "WHERE id = ?3",
368
+ [reason, ts, line.id],
369
+ );
370
+ await query(
371
+ "UPDATE backorder_skus SET pending_quantity = MAX(0, pending_quantity - ?1), " +
372
+ "updated_at = ?2 WHERE sku = ?3",
373
+ [line.quantity, ts, input.sku],
374
+ );
375
+ return { id: line.id, status: "cancelled" };
376
+ },
377
+
378
+ // Pure read used by the PDP. Resolution rules:
379
+ // 1. stock_on_hand > 0 → in_stock (fulfill from shelf
380
+ // first; backorderable flag
381
+ // is irrelevant)
382
+ // 2. config active=1 + stock = 0 → backorderable + ship date +
383
+ // message
384
+ // 3. otherwise → out_of_stock
385
+ // The cap is not enforced here — availabilityFor returns the
386
+ // configured ship date even when the cap is full; the cap refusal
387
+ // surfaces at recordBackorder time so the customer sees the same
388
+ // PDP state until they actually try to commit.
389
+ availabilityFor: async function (sku) {
390
+ _sku(sku);
391
+ var inv = await catalog.inventory.get(sku);
392
+ var onHand = inv && inv.stock_on_hand != null ? inv.stock_on_hand : 0;
393
+ if (onHand > 0) {
394
+ return { status: "in_stock" };
395
+ }
396
+ var config = await _getConfig(sku);
397
+ if (config && config.active === 1) {
398
+ return {
399
+ status: "backorderable",
400
+ expected_ship_date: config.expected_ship_date,
401
+ message: config.message,
402
+ };
403
+ }
404
+ return { status: "out_of_stock" };
405
+ },
406
+
407
+ // Newest first — the customer's account page renders the most
408
+ // recent backorder at the top. The v7-uuid PK sorts
409
+ // lexicographically by creation order so `ORDER BY id DESC` is
410
+ // equivalent to `ORDER BY created_at DESC` without needing the
411
+ // separate timestamp index.
412
+ customerBackorders: async function (customerId) {
413
+ _uuid(customerId, "customer_id");
414
+ var r = await query(
415
+ "SELECT * FROM backorder_lines WHERE customer_id = ?1 ORDER BY id DESC",
416
+ [customerId],
417
+ );
418
+ return r.rows;
419
+ },
420
+
421
+ pendingForSku: async function (sku) {
422
+ _sku(sku);
423
+ var r = await query(
424
+ "SELECT * FROM backorder_lines WHERE sku = ?1 AND status = 'pending' ORDER BY id ASC",
425
+ [sku],
426
+ );
427
+ return r.rows;
428
+ },
429
+
430
+ // Operator dashboard read — pending backorder lines whose
431
+ // configured expected_ship_date falls in the next 7 days
432
+ // (inclusive of now). The join pulls the per-SKU ship date so
433
+ // operators don't have to fan out one read per sku.
434
+ arrivalsThisWeek: async function () {
435
+ var now = _now();
436
+ var cutoff = now + WEEK_MS;
437
+ var r = await query(
438
+ "SELECT l.*, s.expected_ship_date AS expected_ship_date, s.message AS message " +
439
+ "FROM backorder_lines l JOIN backorder_skus s ON s.sku = l.sku " +
440
+ "WHERE l.status = 'pending' AND s.expected_ship_date <= ?1 " +
441
+ "ORDER BY s.expected_ship_date ASC, l.id ASC",
442
+ [cutoff],
443
+ );
444
+ return r.rows;
445
+ },
446
+ };
447
+ }
448
+
449
+ module.exports = {
450
+ create: create,
451
+ BACKORDER_STATUSES: BACKORDER_STATUSES,
452
+ };