@blamejs/blamejs-shop 0.0.59 → 0.0.60

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.60 (2026-05-22) — **Fourteen new primitives: tax rates, storefront pages, webhook subscriptions, tenants, API keys, barcodes, customer portal, subscription billing, translations, coupon stacking, A/B experiments, print receipts, inventory snapshots, product import.** Fourteen primitives land together. `taxRates` is the operator-managed per-jurisdiction rate table with scheduled effective dates. `storefrontPages` is the markdown-driven CMS for legal / about / shipping copy. `webhookSubscriptions` separates subscription management from delivery. `tenants` runs N branded shops from one deployment with per-tenant domain + currency + theme. `apiKeys` issues hashed-at-rest tokens with scope allowlists + per-key rate limits. `barcodes` maps SKUs to UPC-A / EAN-13 / Code-128 / GTIN-14 values with checksum validation + inline-SVG rendering. `customerPortal` mints single-use scoped portal sessions. `subscriptionBilling` is the invoice + payment-attempt + dunning ledger. `translations` is the per-resource multi-locale lookup with locale-fallback chains. `couponStacking` is the rules engine for which discount codes may be combined. `experiments` is the deterministic-assignment A/B test framework with Wilson confidence intervals. `printReceipts` renders ESC/POS thermal + HTML/PDF + plain-text email receipts. `inventorySnapshots` captures point-in-time stock counts with SHA3-512 tamper-evidence + delta reports. `productImport` is the bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalog data. **Added:** *`taxRates` primitive — per-jurisdiction rate table with scheduled effective dates* — `bShop.taxRates.create({ query? })` returns `{ defineRate, rateFor, listForJurisdiction, updateRate, archiveRate, bulkImport, scheduledChanges }`. Jurisdiction shape is `XX` or `XX-YYY` per ISO 3166. `defineRate` refuses overlapping windows within the same (jurisdiction, category). `rateFor({ jurisdiction, category?, on_date })` resolves category-specific first, then NULL-category fallback. `effective_from` inclusive, `effective_until` exclusive. `scheduledChanges({ from, to })` lists rate transitions in the window for the operator scheduler. Migration `0058_tax_rates.sql` (CHECK enum source: manual / vies / state_dept / avalara). · *`storefrontPages` primitive — markdown CMS for About / Shipping / Privacy / Terms* — `bShop.storefrontPages.create({ query? })` returns `{ defineDraft, publish, unpublish, archive, restore, update, get, getPublished, listPublished, listDrafts, listArchived, renderHtml }`. FSM `draft → published → archived` with `restore` lifting archived → draft. Built-in markdown subset: headings, paragraphs, ul/ol, blockquote, hr, inline code, bold, italic, links. Every text run through `b.template.escapeHtml`; every inline link URL through `b.safeUrl.parse` (https-only + /-rooted internal paths). Unsafe URLs drop the `<a>` entirely so anchor text falls back to inert escaped text. Migration `0059_storefront_pages.sql` (layout enum: default / wide / landing / legal). · *`webhookSubscriptions` primitive — subscription management with 24h secret rotation grace* — `bShop.webhookSubscriptions.create({ query?, now? })` returns `{ subscribe, get, listForOwner, subscriptionsForEvent, pauseSubscription, resumeSubscription, update, unsubscribe, rotateSecret, expireRotationGrace }`. Plaintext signing secret returned ONCE on `subscribe` + `rotate`; at rest only the SHA3-512 namespace hash + a previous-hash for a 24h rotation grace window. `subscriptionsForEvent(event_type)` returns active subscriptions matching the event type for delivery fan-out. `endpoint_url` gated via `b.safeUrl` (https-only). Migration `0060_webhook_subscriptions.sql` (owner_type enum: operator / app / customer). · *`tenants` primitive — multi-store deployment with per-tenant domains + currency + theme* — `bShop.tenants.create({ query? })` returns `{ defineTenant, addDomain, removeDomain, setPrimaryDomain, pauseTenant, resumeTenant, archiveTenant, get, getById, resolveByHost, listTenants, update, stats }`. Slug regex `[a-z0-9]([a-z0-9_-]*[a-z0-9])?` ≤ 64. Domain regex `[a-z0-9][a-z0-9.-]*\.[a-z]{2,}` ≤ 255. `resolveByHost(host)` strips port + lowercases + nearest-match against the registered domain set; paused tenants resolve, archived do not. FSM: `active ↔ paused`, both → `archived` (terminal). Migration `0063_tenants.sql` (`tenants` + `tenant_domains` tables with FK CASCADE). · *`apiKeys` primitive — hashed-at-rest tokens with scope + rate limit + 24h rotation grace* — `bShop.apiKeys.create({ query? })` returns `{ issueKey, verifyToken, revoke, rotate, listForOwner, getKey, update, recordUse, usageForKey, cleanupExpired }`. Plaintext is 32 bytes via `b.crypto.generateBytes` rendered as 43-char URL-safe base64; storage holds only the `namespaceHash('api-key-token', plaintext)` digest. `verifyToken` routes hex compare through `b.crypto.timingSafeEqual`. `rotate` slides the old hash into `token_hash_previous` with a 24h grace + issues a new row. Owner type enum: operator / app / affiliate / tenant. Migration `0064_api_keys.sql` (`api_keys` + append-only `api_key_usage` log). · *`barcodes` primitive — SKU → UPC-A / EAN-13 / Code-128 / GTIN-14 with checksum validation + SVG render* — `bShop.barcodes.create({ query?, catalog })` returns `{ assign, lookup, bySkuList, lookupByValue, unassign, assignAuto, defineRange, listRanges, renderSvg, validateValue }`. Checksum gate per kind (GS1 mod-10 reused for UPC-A / EAN-13 / GTIN-14; Code-128 mod-103 at render). `assignAuto` uses CAS on `next_value` to make concurrent mints race-safe (lost race surfaces as `BARCODE_RANGE_RACE` so the caller can retry). `renderSvg` ships its own EAN/UPC L/G/R tables + ITF-14 widths + Code-128 Set B inline — no external barcode library, no `<script>` / `<foreignObject>` in the output. `validateValue` is a pure-function exported on the module so callers can validate without instantiating the factory. Migration `0068_barcodes.sql`. · *`customerPortal` primitive — single-use scoped customer self-serve sessions* — `bShop.customerPortal.create({ query? })` returns `{ createSession, verifyToken, revokeSession, listForCustomer, expireOlderThan }`. Token plaintext returned once on `createSession`; storage holds only `namespaceHash('customer-portal-token', plaintext)`. Scopes: `full / billing_only / address_only / subscriptions_only / order_history_only`. `verifyToken` flips the row to `consumed` (single-use); constant-time hit/miss equalisation via `b.crypto.timingSafeEqual` on the miss path. Single-use enforced by an `UPDATE … WHERE status='issued'` SQL guard so racing verifies don't both succeed. Default TTL 15 min. Migration `0072_customer_portal_sessions.sql`. · *`subscriptionBilling` primitive — invoice + payment-attempt + dunning ledger* — `bShop.subscriptionBilling.create({ query?, subscriptions, payment? })` returns `{ recordInvoice, recordPaymentAttempt, markPaid, markFailed, enterDunning, exitDunning, invoicesForSubscription, failedInvoices, dunningRoster, arpu }`. Webhook-replay idempotency on `processor_invoice_id` + `(invoice_id, attempt_number)`. Invoice FSM: pending → paid / failed / voided. Dunning states: active / dunning / recovered / cancelled / written_off. `arpu({ from, to })` returns average revenue per user bucketed by currency. `invoice_url` gated via `b.safeUrl`. Migration `0066_subscription_billing.sql` (3 tables with FK CASCADE). · *`translations` primitive — per-resource multi-locale lookup with locale-fallback chain* — `bShop.translations.create({ query? })` returns `{ setTranslation, getTranslation, getForResource, removeTranslation, localesForResource, bulkSet, missingTranslations }`. Locale normalised to BCP-47 canonical form; fallback chain walks right-to-left stripping subtags + appends `en` baseline. `bulkSet` validates every row before writing any (pre-flight-then-write for D1 portability). Values pass through `b.template.escapeHtml` at write so HTML metacharacters land inert while operator-intent newlines survive. `missingTranslations({ resource_kind, locale, sample_size })` reports resources lacking translations in that locale's fallback chain. Migration `0070_translations.sql` (UNIQUE(resource_kind, resource_id, locale, field) backing the upsert). · *`couponStacking` primitive — rules engine for which discount codes may be combined* — `bShop.couponStacking.create({ query?, customerSegments? })` returns `{ definePolicy, getPolicy, listPolicies, updatePolicy, archivePolicy, evaluate, activePoliciesForCart }`. Governing policy = highest-priority active policy whose `order_min_minor` + `customer_segment_in` preconditions match the cart. Conservative default when no policy governs: "first code allowed, rest refused with `no_policy_allows_stacking`". `exclusive_codes` short-circuits + additionally refuses the quantity-discount stack regardless of `with_quantity_discounts`. Refusal reasons: `no_policy_allows_stacking` / `other_codes_not_allowed_to_stack` / `max_codes_per_order_exceeded` / `exclusive_code_present` / `quantity_discount_stack_refused`. Migration `0067_coupon_stacking.sql`. · *`experiments` primitive — A/B testing with deterministic assignment + Wilson confidence intervals* — `bShop.experiments.create({ query? })` returns `{ defineExperiment, getVariant, recordConversion, metricsForExperiment, listExperiments, pauseExperiment, resumeExperiment, archiveExperiment, update }`. Variant assignment is deterministic per session: `namespaceHash('experiments-assign', slug + ':' + sessionHash)` → first 64 bits mod cumulative weight (computed in two 32-bit halves to stay inside JS's 53-bit safe-integer territory). Sessions assigned 100x return the same variant 100x. `variants_json` is read-only after `defineExperiment` — changing variants would corrupt prior assignments. `recordConversion` is drop-silent on unknown experiment / variant / archived / bad-shape (hot-path). `metricsForExperiment` returns per-variant counts + Wilson 95% CI when `assigned_sessions` is supplied. Migration `0071_experiments.sql` (FSM: draft / running / paused / archived terminal). · *`printReceipts` primitive — ESC/POS thermal + HTML/PDF + plain-text receipt rendering* — `bShop.printReceipts.create({ query?, order })` returns `{ thermal, htmlPdf, plainText, previewBuffer, recordPrint, printsForOrder }`. `thermal({ paper_width_mm? })` defaults to 80mm cols; 58mm option supported. `htmlPdf` returns an HTML string suitable for headless-Chrome PDF conversion. `plainText` scrubs C0 control bytes + DEL on output. Every operator-input ship-to field passes through `b.template.escapeHtml` for HTML output. Each print writes a `receipt_prints` audit row with byte size + SHA3-512 hash so an operator can prove what came off the printer. `previewBuffer` returns the rendered string WITHOUT writing the audit row (operator preview path). Migration `0062_print_receipts.sql`. · *`inventorySnapshots` primitive — point-in-time stock captures with SHA3-512 tamper-evidence + delta reports* — `bShop.inventorySnapshots.create({ query?, catalog, inventoryLocations? })` returns `{ takeSnapshot, getSnapshot, listSnapshots, deltaBetween, summary, purgeOlderThan }`. Single-bucket capture reads from `inventory` (location_code NULL); multi-location capture reads from `inventory_stock` when `inventoryLocations` is wired. `deltaBetween({ from_snapshot_id, to_snapshot_id, sku? })` returns per-(sku, location) deltas tagged `added / removed / changed / unchanged`. `summary(snapshot_id)` returns counts + total units + flagged stockout outliers + tamper-evidence check (recomputes the SHA3-512 hash + flags mismatch). `purgeOlderThan(days)` walks expired snapshots — FK CASCADE removes the detail rows. Migration `0065_inventory_snapshots.sql`. · *`productImport` primitive — bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalogs* — `bShop.productImport.create({ query?, catalog })` returns `{ dryRun, importRows, importFromCsv, importFromJson, lastReport, cancelInflight, listImports, errorsForImport }`. Formats: `flat_csv / shopify_json / blamejs_native`. on_conflict modes: `update / skip / error`. Within-import SKU dedupe surfaces as `duplicate_sku_in_import` row-error. Shopify JSON shape converts decimal prices to minor units. RFC-4180 CSV with quoted-comma + quote-doubling. Per-row errors persist to `product_import_errors` for operator triage. Migration `0069_product_imports.sql`.
12
+
11
13
  - v0.0.59 (2026-05-22) — **Eleven new primitives: collections, customer segments, recently-viewed, stock alerts, shipping + return labels, promo banners, search synonyms, affiliates, mailing audiences, order timeline.** Eleven primitives ship in one release. `collections` registers manual + smart product groupings. `customerSegments` evaluates RFM-style rules over order history. `recentlyViewed` tracks per-customer / per-session product browse history. `stockAlerts` issues back-in-stock subscriptions with confirmation tokens. `shippingLabels` + `returnLabels` are the carrier-agnostic label-management primitives. `promoBanners` is the storefront CMS for placement-targeted marketing. `searchSynonyms` rewrites and corrects customer search queries. `affiliates` runs the partner program with single-use codes + per-affiliate commission ledgers. `mailingAudiences` is the newsletter segmentation primitive. `orderTimeline` aggregates events from every order-touching primitive into a single chronologically-sorted feed with operator-friendly summary memoization. **Added:** *`collections` primitive — manual + smart product collections* — `bShop.collections.create({ query?, catalog, cursorSecret? })` returns `{ defineManual, defineSmart, get, list, update, archive, addProduct, removeProduct, reorderProducts, productsIn, collectionsForProduct, evaluateRules }`. Manual collections store an explicit ordered membership; smart collections evaluate rules against the catalog at read time. Rules support `eq / neq / contains / gt / gte / lt / lte / in / not_in / between` with field-class guards. `collectionsForProduct(product_id)` unions manual membership with active-smart-rule matches. `update` uses `b.safeSql.assertOneOf` against an `ALLOWED_COLUMNS` allowlist. Migration `0043_collections.sql`. · *`customerSegments` primitive — RFM-style segmentation with cached membership* — `bShop.customerSegments.create({ query?, cursorSecret? })` returns `{ defineSegment, evaluate, recompute, segmentsForCustomer, listSegments, update, archive, unarchive, stats }`. Rules ANDed across `recency_days_max/min`, `frequency_orders_min`, `lifetime_orders_min/max`, `monetary_minor_min/max`, `aov_minor_min`, `refund_rate_bps_min/max`, `last_order_status_in`, `country_in`, `currency_in`. Cancelled orders excluded from aggregates. `recompute()` refreshes the membership cache; subsequent `evaluate(slug)` reads off the cache for paginated iteration. Migration `0049_customer_segments.sql`. · *`recentlyViewed` primitive — per-customer / per-session browse history* — `bShop.recentlyViewed.create({ query?, catalog })` returns `{ recordView, forCustomer, forSession, merge, recommend, cleanupOlderThan, purgeCustomer }`. Session ids are namespace-hashed (`recently-viewed-session`) before write — the log never holds a recoverable identifier. Re-viewing a product within 5 minutes updates the existing row's `last_viewed_at` + increments `view_count` rather than writing a new one. `forCustomer({ exclude_purchased: true })` filters out products the customer has already bought. `merge({ session_id, customer_id })` rolls a guest session into the customer record at login. Sliding-window cap default 20. Migration `0050_recently_viewed.sql`. · *`stockAlerts` primitive — back-in-stock notifications with confirmation tokens* — `bShop.stockAlerts.create({ query?, catalog, notifications? })` returns `{ subscribe, unsubscribe, confirm, isSubscribed, listForSku, listForCustomer, scanAndNotify, cleanupExpired, stats, hashEmail }`. `subscribe` mints a 32-char base64url confirmation token (hash stored, plaintext returned once). `scanAndNotify({ now })` walks pending alerts where `stock_on_hand - stock_held > 0` and (when `notifications` is wired) enqueues an in-app message under event-type `stock.back-in-stock`. Email + token always hashed via `namespaceHash` before write. Migration `0048_stock_alerts.sql`. · *`shippingLabels` primitive — carrier-agnostic shipping label management* — `bShop.shippingLabels.create({ query?, cursorSecret? })` returns `{ requestLabel, markPurchased, voidLabel, markUsed, getLabel, labelsForShipment, labelsForOrder, pendingLabels, voidedInWindow, costsByPeriod, customsForLabel }`. `purchased_via` enum: `easypost / shippo / shipstation / stamps_com / manual / custom`. `label_url` gated through `b.safeUrl.parse` (https-only). `voidLabel` refuses past the carrier's 30-day window with a typed error code. `costsByPeriod({ from, to, carrier? })` groups by purchased_via + currency for cost-allocation dashboards. The actual label-generation HTTP call is the operator's worker. Migration `0051_shipping_labels.sql`. · *`returnLabels` primitive — operator-funded return-shipping flow* — `bShop.returnLabels.create({ query?, returns? })` returns `{ issueLabel, markShipped, markInTransit, markDelivered, markException, getLabel, labelForReturn, pendingPickup, inTransit, eventsForLabel }`. FSM: `issued → shipped → in_transit → delivered`, with an `exception` terminal branch. `markInTransit` accepts re-entry so a multi-hop journey produces a row-per-scan timeline. `markDelivered` calls the injected `returns.markReceived` callback so the RMA flips to `received` status automatically. `issueLabel` refuses unless the underlying return is in `approved` status. Migration `0052_return_labels.sql`. · *`promoBanners` primitive — operator-controlled marketing banners across storefront placements* — `bShop.promoBanners.create({ query?, customerSegments? })` returns `{ defineBanner, activeForPlacement, listAll, getBanner, updateBanner, archive, unarchive, renderHtml, impressionCount, clickCount, recordImpression, recordClick }`. Placements: `top_strip / homepage_hero / pdp_side / cart_side / search_empty / footer`. Audiences: `all / logged_in / guest / segment`. Themes: `info / promo / urgency / success`. `cta_url` gated through `b.safeUrl.parse` (https-only + /-rooted internal paths). `renderHtml` escapes every operator-input field via `b.template.escapeHtml` so script / img-onerror payloads render inert. `recordImpression` / `recordClick` drop-silent on bad slug (hot-path). Migration `0053_promo_banners.sql`. · *`searchSynonyms` primitive — query rewriting + typo correction + stopword removal* — `bShop.searchSynonyms.create({ query? })` returns `{ addGroup, addTypo, addStopword, removeStopword, getGroup, listGroups, updateGroup, deleteGroup, listTypos, listStopwords, rewrite, learnFromQueries }`. Group kinds: `bidirectional` (any term matches any other) + `directional` (terms[0..N-1] all map to terms[N], canonical is terms[N]). `rewrite(query, { include_corrections?, max_expansions? })` returns `{ canonical, expansions, corrections }`. Tokenise → strip control bytes (don't refuse — denial-of-search would block legitimate shoppers) → stopword drop → typo correction → conservative English stemmer (`-ies / -ing / -ed / -es / -s`, min length 4). `learnFromQueries({ from, to, min_count })` scans `search_query_log` for term co-occurrences not yet covered by an existing group. Migration `0055_search_synonyms.sql`. · *`affiliates` primitive — partner program with single-use codes + per-affiliate commission ledger* — `bShop.affiliates.create({ query?, cursorSecret? })` returns `{ registerAffiliate, getAffiliate, affiliateByCode, listAffiliates, updateAffiliate, pauseAffiliate, reinstateAffiliate, recordVisit, attributionForSession, recordCommissionEvent, commissionsForAffiliate, markCommissionPaid, markCommissionVoided, payoutsDue, topAffiliates }`. Codes are 8 chars over a confusion-resistant 32-glyph alphabet. Commission kinds: `percent_bps / amount_per_order_minor / amount_per_signup_minor`. `commission_minor` is computed at write time and stored — historical payouts don't shift when the operator later edits the affiliate's rate. `recordCommissionEvent` is idempotent on `(order_id, affiliate_id)`. Email + visitor session id always hashed via `namespaceHash` before write. Migration `0057_affiliates.sql`. · *`mailingAudiences` primitive — newsletter audience segmentation with suppression filtering* — `bShop.mailingAudiences.create({ query?, newsletter?, emailSuppressions?, cursorSecret? })` returns `{ defineAudience, resolve, count, getAudience, listAudiences, update, archive, recompute, auditDelivery, listDeliveries }`. Audience rules over `newsletter_signups`: `subscribed_after/before`, `source_in`, `tag_any/all`, `country_in`, `double_opt_in`, `language_in`, `customer_status_in`. `resolve({ slug, skip_suppressed: true })` filters out suppressions via the injected `emailSuppressions` peer (default true). Defaults: emails return hashed only; plaintext requires `include_plaintext: true` for verifier-role-gated routes. `recompute()` refreshes the per-slug membership cache. `auditDelivery` writes append-only rows for compliance export. Unsubscribed signups never match any rule. Migration `0056_mailing_audiences.sql` (+ additive columns on `newsletter_signups` for `tags_csv` / `country` / `language` / `customer_status` / `double_opt_in_at`). · *`orderTimeline` primitive — unified per-order event feed across every order-touching primitive* — `bShop.orderTimeline.create({ query?, order?, orderTracking?, orderNotes?, returns?, payment?, fraudScreen?, shippingLabels?, notifications? })` returns `{ forOrder, summarize, customerVisibleFor, compareOrders, recentActivity }`. Aggregates events from `order_transitions`, `shipment_events`, `order_notes`, `return_authorizations`, `fraud_screenings`, `shipping_labels`, and `notifications` into a chronologically-sorted feed newest-first. `customerVisibleFor({ order_id, locale })` filters to events the customer should see (en / es / de built-in, unknown locales fall back to English). `summarize(order_id)` returns `{ status, first_event_at, last_event_at, event_count, milestones: { paid_at?, fulfilled_at?, shipped_at?, delivered_at?, refunded_at? }, cache_hit }`. Cache invalidation tracks both `last_event_at` AND a per-source row count so a same-millisecond event still flushes the cache. Every injected peer is optional. Migration `0054_order_timeline_cache.sql`. **Fixed:** *`returnLabels.issueLabel` — accepts an optional `issued_at` epoch-ms for deterministic test ordering* — Synthetic-timestamp tests that mix `markShipped({ shipped_at: ... })` with the issuer's wall-clock `Date.now()` could see the issued event sort AFTER the synthetic ones. `issueLabel` now accepts `issued_at` so the full timeline can be authored from a single clock source. Production callers omit the field; `Date.now()` is the default. · *`orderTimeline.summarize` — cache freshness check covers every source the collector walks* — `_sourceStats` now joins `shipping_labels` (via `shipments.order_id`) and `notifications` (via `payload_json` `LIKE '%"order_id":"<uuid>"%'`) so the per-source row count matches `_collectAll`. Previously, an order with any shipping-label or notification rows had a mismatched count between cache-write and cache-check, and the cache never registered as fresh. The second `summarize(order_id)` call now correctly hits the cache.
12
14
 
13
15
  - 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).