@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 +4 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +15 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/sales-reports.js +843 -0
- package/lib/save-for-later.js +667 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
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)).
|
package/lib/backorder.js
ADDED
|
@@ -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
|
+
};
|