diff --git a/sg-commerce-pro/CHANGELOG.md b/sg-commerce-pro/CHANGELOG.md
new file mode 100644
index 0000000..5f6f0e6
--- /dev/null
+++ b/sg-commerce-pro/CHANGELOG.md
@@ -0,0 +1,55 @@
+# Changelog
+
+## 4.0.0 — Amazon Seller Suite
+
+### Added
+- **Amazon Suite** admin app: a single-page app with Dashboard, Profit & P&L,
+ Orders, Inventory & Restock, PPC Manager, Keywords, Listing Optimizer,
+ Hijackers & Buy Box, Review Requests, Reimbursements, Traffic & Returns,
+ Product Research, Reports Center, Alerts and Settings.
+- REST API `sg-commerce/v1/suite/*` (45 routes, `manage_options` only).
+- 24 new `sg_suite_*` tables, versioned separately (`sg_suite_schema_version`).
+- Data pipelines:
+ - Orders v0 (incremental, lazy item fetch).
+ - Finances v0 financial events (windowed and resumable per region).
+ - Reports API pipeline: request → poll → download → gunzip → ingest. Covers
+ all-orders, sales & traffic, ledger, reimbursements, returns, inventory
+ planning, seller feedback and Search Query Performance.
+ - Ads API v3: campaigns, keywords, targets, and spCampaigns / spTargeting /
+ spSearchTerm reports.
+- Engines:
+ - Profit engine: settled + estimated fees, refunds, account fees, ads, COGS
+ with sellable-return add-back, expenses, month-end forecast.
+ - Restock planner: OOS-aware velocity, case pack / MOQ, stock-out gap before
+ inbound.
+ - PPC bid optimizer, search-term harvester / negator and budget pacing, with
+ review, apply, dismiss and auto-apply.
+ - Listing auditor: 100-point score, content-change detection, AI rewrite.
+ - Listing monitor: hijackers, Buy Box, competitor price drops, price/BSR
+ history.
+ - Review requester (Solicitations API).
+ - Reimbursement auditor: lost/damaged inventory, refund-no-return, fee
+ overcharge, with case-message generator.
+ - Product research: catalog search, BSR sales estimate, fee estimates,
+ profitability calculator.
+- Marketplace-local day bucketing (US = Pacific), matching Seller Central.
+- Email delivery for suite alerts (configurable levels, throttled).
+- Deterministic demo account for the 7 SEVEN catalog, removable exactly.
+- Tests: `tests/unit.php` (79 assertions) and
+ `tests/integration-smoke.php` (all REST routes, secret handling, demo
+ cleanup).
+
+### Changed
+- Amazon Ads API secrets are added to the encrypted secret store.
+- Rate-limiter buckets added for reports documents, order items, finances,
+ listings, catalog search, fee estimates and solicitations.
+
+### Fixed
+- Uninstall now also drops `sg_inbound_plans`, `sg_product_ean` and every
+ `sg_suite_*` table. It also deletes every `sg_commerce_*` option (carton
+ templates and the sandbox flag were previously left behind), all suite
+ options and transients, and Action Scheduler jobs.
+
+## 3.3.1
+- Baseline imported (SP-API connection, sync, repricer, AI copy, analytics,
+ automation, MCF, inbound plans, PII tools, settlements, headless API).
diff --git a/sg-commerce-pro/README.md b/sg-commerce-pro/README.md
new file mode 100644
index 0000000..cb98ce2
--- /dev/null
+++ b/sg-commerce-pro/README.md
@@ -0,0 +1,158 @@
+# Seven Gum Commerce Pro — with Amazon Seller Suite (v4.0)
+
+A WordPress plugin that turns **sevengum.com** into the command center for the
+7 SEVEN Amazon business. v4.0 adds the **Amazon Seller Suite**, a full
+single-page app inside wp-admin (**Amazon Suite** menu) that covers what sellers
+usually pay several monthly subscriptions for. It runs on Amazon's official
+SP-API and Ads API and needs no external SaaS.
+
+| Suite module | Replaces / matches | Data source (official APIs only) |
+|---|---|---|
+| **Dashboard**: Today / Yesterday / MTD / Last month, month-end forecast, action center | Sellerboard dashboard | Orders, Finances, Ads, Reports |
+| **Profit & P&L**: net profit per day / SKU / order, fee breakdown, waterfall, COGS editor, custom expenses | Sellerboard, Shopkeeper | Finances v0 events, Orders v0, all-orders report, Ads reports |
+| **Orders**: every order with its own profit, settled vs. estimated fees | Sellerboard orders | Orders v0 (near real time) + flat-file orders report |
+| **Inventory & Restock**: velocity forecast, days of cover, stock-out date, order-by date, PO qty (case pack / MOQ), lost-sales estimate, gap-before-inbound warning, aged inventory & storage | SoStocked, Forecastly, RestockPro | FBA inventory, order items, FBA inventory planning report |
+| **PPC Manager**: bid optimisation to target ACoS, pause bleeders, search-term harvesting (exact + ASIN), negative keywords, budget pacing, change log, auto-apply | Helium 10 Adtomic, Perpetua, PPC Entourage | Ads API v3 (Sponsored Products + Reporting v3) |
+| **Keywords**: share-of-search tracker (impression / click / purchase share, 8-week trend), keyword research, all Brand Analytics queries per ASIN | Helium 10 Keyword Tracker / Cerebro | Brand Analytics Search Query Performance, Ads keyword recommendations, your search-term report |
+| **Listing Optimizer**: 100-point grade (title policy 2025, bullets, description, backend bytes, images, Amazon issues, suppression, keyword coverage) + AI rewrite in brand voice | Helium 10 Listing Analyzer / Scribbles | Listings Items API 2021-08-01 + your AI provider (Ollama) |
+| **Hijackers & Buy Box**: new-seller (hijacker) alerts, Buy Box lost/suppressed alerts, competitor price-drop alerts, Keepa-style price & BSR history | Seller Snap alerts, Keepa | Product Pricing v0 `getItemOffers` |
+| **Review Requests**: automatic Amazon "Request a Review" 5–30 days after delivery, skipping refunded/returned orders, daily cap; seller-feedback monitor | FeedbackWhiz, Jungle Scout review automation | Solicitations API, seller feedback report |
+| **Reimbursements**: lost & damaged FBA inventory, refunded-but-never-returned orders, FBA fee overcharges, each with a ready-to-paste case message | GETIDA, Refunds Manager | Inventory ledger, reimbursements, returns reports, Finances |
+| **Traffic & Returns**: sessions, page views, unit-session %, Buy Box % per day and ASIN; return reasons & dispositions | Business Reports+ | Sales & Traffic report, customer returns report |
+| **Product Research**: niche explorer (BSR-based sales estimate, page-1 revenue, offers, opportunity score), exact FBA fees for any ASIN, profitability calculator (break-even ACoS, max CPC, ROI) | Jungle Scout, Helium 10 Black Box, FBA calculator | Catalog Items 2022-04-01, Product Pricing, Product Fees |
+| **Reports Center**, **Alerts** (+ email), **Settings**, **Demo mode** | | |
+
+---
+
+## Install
+
+1. Upload the plugin zip in **Plugins → Add New → Upload**, then activate.
+ Requirements: WordPress 6.4+, PHP 8.1+ with `openssl`, `curl`, `json`, `mbstring`.
+2. Open **Amazon Suite** in wp-admin. Before you connect anything, you can click
+ **Load demo account** to explore every screen with a realistic 90-day
+ sample account for the 19 SKUs. Demo rows are flagged and removed exactly by
+ **Clear demo data**; real data is never touched.
+
+### Hostinger / shared hosting: use a real cron
+
+WP-Cron only runs when someone visits the site. In hPanel → **Advanced → Cron
+Jobs**, add (every 5 minutes):
+
+```
+wget -q -O - https://sevengum.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
+```
+
+and add `define( 'DISABLE_WP_CRON', true );` to `wp-config.php`. All jobs are
+batched to fit shared-hosting time limits (report polling, item fetches and
+monitor checks are capped per tick and resume on the next one).
+
+---
+
+## Connect Amazon (SP-API)
+
+1. **Seller Central → Apps and Services → Develop Apps** (or the Solution
+ Provider Portal) → register as a *private developer* and create an app client.
+2. Request these **roles** so every suite module works:
+ - Product Listing (listings, catalog)
+ - Pricing (Buy Box / offers, competitive pricing, fee estimates)
+ - Inventory and Order Tracking (orders, FBA inventory)
+ - Amazon Fulfillment (FBA reports, inbound, MCF)
+ - Finance and Accounting (financial events)
+ - Buyer Solicitation (review requests)
+ - Brand Analytics (Search Query Performance; needs Brand Registry)
+ - Selling Partner Insights (seller feedback)
+3. **Authorize** the app for your own account to get the **refresh token**.
+4. In wp-admin → **Seven Gum → Amazon Connect**, enter the LWA Client ID, Client
+ Secret and Refresh Token, choose the primary marketplace (US) and the enabled
+ marketplaces (e.g. US + AE), save, and click **Run connection test**.
+5. In **Amazon Suite → Settings**, enter your **Seller ID (Merchant Token)**:
+ Seller Central → Settings → Account Info → *Merchant Token*. Listing audits
+ and hijacker detection need it.
+6. Click **Settings → Run now → Backfill history**. This requests the orders and
+ traffic reports for the backfill window (default 60 days) and resets the
+ finance cursor. Data then fills in over the next cron ticks.
+
+## Connect the Amazon Ads API (PPC)
+
+1. Apply for Amazon Ads API access (advertising.amazon.com → API), using a Login
+ with Amazon security profile.
+2. Generate a refresh token with scope `advertising::campaign_management`.
+3. In **Amazon Suite → Settings → Amazon Ads API**: choose the region, enter the
+ refresh token, plus the client ID and secret if they differ from your SP-API
+ app. Click **Test & list profiles**, then **Use** on your seller profile
+ (e.g. US).
+4. Turn on **Sync ads daily**. Set a target ACoS. Optionally set a harvest
+ destination campaign and ad group (your exact-match campaign).
+5. The optimizer plans changes daily. Review them in **PPC → Optimizer** and
+ apply the ones you want. Turn on **Auto-apply** only after you trust the
+ rules; it pushes bid changes and negatives only.
+
+---
+
+## How the numbers are computed
+
+- **Profit basis.** Sales, units and promotions are counted on the order date,
+ in the marketplace's own timezone (US = Pacific), the same as Seller Central.
+ Amazon fees are the settled amounts for those orders. For orders that are not
+ settled yet, fees are estimated from each SKU's trailing 90-day fee per unit.
+ Refunds, storage, subscription, reimbursements and adjustments are counted on
+ their posted date. Ads spend comes from the Ads API (or from invoices if the
+ Ads API is not connected). COGS is units × landed cost, with cost added back
+ for returns restocked as sellable. Monthly expenses are pro-rated per day.
+ Taxes are pass-through.
+- **Restock.** Velocity = 0.5·v7 + 0.3·v30 + 0.2·v90, with out-of-stock days
+ excluded. Reorder point = velocity × (lead time + safety days). Order quantity
+ covers lead time plus target cover, rounded up to the case pack and at least
+ the MOQ.
+- **Bid rules.** With orders: move the bid toward (sales ÷ clicks) × target ACoS,
+ at most ±max-step per run. No orders after N clicks: cut the bid, or pause once
+ spend reaches 2× break-even CPA. Low impressions: raise by half a step. Bids
+ are always clamped to [min, max].
+
+### Honest limitations
+
+- **BSR → monthly sales is an estimate (about ±40%).** No tool has real unit
+ data for other sellers' ASINs.
+- **Amazon has no official organic-rank API.** The keyword tracker uses Brand
+ Analytics share of impressions, clicks and purchases, which is the compliant
+ equivalent.
+- **Search Query Performance requires Brand Registry.**
+- **Review requests use Amazon's own template only.** That is the only
+ ToS-compliant automation.
+
+---
+
+## Security & privacy
+
+- Every secret (LWA, Ads, AI keys) is encrypted at rest with AES-256-GCM and
+ never returned to the browser or written to logs. The settings API only
+ exposes "is set" flags.
+- Every admin page and REST route requires `manage_options`. REST uses the
+ `wp_rest` nonce.
+- Buyer PII is never requested (no RDT) and never stored. PII columns in flat
+ files (buyer email, name, address) are skipped at ingest. Only ship country is
+ kept.
+- **Uninstall leaves zero traces**: all `sg_*` / `sg_suite_*` tables, all
+ `sg_commerce_*` / `sg_suite_*` options and transients, encrypted secrets,
+ cron events and Action Scheduler jobs are removed (multisite-safe).
+
+## Tests
+
+```bash
+php tests/unit.php # 79 pure-logic tests, no WordPress needed
+wp eval-file wp-content/plugins/sg-commerce-pro/tests/integration-smoke.php
+ # seeds demo, hits every REST route, checks secrets & cleanup
+```
+
+---
+
+## راهنمای سریع (فارسی)
+
+۱. افزونه را نصب و فعال کنید. منوی جدید **Amazon Suite** در پیشخوان وردپرس اضافه میشود.
+۲. برای دیدن همهی بخشها قبل از اتصال، روی **Load demo account** بزنید. دادههای نمونهی ۹۰ روزه برای ۱۹ محصول ساخته میشود و با **Clear demo data** کامل پاک میشود.
+۳. اتصال آمازون: در **Seven Gum → Amazon Connect** مقادیر Client ID، Client Secret و Refresh Token را وارد کنید و تست اتصال را بزنید.
+۴. در **Amazon Suite → Settings** شناسهی فروشنده (Merchant Token) را وارد کنید و سپس **Backfill history** را بزنید.
+۵. برای مدیریت تبلیغات (PPC)، اطلاعات Amazon Ads API را در همان تنظیمات وارد کنید، **Test & list profiles** را بزنید و پروفایل US را انتخاب کنید.
+۶. روی هاستینگر حتماً Cron واقعی تنظیم کنید (دستور بالا).
+
+بخشها: سود خالص واقعی و صورت سود و زیان، سفارشها، برنامهریزی موجودی و سفارش مجدد، مدیریت تبلیغات، ردیابی کلمات کلیدی، بهینهسازی لیستینگ با هوش مصنوعی، هشدار هایجکر و Buy Box، درخواست خودکار نظر، پیدا کردن غرامتهای آمازون، ترافیک و مرجوعیها، تحقیق محصول و ماشینحساب سود.
diff --git a/sg-commerce-pro/admin/views/ai.php b/sg-commerce-pro/admin/views/ai.php
new file mode 100644
index 0000000..30bbb15
--- /dev/null
+++ b/sg-commerce-pro/admin/views/ai.php
@@ -0,0 +1,121 @@
+get( SettingsRepository::class );
+$prompts = $container->get( PromptRegistry::class );
+
+$active_provider = (string) $settings->get( 'ai_provider', 'ollama' );
+$ollama_url = (string) $settings->get( 'ollama_url', 'http://127.0.0.1:11434' );
+$ollama_model = (string) $settings->get( 'ollama_model', 'llama3.1:8b' );
+$temperature = (float) $settings->get( 'ai_temperature', 0.7 );
+$brand_voice = (string) $settings->get( 'ai_brand_voice', 'modern, clean, playful but confident' );
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 Install: curl -fsSL https://ollama.com/install.sh | sh
+ 2 Pull a model: ollama pull llama3.1:8b
+ 3 Verify: curl http://127.0.0.1:11434/api/tags
+ 4
+
+
+
+
+
+
+
+
+
+
brand_system() ); ?>
+
+
+
describe_product( array(
+ 'sku' => '{sku}', 'asin' => '{asin}', 'product_name' => '{name}', 'market' => '{market}'
+) ) ); ?>
+
+
+
translate( '{english_text}', '{target_language}' ) ); ?>
+
+
diff --git a/sg-commerce-pro/admin/views/amazon.php b/sg-commerce-pro/admin/views/amazon.php
new file mode 100644
index 0000000..331e3df
--- /dev/null
+++ b/sg-commerce-pro/admin/views/amazon.php
@@ -0,0 +1,140 @@
+get( SettingsRepository::class );
+$marketplaces = $container->get( Marketplaces::class );
+$client = $container->get( AmazonClient::class );
+
+$markets = $marketplaces->all();
+$enabled_codes = $marketplaces->enabled_codes();
+$primary = $marketplaces->primary();
+$has_creds = $client->has_credentials();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ LWA Client ID
+
+
+
+ LWA Client Secret
+
+
+
+ LWA Refresh Token
+
+
+
+ Seller ID (optional)
+
+
+
+
+
+ $m ) : ?>
+ >
+
+
+
+
+
+
+
+
+
+ $m ) : ?>
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Client ID saved
+
Client Secret encrypted
+
Refresh Token encrypted
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'Client Secret', 'amazon_refresh_token' => 'Refresh Token', 'amazon_seller_id' => 'Seller ID' ) as $secret_key => $label ) : ?>
+
+
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
+
+ 1
+ 2
+ 3
+ 4
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/analytics.php b/sg-commerce-pro/admin/views/analytics.php
new file mode 100644
index 0000000..6cc8f45
--- /dev/null
+++ b/sg-commerce-pro/admin/views/analytics.php
@@ -0,0 +1,165 @@
+get( ProductRepository::class );
+$aggregator = $container->get( SalesAggregator::class );
+
+$kpis = $aggregator->headline_kpis();
+$by_mkt = $products->counts_by_market();
+$trend = $aggregator->buybox_trend( 14 );
+$at_risk = $aggregator->at_risk( 20 );
+
+// Build chart data.
+$trend_labels = array();
+$trend_won = array();
+$trend_lost = array();
+foreach ( $trend as $row ) {
+ $trend_labels[] = (string) $row['day'];
+ $trend_won[] = (int) $row['won'];
+ $trend_lost[] = (int) $row['lost'];
+}
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $count ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ' . $qty . '' : (string) $qty;
+ ?>
+
+
+ Won'
+ : 'Lost '; ?>
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/api-keys.php b/sg-commerce-pro/admin/views/api-keys.php
new file mode 100644
index 0000000..7ddc69d
--- /dev/null
+++ b/sg-commerce-pro/admin/views/api-keys.php
@@ -0,0 +1,117 @@
+get( SettingsRepository::class );
+$keys = (array) $settings->get( 'api_keys', array() );
+
+$new_key = get_transient( 'sg_new_api_key_' . get_current_user_id() );
+if ( $new_key ) {
+ delete_transient( 'sg_new_api_key_' . get_current_user_id() );
+}
+?>
+
+
+
+
+
+
+
+
✓
+
+
+
+
+ Authorization: Bearer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
()
+
+
+
+
+
+
+
+
+
+
+
+ $k ) : if ( ! is_array( $k ) ) continue; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GET /products —
+ GET /products/{id} —
+ GET /analytics/kpis —
+ GET /analytics/buybox-trend?days=14 —
+ GET /mcf/orders —
+ POST /mcf/orders —
+ GET /meta —
+ GET /openapi —
+
+
+
+
curl '' \
+ -H 'Authorization: Bearer sgc_YOUR_KEY_HERE'
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/automation.php b/sg-commerce-pro/admin/views/automation.php
new file mode 100644
index 0000000..68d3947
--- /dev/null
+++ b/sg-commerce-pro/admin/views/automation.php
@@ -0,0 +1,104 @@
+get_results( "SELECT * FROM {$wpdb->prefix}sg_rules ORDER BY id ASC", ARRAY_A );
+$alerts = AlertDispatcher::recent( 50 );
+$unread = AlertDispatcher::unread_count();
+?>
+
+
+
+
+
+
+
()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Active'
+ : 'Off '; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 ) : ?>
+
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ago
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/catalog-lookup.php b/sg-commerce-pro/admin/views/catalog-lookup.php
new file mode 100644
index 0000000..774aec1
--- /dev/null
+++ b/sg-commerce-pro/admin/views/catalog-lookup.php
@@ -0,0 +1,174 @@
+get( Marketplaces::class );
+$lookup = $container->get( CatalogLookup::class );
+
+global $wpdb;
+$mapped = $wpdb->get_results(
+ "SELECT * FROM {$wpdb->prefix}sg_product_ean ORDER BY sku ASC, market ASC",
+ ARRAY_A
+);
+
+$search_ean = isset( $_POST['ean'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['ean'] ) ) : '';
+$search_market = isset( $_POST['market'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $_POST['market'] ) ) ) : 'US';
+$search_results = null;
+$search_error = '';
+
+if ( '' !== $search_ean && isset( $_POST['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( (string) $_POST['_wpnonce'] ) ), 'sg_ean_lookup' ) ) {
+ try {
+ if ( ! CatalogLookup::validate_ean13( $search_ean ) ) {
+ $search_error = __( 'Invalid EAN-13 — checksum digit does not match.', 'sg-commerce' );
+ } else {
+ $preflight = $lookup->preflight( $search_ean, $search_market );
+ $search_results = $preflight;
+ }
+ } catch ( \Throwable $e ) {
+ $search_error = $e->getMessage();
+ }
+}
+?>
+
+
+
+
+
+
+
+
+
+
+ EAN-13
+
+
+
+
+ enabled_codes() as $m ) : ?>
+ >
+
+
+
+
+
+
+
+
+
+
+ ✓ EAN checksum valid.
+
+
+
+ ✗ EAN checksum invalid.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
()
+
+
+
+
+
+ SKU
+ EAN-13
+
+ ASIN
+
+
+
+
+
+ 'sg-chip-ok', 'absent' => 'sg-chip-warn', 'ambiguous' => 'sg-chip-warn',
+ default => 'sg-chip-muted',
+ };
+ $listing_chip = match ( (string) $m['listing_status'] ) {
+ 'published' => 'sg-chip-ok', 'draft' => 'sg-chip-muted',
+ 'error' => 'sg-chip-err', default => 'sg-chip-muted',
+ };
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/comparisons.php b/sg-commerce-pro/admin/views/comparisons.php
new file mode 100644
index 0000000..c6206db
--- /dev/null
+++ b/sg-commerce-pro/admin/views/comparisons.php
@@ -0,0 +1,125 @@
+get( Marketplaces::class );
+
+global $wpdb;
+$sets = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}sg_comparisons ORDER BY updated_at DESC", ARRAY_A );
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+
+
+
+
+
+
+
+ auto
+ all() ) as $code ) : ?>
+
+
+
+
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
()
+
+
+
+
[sg_compare slug="..."].
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ' . esc_html( $set['market'] ) . '' : 'auto '; ?>
+
+
+
+
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
+
[sg_compare skus="SG-COOKIE-01,SG-MINT-01,SG-CINN-01"]
+[sg_compare skus="..." market="DE" features="image,name,price,stock,cta"]
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/dashboard.php b/sg-commerce-pro/admin/views/dashboard.php
new file mode 100644
index 0000000..a74988b
--- /dev/null
+++ b/sg-commerce-pro/admin/views/dashboard.php
@@ -0,0 +1,186 @@
+get( AmazonClient::class );
+$marketplaces = $container->get( Marketplaces::class );
+$products = $container->get( ProductRepository::class );
+$kpis = $container->get( SalesAggregator::class )->headline_kpis();
+
+$has_amazon = $amazon->has_credentials();
+$total = (int) $kpis['products_total'];
+
+global $wpdb;
+$described = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT product_id) FROM {$wpdb->prefix}sg_descriptions WHERE language='en'" );
+
+$step1 = $has_amazon;
+$step2 = $total > 0;
+$step3 = $described > 0;
+$all_done = $step1 && $step2 && $step3;
+
+$last_sync = $products->last_sync_at();
+$last_sync_human = $last_sync ? human_time_diff( strtotime( (string) $last_sync ), time() ) . ' ago' : 'never';
+
+$at_risk = $container->get( SalesAggregator::class )->at_risk( 5 );
+$recent_alerts = AlertDispatcher::recent( 5 );
+?>
+
+
+
+
Seven Gum Commerce
+
Enterprise Amazon SP-API + AI commerce engine. Inventory, pricing, copywriting, repricing & analytics — all in one place.
+
+
+ v
+ enabled_codes() ) ); ?> markets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SKU Market Stock Buy Box
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ · ago
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/fulfillment.php b/sg-commerce-pro/admin/views/fulfillment.php
new file mode 100644
index 0000000..5d9214c
--- /dev/null
+++ b/sg-commerce-pro/admin/views/fulfillment.php
@@ -0,0 +1,215 @@
+get( SettingsRepository::class );
+$marketplaces = $container->get( Marketplaces::class );
+$repo = $container->get( MCFOrderRepository::class );
+$orders = $repo->recent( 50 );
+$counts = $repo->count_by_status();
+
+$mcf_auto_wc = (bool) $settings->get( 'mcf_auto_wc', false );
+$default_market = (string) $settings->get( 'mcf_default_market', 'US' );
+$default_speed = (string) $settings->get( 'mcf_default_speed', 'Standard' );
+$wc_active = class_exists( 'WooCommerce' );
+?>
+
+
+
Multi-Channel Fulfillment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+ enabled_codes() as $code ) : ?>
+ >
+
+
+
+
+
+
+
+
+ >Standard (3-5 days)
+ >Expedited (2 days)
+ >Priority (1 day)
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ 2
+ 3
+ 4
+
+
+
+
+
+
do_action('sg_commerce_fulfill', [
+ 'displayable_order_id' => 'WEB-12345',
+ 'market' => 'US',
+ 'customer' => ['name' => 'Jane', 'email' => '...', 'phone' => '...'],
+ 'address' => [
+ 'line1' => '123 Main St',
+ 'city' => 'Austin',
+ 'state_region' => 'TX',
+ 'postal_code' => '78701',
+ 'country_code' => 'US',
+ ],
+ 'items' => [['sku' => 'SG-COOKIE-01', 'quantity' => 2]],
+ 'shipping_speed' => 'Standard',
+]);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'sg-chip-ok',
+ 'shipped' => 'sg-chip-ok',
+ 'processing'=> 'sg-chip-warn',
+ 'submitted' => 'sg-chip-warn',
+ 'failed' => 'sg-chip-err',
+ 'cancelled' => 'sg-chip-err',
+ default => 'sg-chip-muted',
+ };
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/inbound-plans.php b/sg-commerce-pro/admin/views/inbound-plans.php
new file mode 100644
index 0000000..90df634
--- /dev/null
+++ b/sg-commerce-pro/admin/views/inbound-plans.php
@@ -0,0 +1,240 @@
+get( Marketplaces::class );
+$box_service = $container->get( BoxContentService::class );
+
+global $wpdb;
+$plans = $wpdb->get_results(
+ "SELECT * FROM {$wpdb->prefix}sg_inbound_plans ORDER BY updated_at DESC LIMIT 50",
+ ARRAY_A
+);
+
+$templates = $box_service->get_templates();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sg-master-24pack
+
+
+
+
+
+
+
+
+ kg
+
+
+ kg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✓
+
+
+
+ ✓
+
+
+ ✓
+
+
+
+
+
+
+
+
+ 1 Create plan (items + source address)
+ 2 Poll plan status until READY
+ 3 Generate packing options
+ 4 Confirm packing option + push box content
+ 5 Generate placement options (warehouse choice)
+ 6 Confirm placement — operator review recommended
+ 7 Generate transport options (carrier, cost)
+ 8 Confirm transport option
+ 9 Confirm plan + print labels
+
+
+
+
+
+
+
+
+
+
+
()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'sg-chip-ok',
+ 'draft' => 'sg-chip-muted',
+ 'error' => 'sg-chip-err',
+ default => 'sg-chip-warn',
+ };
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/injection.php b/sg-commerce-pro/admin/views/injection.php
new file mode 100644
index 0000000..3800af8
--- /dev/null
+++ b/sg-commerce-pro/admin/views/injection.php
@@ -0,0 +1,163 @@
+get( Marketplaces::class );
+$settings = $container->get( SettingsRepository::class );
+$enabled = (bool) $settings->get( 'injection_enabled', true );
+
+global $wpdb;
+$rules = $wpdb->get_results(
+ "SELECT * FROM {$wpdb->prefix}sg_injection_rules ORDER BY priority ASC, id ASC",
+ ARRAY_A
+);
+?>
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+
+
+ SKU *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ auto
+ all() ) as $code ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ On'
+ : 'Off '; ?>
+
+
+
+
+
+ regex
+
+
+
+ ' . esc_html( $rule['market'] ) . '' : 'auto '; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/logs.php b/sg-commerce-pro/admin/views/logs.php
new file mode 100644
index 0000000..da480c2
--- /dev/null
+++ b/sg-commerce-pro/admin/views/logs.php
@@ -0,0 +1,65 @@
+get( Logger::class );
+$lines = $logger->tail( 200 );
+$log_dir = $logger->get_log_dir();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'sg-chip-err',
+ 'WARNING' => 'sg-chip-warn',
+ 'INFO', 'NOTICE' => 'sg-chip-ok',
+ default => 'sg-chip-muted',
+ };
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/pii.php b/sg-commerce-pro/admin/views/pii.php
new file mode 100644
index 0000000..20f1394
--- /dev/null
+++ b/sg-commerce-pro/admin/views/pii.php
@@ -0,0 +1,151 @@
+get( SettingsRepository::class );
+$pii = $container->get( PIIManager::class );
+
+$retention = (int) $settings->get( 'pii_retention_days', 30 );
+$pending = $pii->count_pending_anonymization();
+
+global $wpdb;
+$total_orders = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_mcf_orders" );
+$anonymized = (int) $wpdb->get_var( $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}sg_mcf_orders WHERE customer_name = %s",
+ '[anonymized]'
+) );
+
+$recent_audit = $wpdb->get_results(
+ "SELECT * FROM {$wpdb->prefix}sg_audit WHERE action LIKE 'pii_%' ORDER BY created_at DESC LIMIT 10",
+ ARRAY_A
+);
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✗
+ ✗
+ ✗
+ ✗
+ ✓
+ ✓
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ 0
+ ? esc_html( sprintf( __( 'Purge %d pending now', 'sg-commerce' ), $pending ) )
+ : esc_html__( 'Nothing pending', 'sg-commerce' ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/products.php b/sg-commerce-pro/admin/views/products.php
new file mode 100644
index 0000000..54494dd
--- /dev/null
+++ b/sg-commerce-pro/admin/views/products.php
@@ -0,0 +1,258 @@
+get( ProductRepository::class );
+$marketplaces = $container->get( Marketplaces::class );
+$copywriter = $container->get( Copywriter::class );
+$has_amazon = $container->get( AmazonClient::class )->has_credentials();
+
+$markets = $marketplaces->all();
+$enabled = $marketplaces->enabled_codes();
+
+$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['s'] ) ) : '';
+$market_f = isset( $_GET['market'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $_GET['market'] ) ) ) : '';
+$in_stock = ! empty( $_GET['in_stock'] );
+$page_num = max( 1, (int) ( $_GET['paged'] ?? 1 ) );
+$per_page = 25;
+
+$result = $products->paginate( array(
+ 'search' => $search,
+ 'market' => $market_f,
+ 'in_stock' => $in_stock,
+ 'page' => $page_num,
+ 'per_page' => $per_page,
+ 'orderby' => 'updated_at',
+ 'order' => 'DESC',
+) );
+
+$base_url = admin_url( 'admin.php?page=sg-commerce-products' );
+?>
+
diff --git a/sg-commerce-pro/admin/views/repricer.php b/sg-commerce-pro/admin/views/repricer.php
new file mode 100644
index 0000000..c213f82
--- /dev/null
+++ b/sg-commerce-pro/admin/views/repricer.php
@@ -0,0 +1,88 @@
+get( SettingsRepository::class );
+
+$enabled = (bool) $settings->get( 'repricer_enabled', false );
+$strategy = (string) $settings->get( 'repricer_strategy', 'match_buybox' );
+$dry_run = (bool) $settings->get( 'repricer_dry_run', true );
+$cents_below = (float) $settings->get( 'repricer_cents_below_buybox', 0.01 );
+$min_margin = (float) $settings->get( 'repricer_min_margin_pct', 15.0 );
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
+
+ />
+
+ ()
+
+
+
+
+ >Match Buy Box (minus offset)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/settings.php b/sg-commerce-pro/admin/views/settings.php
new file mode 100644
index 0000000..ed9b42f
--- /dev/null
+++ b/sg-commerce-pro/admin/views/settings.php
@@ -0,0 +1,154 @@
+get( SettingsRepository::class );
+
+$log_level = (string) $settings->get( 'log_level', 'info' );
+$log_retention = (int) $settings->get( 'log_retention_days', 30 );
+$pages_per_run = (int) $settings->get( 'sync_pages_per_run', 5 );
+$referral_pct = (float) $settings->get( 'referral_fee_pct', 15.0 );
+$fba_fee = (float) $settings->get( 'fba_pick_pack_fee', 3.00 );
+?>
+
diff --git a/sg-commerce-pro/admin/views/settlements.php b/sg-commerce-pro/admin/views/settlements.php
new file mode 100644
index 0000000..d6c139a
--- /dev/null
+++ b/sg-commerce-pro/admin/views/settlements.php
@@ -0,0 +1,193 @@
+prefix . 'sg_settlements';
+
+$totals_by_type = $wpdb->get_results(
+ "SELECT event_type, COUNT(*) cnt, SUM(amount) total, currency
+ FROM {$table}
+ GROUP BY event_type, currency
+ ORDER BY ABS(SUM(amount)) DESC",
+ ARRAY_A
+);
+
+$net = (float) $wpdb->get_var( "SELECT COALESCE(SUM(amount), 0) FROM {$table}" );
+$event_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+
+$top_skus = $wpdb->get_results(
+ "SELECT sku,
+ SUM(CASE WHEN event_type = 'sale' THEN amount ELSE 0 END) revenue,
+ SUM(CASE WHEN event_type IN ('fba_fee','fba_storage','referral_fee','advertising') THEN amount ELSE 0 END) fees,
+ SUM(amount) net
+ FROM {$table}
+ WHERE sku != ''
+ GROUP BY sku
+ ORDER BY revenue DESC
+ LIMIT 20",
+ ARRAY_A
+);
+
+$recent = $wpdb->get_results(
+ "SELECT * FROM {$table} ORDER BY event_date DESC, id DESC LIMIT 50",
+ ARRAY_A
+);
+
+$settlement_ids = $wpdb->get_col( "SELECT DISTINCT settlement_id FROM {$table} WHERE settlement_id != '' ORDER BY imported_at DESC LIMIT 10" );
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GET_V2_SETTLEMENT_REPORT_DATA_FLAT_FILE_V2
+
+
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/suite.php b/sg-commerce-pro/admin/views/suite.php
new file mode 100644
index 0000000..68736e5
--- /dev/null
+++ b/sg-commerce-pro/admin/views/suite.php
@@ -0,0 +1,25 @@
+
+
diff --git a/sg-commerce-pro/admin/views/theme-bridge.php b/sg-commerce-pro/admin/views/theme-bridge.php
new file mode 100644
index 0000000..1c71d7d
--- /dev/null
+++ b/sg-commerce-pro/admin/views/theme-bridge.php
@@ -0,0 +1,210 @@
+get( Marketplaces::class );
+$products = $container->get( ProductRepository::class );
+
+$theme_slug = get_stylesheet();
+$theme_active = ( 'sevengum' === $theme_slug || 'sevengum' === get_template() );
+
+$theme_posts = get_posts( array(
+ 'post_type' => 'products',
+ 'post_status' => array( 'publish', 'draft' ),
+ 'posts_per_page' => -1,
+ 'orderby' => 'title',
+ 'order' => 'ASC',
+) );
+
+$mapped = 0;
+$unmapped = 0;
+$rows = array();
+foreach ( (array) $theme_posts as $p ) {
+ $sku = (string) get_post_meta( $p->ID, ThemeBridge::META_SKU, true );
+ $market = (string) get_post_meta( $p->ID, ThemeBridge::META_MARKET, true ) ?: $marketplaces->primary();
+ $last = (string) get_post_meta( $p->ID, ThemeBridge::META_LAST_SYNC, true );
+ $stock = (int) get_post_meta( $p->ID, ThemeBridge::META_STOCK_CACHE, true );
+ $price = (string) get_post_meta( $p->ID, '_sevengum_price', true );
+ $theme_mod = (string) get_theme_mod( 'sg_amazon_' . $p->post_name, '' );
+ $amazon_row = '' !== $sku ? $products->find_by_sku( $market, $sku ) : null;
+
+ if ( '' !== $sku ) $mapped++; else $unmapped++;
+
+ $rows[] = array(
+ 'post' => $p,
+ 'sku' => $sku,
+ 'market' => $market,
+ 'last' => $last,
+ 'stock' => $stock,
+ 'price' => $price,
+ 'theme_mod' => $theme_mod,
+ 'row' => $amazon_row,
+ );
+}
+?>
+
+
+
+
+
+
+
+
+
+ ' . esc_html( $theme_slug ) . ''
+ ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ ✓ ' . esc_html__( 'compatible', 'sg-commerce' ) . ''
+ : '⚠ ' . esc_html__( 'incompatible', 'sg-commerce' ) . ' '; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Buy Box price → _sevengum_price post meta
+ Amazon URL → theme_mod['sg_amazon_{slug}']
+ Stock count → _sg_amazon_stock post meta
+
+
+
+
+
+
+
+
+
+
[sg_theme_price slug="cookie"]
+[sg_theme_buy slug="cookie" label="Buy Cookie Gum"]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ post_title ); ?>
+ post_status ); ?>
+
+ post_name ); ?>
+
+
+
+
+
+
+ ⚠
+
+
+
+
+
+
+ —
+ 5 ) : ?>
+
+ 0 ) : ?>
+
+
+ 0
+
+
+
+ —'; ?>
+
+
+
+ ✓
+
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/admin/views/widgets.php b/sg-commerce-pro/admin/views/widgets.php
new file mode 100644
index 0000000..977ebb2
--- /dev/null
+++ b/sg-commerce-pro/admin/views/widgets.php
@@ -0,0 +1,101 @@
+get( ProductRepository::class );
+$result = $products->paginate( array( 'page' => 1, 'per_page' => 200 ) );
+?>
+
+
+
+
+
+
+
+
+
[sg_product sku="SG-COOKIE-01" market="US"]
+
+
+
+ sku (required) —
+ market —
+ style — card (default), compact, default.
+ show — image, name, desc, price, stock, cta.
+
+
+
+
+
()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sg-commerce-pro/assets/css/admin.css b/sg-commerce-pro/assets/css/admin.css
new file mode 100644
index 0000000..5a7f4ac
--- /dev/null
+++ b/sg-commerce-pro/assets/css/admin.css
@@ -0,0 +1,568 @@
+/*
+ * Seven Gum Commerce Pro — admin styles.
+ * Light theme, high-contrast, no invisible headings.
+ */
+
+:root {
+ --sg-bg: #f3f4f6;
+ --sg-surface: #ffffff;
+ --sg-ink: #111827;
+ --sg-ink-soft: #374151;
+ --sg-ink-muted: #6b7280;
+ --sg-ink-faint: #9ca3af;
+ --sg-line: #e5e7eb;
+ --sg-line-strong: #d1d5db;
+ --sg-accent: #0a0a0a;
+ --sg-accent-hover: #262626;
+ --sg-mint: #059669;
+ --sg-mint-bg: #d1fae5;
+ --sg-amber: #b45309;
+ --sg-amber-bg: #fef3c7;
+ --sg-danger: #b91c1c;
+ --sg-danger-bg: #fee2e2;
+ --sg-radius: 12px;
+}
+
+.sg-admin {
+ max-width: 1320px;
+ margin: 20px 20px 40px 0;
+ color: var(--sg-ink);
+ font-size: 14px;
+ line-height: 1.55;
+}
+.sg-admin * { box-sizing: border-box; }
+
+/* HERO */
+.sg-hero {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 28px 0 24px;
+ border-bottom: 1px solid var(--sg-line);
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+}
+.sg-hero h1 {
+ margin: 0 0 6px;
+ font-size: 32px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--sg-ink);
+ line-height: 1.1;
+}
+.sg-hero h1 span {
+ font-weight: 400;
+ color: var(--sg-ink-muted);
+}
+.sg-hero-meta { display: flex; gap: 8px; }
+.sg-subtitle {
+ margin: 0;
+ color: var(--sg-ink-muted);
+ font-size: 14.5px;
+ max-width: 720px;
+}
+
+/* CARD */
+.sg-card {
+ background: var(--sg-surface);
+ border: 1px solid var(--sg-line);
+ border-radius: var(--sg-radius);
+ padding: 24px 28px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.03);
+}
+.sg-card h2 {
+ margin: 0 0 6px;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--sg-ink);
+ letter-spacing: -0.01em;
+}
+.sg-card h3 {
+ margin: 16px 0 8px;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--sg-ink);
+}
+.sg-card p { margin: 0 0 14px; color: var(--sg-ink-soft); }
+.sg-card hr {
+ border: 0;
+ border-top: 1px solid var(--sg-line);
+ margin: 22px 0;
+}
+.sg-muted { color: var(--sg-ink-muted); }
+
+/* GRIDS */
+.sg-grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+ align-items: start;
+}
+@media (max-width: 960px) { .sg-grid-2 { grid-template-columns: 1fr; } }
+
+.sg-grid-4 {
+ display: grid;
+ grid-template-columns: 1fr 2fr 2fr 2fr;
+ gap: 14px;
+ align-items: end;
+ margin-bottom: 14px;
+}
+@media (max-width: 800px) { .sg-grid-4 { grid-template-columns: 1fr 1fr; } }
+
+/* FORMS */
+.sg-admin label {
+ display: block;
+ margin: 14px 0 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--sg-ink-soft);
+}
+.sg-admin label > input,
+.sg-admin label > select,
+.sg-admin label > textarea {
+ display: block;
+ width: 100%;
+ margin-top: 6px;
+ padding: 10px 12px;
+ font-size: 14px;
+ font-family: inherit;
+ color: var(--sg-ink);
+ background: #fff;
+ border: 1px solid var(--sg-line-strong);
+ border-radius: 8px;
+ box-shadow: inset 0 1px 2px rgba(0,0,0,0.03);
+ transition: border-color 0.15s, box-shadow 0.15s;
+ font-weight: 400;
+}
+.sg-admin label > input:focus,
+.sg-admin label > select:focus,
+.sg-admin label > textarea:focus {
+ outline: none;
+ border-color: var(--sg-accent);
+ box-shadow: 0 0 0 3px rgba(10,10,10,0.1);
+}
+.sg-admin label.sg-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+}
+.sg-admin label.sg-inline > select,
+.sg-admin label.sg-inline > input {
+ width: auto; margin: 0; padding: 6px 10px;
+}
+.sg-admin label.sg-inline > input[type="checkbox"] { padding: 0; }
+
+.sg-fieldset { border: 0; padding: 0; margin-top: 20px; }
+.sg-fieldset legend {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--sg-ink-soft);
+ margin-bottom: 10px;
+ padding: 0;
+}
+.sg-market-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
+ gap: 8px;
+}
+.sg-check {
+ display: flex !important;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--sg-surface);
+ border: 1px solid var(--sg-line);
+ border-radius: 8px;
+ margin: 0 !important;
+ cursor: pointer;
+ font-weight: 400 !important;
+ transition: border-color 0.15s;
+}
+.sg-check:hover { border-color: var(--sg-line-strong); }
+.sg-check input[type="checkbox"] { margin: 0; }
+.sg-check span {
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--sg-ink);
+}
+.sg-check em {
+ font-style: normal;
+ color: var(--sg-ink-muted);
+ font-size: 11.5px;
+}
+
+.sg-actions {
+ margin-top: 22px;
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
+/* BUTTONS */
+.sg-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 9px 16px;
+ border: 1px solid var(--sg-line-strong);
+ background: #fff;
+ color: var(--sg-ink);
+ border-radius: 8px;
+ font-size: 13.5px;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+ font-family: inherit;
+}
+.sg-btn:hover { background: #f9fafb; border-color: var(--sg-line-strong); color: var(--sg-ink); }
+.sg-btn-primary {
+ background: var(--sg-accent);
+ color: #fff;
+ border-color: var(--sg-accent);
+}
+.sg-btn-primary:hover { background: var(--sg-accent-hover); color: #fff; border-color: var(--sg-accent-hover); }
+.sg-btn-primary:focus { box-shadow: 0 0 0 3px rgba(10,10,10,0.2); }
+.sg-btn-sm { padding: 5px 10px; font-size: 12px; }
+.sg-btn[disabled], .sg-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.sg-btn-icon {
+ background: transparent;
+ border: 0;
+ color: var(--sg-ink-faint);
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+.sg-btn-icon:hover { color: var(--sg-danger); background: var(--sg-danger-bg); }
+
+.sg-link {
+ color: var(--sg-ink-soft);
+ font-size: 12px;
+ text-decoration: underline;
+ margin-left: 10px;
+}
+
+.sg-select-sm {
+ padding: 4px 8px;
+ font-size: 12px;
+ border: 1px solid var(--sg-line-strong);
+ border-radius: 6px;
+ background: #fff;
+ color: var(--sg-ink);
+}
+
+/* CHIPS */
+.sg-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 9px;
+ background: #f3f4f6;
+ border: 1px solid var(--sg-line);
+ border-radius: 999px;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--sg-ink-soft);
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+}
+.sg-chip-ok { background: var(--sg-mint-bg); border-color: #a7f3d0; color: var(--sg-mint); }
+.sg-chip-warn { background: var(--sg-amber-bg); border-color: #fde68a; color: var(--sg-amber); }
+.sg-chip-err { background: var(--sg-danger-bg); border-color: #fecaca; color: var(--sg-danger); }
+.sg-chip-muted { background: #f3f4f6; border-color: var(--sg-line); color: var(--sg-ink-muted); }
+
+.sg-dot {
+ display: inline-block;
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: var(--sg-ink-faint);
+ margin-right: 6px;
+}
+.sg-dot-ok { background: var(--sg-mint); box-shadow: 0 0 0 2px rgba(5,150,105,0.15); }
+
+/* ONBOARDING */
+.sg-onboarding {
+ background: #fff;
+ border: 1px solid var(--sg-line);
+ border-radius: var(--sg-radius);
+ padding: 28px;
+ margin-bottom: 24px;
+}
+.sg-onboarding > h2 {
+ margin: 0 0 16px;
+ font-size: 20px;
+ font-weight: 600;
+}
+.sg-steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 12px;
+}
+.sg-steps li {
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ gap: 16px;
+ align-items: start;
+ padding: 18px;
+ background: #fafafa;
+ border: 1px solid var(--sg-line);
+ border-radius: 10px;
+ transition: border-color 0.2s, background 0.2s;
+}
+.sg-steps li.active {
+ border-color: var(--sg-accent);
+ background: #fff;
+ box-shadow: 0 0 0 3px rgba(10,10,10,0.04);
+}
+.sg-steps li.done { opacity: 0.75; }
+.sg-steps li.pending { opacity: 0.55; }
+.sg-step-num {
+ width: 36px; height: 36px;
+ border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ background: #fff;
+ border: 1.5px solid var(--sg-line-strong);
+ color: var(--sg-ink-muted);
+ font-size: 15px;
+ font-weight: 700;
+ line-height: 1;
+}
+.sg-steps li.active .sg-step-num { border-color: var(--sg-accent); color: var(--sg-accent); }
+.sg-steps li.done .sg-step-num { background: var(--sg-mint); border-color: var(--sg-mint); color: #fff; }
+.sg-steps li > div h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; }
+.sg-steps li > div p { margin: 0 0 10px; color: var(--sg-ink-muted); font-size: 13px; }
+.sg-steps-compact li { padding: 10px 0; background: transparent; border: 0; border-bottom: 1px dashed var(--sg-line); }
+.sg-steps-compact li:last-child { border-bottom: 0; }
+.sg-steps-compact .sg-step-num { width: 26px; height: 26px; font-size: 12px; }
+.sg-steps-compact li > div { padding-top: 2px; }
+.sg-steps-compact li > div * { font-size: 13px; }
+
+/* STATS */
+.sg-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-bottom: 24px;
+}
+@media (max-width: 900px) { .sg-stats { grid-template-columns: 1fr 1fr; } }
+.sg-stat {
+ background: var(--sg-surface);
+ border: 1px solid var(--sg-line);
+ border-radius: var(--sg-radius);
+ padding: 20px 24px;
+}
+.sg-stat-label {
+ margin: 0 0 6px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sg-ink-muted);
+ font-weight: 600;
+}
+.sg-stat-value {
+ margin: 0;
+ font-size: 32px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--sg-ink);
+ letter-spacing: -0.02em;
+}
+.sg-stat-value-text { font-size: 16px; font-weight: 500; }
+.sg-stat-meta {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--sg-ink-muted);
+}
+
+/* TABLES */
+.sg-table-wrap { overflow-x: auto; margin: 10px -4px 0; }
+.sg-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: 13px;
+}
+.sg-table th {
+ text-align: left;
+ padding: 10px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sg-ink-muted);
+ border-bottom: 1px solid var(--sg-line);
+ background: #fafafa;
+}
+.sg-table td {
+ padding: 12px;
+ border-bottom: 1px solid var(--sg-line);
+ vertical-align: middle;
+ color: var(--sg-ink);
+}
+.sg-table tr:last-child td { border-bottom: 0; }
+.sg-table tbody tr:hover td { background: #fafafa; }
+.sg-mono {
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 12.5px;
+}
+.sg-row-actions { white-space: nowrap; }
+.sg-row-actions form { margin-right: 4px; }
+.sg-row-actions .sg-select-sm { margin-right: 2px; }
+.sg-desc-row td {
+ background: #fafafa;
+ font-size: 12.5px;
+ color: var(--sg-ink-soft);
+ padding: 10px 20px;
+}
+.sg-desc-row div { margin-bottom: 4px; }
+.sg-desc-row strong { color: var(--sg-ink); font-weight: 600; }
+
+/* SYNC + FILTER BAR */
+.sg-syncbar, .sg-filterbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.sg-syncbar > div:first-child { flex: 1; min-width: 200px; }
+
+/* PAGINATION */
+.sg-pagination {
+ margin: 16px 0 0;
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+}
+.sg-pagination .page-numbers {
+ display: inline-block;
+ padding: 6px 12px;
+ border: 1px solid var(--sg-line);
+ border-radius: 6px;
+ font-size: 13px;
+ color: var(--sg-ink);
+ text-decoration: none;
+ background: #fff;
+}
+.sg-pagination .page-numbers.current {
+ background: var(--sg-accent); color: #fff; border-color: var(--sg-accent);
+}
+.sg-pagination .page-numbers:hover { background: #f9fafb; }
+.sg-pagination .page-numbers.current:hover { background: var(--sg-accent); }
+
+/* EMPTY STATE */
+.sg-empty {
+ padding: 40px 20px;
+ text-align: center;
+ border: 1px dashed var(--sg-line-strong);
+ border-radius: 10px;
+ color: var(--sg-ink-muted);
+ background: #fafafa;
+}
+.sg-empty p { margin: 0; }
+
+/* CREDENTIALS SUMMARY */
+.sg-creds-summary {
+ display: grid;
+ gap: 6px;
+ padding: 14px 16px;
+ background: var(--sg-mint-bg);
+ border: 1px solid #a7f3d0;
+ border-radius: 8px;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 12.5px;
+ color: var(--sg-mint);
+ margin-bottom: 16px;
+}
+
+/* CODE */
+.sg-code {
+ display: block;
+ padding: 14px 16px;
+ background: #1f2937;
+ color: #e5e7eb;
+ border-radius: 8px;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 12.5px;
+ line-height: 1.6;
+ overflow-x: auto;
+ white-space: pre;
+ margin: 10px 0 18px;
+ user-select: all;
+}
+.sg-code-inline {
+ display: inline-block;
+ padding: 6px 10px;
+ background: #f3f4f6;
+ color: var(--sg-ink);
+ border: 1px solid var(--sg-line);
+ border-radius: 6px;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 12px;
+ width: 100%;
+ user-select: all;
+ word-break: break-all;
+}
+
+.sg-attr-list {
+ margin: 8px 0 0;
+ padding: 0;
+ list-style: none;
+}
+.sg-attr-list li {
+ padding: 8px 0;
+ border-bottom: 1px dashed var(--sg-line);
+ font-size: 13.5px;
+ color: var(--sg-ink-soft);
+}
+.sg-attr-list li:last-child { border-bottom: 0; }
+.sg-attr-list code {
+ background: #f3f4f6;
+ padding: 2px 7px;
+ border-radius: 4px;
+ font-size: 12px;
+ margin-right: 4px;
+ color: var(--sg-ink);
+}
+
+/* ALERTS FEED */
+.sg-alerts {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.sg-alert {
+ padding: 12px 14px;
+ border-left: 3px solid var(--sg-line-strong);
+ background: #fafafa;
+ border-radius: 6px;
+ margin-bottom: 8px;
+}
+.sg-alert--info { border-left-color: var(--sg-mint); }
+.sg-alert--warning { border-left-color: var(--sg-amber); background: #fffbeb; }
+.sg-alert--error { border-left-color: var(--sg-danger); background: #fef2f2; }
+.sg-alert--critical { border-left-color: var(--sg-danger); background: #fef2f2; }
+.sg-alert.is-read { opacity: 0.6; }
+.sg-alert p { margin: 6px 0 0; font-size: 13px; color: var(--sg-ink-soft); }
+
+/* HORIZONTAL BAR (for analytics) */
+.sg-bar {
+ width: 100%;
+ height: 8px;
+ background: #f3f4f6;
+ border-radius: 999px;
+ overflow: hidden;
+}
+.sg-bar-fill {
+ height: 100%;
+ background: var(--sg-accent);
+ border-radius: 999px;
+}
diff --git a/sg-commerce-pro/assets/css/compare.css b/sg-commerce-pro/assets/css/compare.css
new file mode 100644
index 0000000..b1609de
--- /dev/null
+++ b/sg-commerce-pro/assets/css/compare.css
@@ -0,0 +1,212 @@
+/*
+ * Seven Gum Compare — mobile-first comparison table.
+ *
+ * Strategy: emit BOTH the desktop and the mobile .sg-compare__cards
+ * in the HTML. Hide one or the other via media query. Keeps JS out of the
+ * layout entirely — pure CSS.
+ *
+ * Breakpoint: 768px.
+ */
+.sg-compare {
+ font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ color: #1a1a1a;
+ margin: 28px 0;
+ box-sizing: border-box;
+}
+.sg-compare *, .sg-compare *::before, .sg-compare *::after { box-sizing: border-box; }
+
+.sg-compare__title {
+ font-size: 22px;
+ font-weight: 700;
+ margin: 0 0 16px;
+ color: #0a0a0a;
+ letter-spacing: -0.01em;
+}
+
+.sg-compare--error {
+ padding: 14px 18px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ border-radius: 8px;
+ font-size: 14px;
+}
+
+/* By default, show cards, hide table. Desktop media query inverts this. */
+.sg-compare__table-wrap { display: none; }
+.sg-compare__cards { display: grid; gap: 16px; }
+
+/* ---------------- MOBILE CARDS ---------------- */
+.sg-compare__card {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 14px;
+ padding: 20px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
+}
+.sg-compare__card-image {
+ margin: -4px 0 14px;
+ text-align: center;
+}
+.sg-compare__card-image img {
+ max-width: 140px;
+ height: auto;
+ border-radius: 10px;
+}
+.sg-compare__card-title {
+ margin: 0 0 12px;
+ font-size: 18px;
+ font-weight: 700;
+ color: #0a0a0a;
+ line-height: 1.25;
+}
+.sg-compare__dl {
+ margin: 0 0 16px;
+ padding: 0;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+}
+.sg-compare__dl dt {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #6b7280;
+ margin: 0 0 2px;
+}
+.sg-compare__dl dd {
+ margin: 0 0 0;
+ font-size: 14px;
+ color: #1a1a1a;
+ line-height: 1.45;
+}
+.sg-compare__cta {
+ display: block;
+ padding: 12px 16px;
+ background: #0a0a0a;
+ color: #fff !important;
+ text-decoration: none;
+ text-align: center;
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ transition: background 0.2s ease;
+}
+.sg-compare__cta:hover { background: #262626; color: #fff !important; }
+
+/* ---------------- DESKTOP TABLE ---------------- */
+@media (min-width: 768px) {
+ .sg-compare__table-wrap {
+ display: block;
+ overflow-x: auto;
+ border-radius: 14px;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
+ }
+ .sg-compare__cards { display: none; }
+
+ .sg-compare__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+ }
+ .sg-compare__table th,
+ .sg-compare__table td {
+ padding: 14px 16px;
+ text-align: left;
+ vertical-align: middle;
+ border-bottom: 1px solid #f3f4f6;
+ }
+ .sg-compare__table tr:last-child td,
+ .sg-compare__table tr:last-child th { border-bottom: 0; }
+ .sg-compare__table thead th {
+ background: #fafafa;
+ border-bottom: 1px solid #e5e7eb;
+ font-weight: 600;
+ }
+ .sg-compare__table tbody th {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #6b7280;
+ font-weight: 700;
+ background: #fafafa;
+ width: 140px;
+ }
+ .sg-compare__col-head {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ text-align: center;
+ }
+ .sg-compare__col-head img {
+ max-width: 80px;
+ height: auto;
+ border-radius: 8px;
+ }
+ .sg-compare__col-head strong {
+ font-size: 14px;
+ font-weight: 700;
+ color: #0a0a0a;
+ line-height: 1.3;
+ }
+}
+
+/* Shared — works in both layouts */
+.sg-compare__price {
+ font-size: 16px;
+ font-weight: 700;
+ color: #0a0a0a;
+}
+.sg-compare__price small {
+ font-size: 11px;
+ font-weight: 500;
+ color: #6b7280;
+ margin-left: 2px;
+}
+.sg-compare__badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ white-space: nowrap;
+}
+.sg-compare__badge--ok { background: rgba(22, 163, 74, 0.12); color: #16a34a; }
+.sg-compare__badge--out { background: rgba(220, 38, 38, 0.12); color: #dc2626; }
+
+.sg-compare__cta-small {
+ display: inline-block;
+ padding: 6px 12px;
+ background: #0a0a0a;
+ color: #fff !important;
+ text-decoration: none;
+ border-radius: 6px;
+ font-size: 12.5px;
+ font-weight: 600;
+}
+.sg-compare__cta-small:hover { background: #262626; color: #fff !important; }
+
+/* Injected link styles (from ContentInjector) */
+.sg-injected-link,
+.sg-injected-tooltip {
+ color: #0a0a0a;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ text-underline-offset: 3px;
+ text-decoration-color: rgba(10,10,10,0.35);
+}
+.sg-injected-link:hover,
+.sg-injected-tooltip:hover {
+ text-decoration-style: solid;
+ text-decoration-color: #0a0a0a;
+}
+.sg-injected-widget {
+ margin: 12px 0;
+}
diff --git a/sg-commerce-pro/assets/css/suite.css b/sg-commerce-pro/assets/css/suite.css
new file mode 100644
index 0000000..4787b96
--- /dev/null
+++ b/sg-commerce-pro/assets/css/suite.css
@@ -0,0 +1,240 @@
+/*
+ * Amazon Seller Suite — app styles. Everything is scoped under .sgs so the
+ * rest of wp-admin is untouched.
+ */
+
+.sgs-wrap { margin: 0 20px 0 0; }
+.sgs {
+ --ink: #0f172a;
+ --ink-2: #334155;
+ --muted: #64748b;
+ --faint: #94a3b8;
+ --line: #e2e8f0;
+ --line-2: #cbd5e1;
+ --bg: #f1f5f9;
+ --card: #ffffff;
+ --accent: #0f172a;
+ --blue: #2563eb;
+ --blue-bg: #dbeafe;
+ --green: #059669;
+ --green-bg: #d1fae5;
+ --amber: #b45309;
+ --amber-bg: #fef3c7;
+ --red: #dc2626;
+ --red-bg: #fee2e2;
+ --violet: #7c3aed;
+ --violet-bg: #ede9fe;
+ --radius: 12px;
+ --shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 1px 3px rgba(15, 23, 42, .06);
+ color: var(--ink);
+ font-size: 13.5px;
+ line-height: 1.5;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ font-variant-numeric: tabular-nums;
+ max-width: 1480px;
+ padding-bottom: 60px;
+}
+.sgs *, .sgs *::before, .sgs *::after { box-sizing: border-box; }
+.sgs a { color: var(--blue); text-decoration: none; }
+.sgs a:hover { text-decoration: underline; }
+.sgs h2, .sgs h3, .sgs h4 { color: var(--ink); margin: 0; letter-spacing: -.01em; }
+.sgs h2 { font-size: 18px; font-weight: 650; }
+.sgs h3 { font-size: 14.5px; font-weight: 650; }
+.sgs p { margin: 0; }
+.sgs code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
+
+.sgs-boot { display: flex; gap: 10px; align-items: center; padding: 60px; color: var(--muted); }
+.sgs-spinner { width: 18px; height: 18px; border: 2px solid var(--line-2); border-top-color: var(--ink); border-radius: 50%; animation: sgs-spin .7s linear infinite; display: inline-block; }
+@keyframes sgs-spin { to { transform: rotate(360deg); } }
+
+/* Header */
+.sgs-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 0 12px; flex-wrap: wrap; }
+.sgs-brand { display: flex; align-items: center; gap: 12px; }
+.sgs-logo { width: 36px; height: 36px; border-radius: 10px; background: var(--ink); color: #fff; display: grid; place-items: center; font-weight: 800; font-size: 17px; }
+.sgs-brand h1 { font-size: 20px; font-weight: 700; margin: 0; padding: 0; line-height: 1.2; color: var(--ink); }
+.sgs-brand small { display: block; color: var(--muted); font-size: 12px; }
+.sgs-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.sgs-pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--bg); color: var(--ink-2); white-space: nowrap; }
+.sgs-pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
+.sgs-pill.ok .dot { background: var(--green); }
+.sgs-pill.warn .dot { background: #f59e0b; }
+.sgs-pill.bad .dot { background: var(--red); }
+
+/* Nav */
+.sgs-nav { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 18px; overflow-x: auto; scrollbar-width: thin; }
+.sgs-nav a { padding: 10px 12px; color: var(--muted); font-weight: 600; font-size: 13px; border-bottom: 2px solid transparent; white-space: nowrap; text-decoration: none !important; }
+.sgs-nav a:hover { color: var(--ink); }
+.sgs-nav a.on { color: var(--ink); border-bottom-color: var(--ink); }
+.sgs-nav a .badge { background: var(--red); color: #fff; border-radius: 999px; padding: 0 6px; font-size: 10.5px; margin-left: 4px; }
+
+/* Banners */
+.sgs-banner { display: flex; gap: 12px; align-items: center; justify-content: space-between; padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px; flex-wrap: wrap; }
+.sgs-banner.demo { background: var(--violet-bg); color: #4c1d95; }
+.sgs-banner.info { background: var(--blue-bg); color: #1e3a8a; }
+.sgs-banner.warn { background: var(--amber-bg); color: #78350f; }
+.sgs-banner.bad { background: var(--red-bg); color: #7f1d1d; }
+
+/* Layout */
+.sgs-page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
+.sgs-page-head p { color: var(--muted); margin-top: 2px; max-width: 760px; }
+.sgs-grid { display: grid; gap: 14px; }
+.sgs-grid.k5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+.sgs-grid.k4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.sgs-grid.k3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.sgs-grid.k2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.sgs-grid.k21 { grid-template-columns: 2fr 1fr; }
+.sgs-grid.k12 { grid-template-columns: 1fr 2fr; }
+@media (max-width: 1280px) { .sgs-grid.k5 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+@media (max-width: 1100px) { .sgs-grid.k4, .sgs-grid.k3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .sgs-grid.k21, .sgs-grid.k12, .sgs-grid.k2 { grid-template-columns: 1fr; } }
+@media (max-width: 700px) { .sgs-grid.k5, .sgs-grid.k4, .sgs-grid.k3 { grid-template-columns: 1fr; } }
+.sgs-stack > * + * { margin-top: 14px; }
+
+.sgs-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; min-width: 0; }
+.sgs-card.flush { padding: 0; }
+.sgs-card.flush > .sgs-card-head { padding: 14px 18px 10px; }
+.sgs-card-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
+.sgs-card-head p { color: var(--muted); font-size: 12.5px; }
+
+/* KPI tiles */
+.sgs-kpi { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); min-width: 0; }
+.sgs-kpi .lbl { color: var(--muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
+.sgs-kpi .val { font-size: 22px; font-weight: 700; margin-top: 4px; letter-spacing: -.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.sgs-kpi .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
+.sgs-kpi.tone-green .val { color: var(--green); }
+.sgs-kpi.tone-red .val { color: var(--red); }
+.sgs-kpi.tone-amber .val { color: var(--amber); }
+.sgs-period { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); }
+.sgs-period .head { display: flex; justify-content: space-between; align-items: baseline; }
+.sgs-period .head b { font-size: 13px; }
+.sgs-period .head span { color: var(--muted); font-size: 11.5px; }
+.sgs-period .big { font-size: 22px; font-weight: 700; margin: 6px 0 2px; letter-spacing: -.02em; }
+.sgs-period dl { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; margin: 8px 0 0; font-size: 12.5px; }
+.sgs-period dt { color: var(--muted); }
+.sgs-period dd { margin: 0; text-align: right; font-weight: 600; }
+.sgs-period.hl { background: var(--ink); color: #fff; border-color: var(--ink); }
+.sgs-period.hl .head span, .sgs-period.hl dt { color: #cbd5e1; }
+.sgs-period.hl .head b { color: #fff; }
+.delta { font-size: 11.5px; font-weight: 700; padding: 1px 6px; border-radius: 999px; margin-left: 6px; vertical-align: middle; }
+.delta.up { background: var(--green-bg); color: var(--green); }
+.delta.down { background: var(--red-bg); color: var(--red); }
+
+/* Buttons & forms */
+.sgs .btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line-2); background: #fff; color: var(--ink); padding: 6px 12px; border-radius: 8px; font-weight: 600; font-size: 12.5px; cursor: pointer; line-height: 1.4; white-space: nowrap; }
+.sgs .btn:hover { border-color: var(--ink-2); }
+.sgs .btn.primary { background: var(--ink); border-color: var(--ink); color: #fff; }
+.sgs .btn.primary:hover { background: #1e293b; }
+.sgs .btn.green { background: var(--green); border-color: var(--green); color: #fff; }
+.sgs .btn.danger { color: var(--red); border-color: #fecaca; }
+.sgs .btn.ghost { border-color: transparent; background: transparent; }
+.sgs .btn.sm { padding: 3px 8px; font-size: 12px; }
+.sgs .btn[disabled] { opacity: .5; cursor: not-allowed; }
+.sgs .btns { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
+.sgs input[type=text], .sgs input[type=number], .sgs input[type=date], .sgs input[type=email], .sgs input[type=password], .sgs select, .sgs textarea {
+ border: 1px solid var(--line-2); border-radius: 8px; padding: 5px 9px; font-size: 13px; background: #fff; color: var(--ink); min-height: 32px; max-width: 100%;
+}
+.sgs input:focus, .sgs select:focus, .sgs textarea:focus { outline: 2px solid var(--blue-bg); border-color: var(--blue); box-shadow: none; }
+.sgs textarea { width: 100%; min-height: 80px; font-family: inherit; }
+.sgs .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+.sgs .field label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
+.sgs .field small { color: var(--muted); font-size: 11.5px; }
+.sgs .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px 16px; }
+.sgs .check { display: flex; gap: 8px; align-items: center; font-weight: 600; font-size: 12.5px; }
+.sgs .seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; background: #fff; }
+.sgs .seg button { border: 0; background: transparent; padding: 5px 10px; font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; border-right: 1px solid var(--line); }
+.sgs .seg button:last-child { border-right: 0; }
+.sgs .seg button.on { background: var(--ink); color: #fff; }
+
+/* Tables */
+.sgs-table-tools { display: flex; gap: 8px; align-items: center; justify-content: space-between; padding: 0 18px 10px; flex-wrap: wrap; }
+.sgs-table-tools input[type=text] { min-width: 220px; }
+.sgs-table-wrap { overflow-x: auto; }
+.sgs table.t { width: 100%; border-collapse: collapse; font-size: 12.8px; }
+.sgs table.t th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; padding: 8px 10px; border-bottom: 1px solid var(--line); background: #f8fafc; white-space: nowrap; position: sticky; top: 0; cursor: pointer; user-select: none; }
+.sgs table.t th.r, .sgs table.t td.r { text-align: right; }
+.sgs table.t th.c, .sgs table.t td.c { text-align: center; }
+.sgs table.t th .arrow { color: var(--ink); margin-left: 3px; }
+.sgs table.t td { padding: 8px 10px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
+.sgs table.t tr:hover td { background: #f8fafc; }
+.sgs table.t tr.click { cursor: pointer; }
+.sgs table.t tfoot td { font-weight: 700; background: #f8fafc; border-top: 1px solid var(--line); }
+.sgs table.t .title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
+.sgs table.t .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
+.sgs .pos { color: var(--green); }
+.sgs .neg { color: var(--red); }
+.sgs .muted { color: var(--muted); }
+.sgs .small { font-size: 12px; }
+.sgs-pager { display: flex; gap: 6px; justify-content: flex-end; align-items: center; padding: 10px 18px; color: var(--muted); font-size: 12px; }
+.sgs-empty { padding: 36px 18px; text-align: center; color: var(--muted); }
+.sgs-empty b { display: block; color: var(--ink); font-size: 14px; margin-bottom: 4px; }
+
+/* Badges */
+.sgs .b { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; white-space: nowrap; background: var(--bg); color: var(--ink-2); }
+.sgs .b.green { background: var(--green-bg); color: #065f46; }
+.sgs .b.red { background: var(--red-bg); color: #991b1b; }
+.sgs .b.amber { background: var(--amber-bg); color: #92400e; }
+.sgs .b.blue { background: var(--blue-bg); color: #1e40af; }
+.sgs .b.violet { background: var(--violet-bg); color: #5b21b6; }
+.sgs .b.dark { background: var(--ink); color: #fff; }
+.sgs .grade { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; font-weight: 800; color: #fff; }
+.sgs .grade.A { background: var(--green); } .sgs .grade.B { background: #16a34a; } .sgs .grade.C { background: #d97706; } .sgs .grade.D { background: #ea580c; } .sgs .grade.F { background: var(--red); }
+
+/* Bars & meters */
+.sgs .meter { height: 6px; background: var(--bg); border-radius: 999px; overflow: hidden; min-width: 60px; }
+.sgs .meter i { display: block; height: 100%; background: var(--ink); border-radius: 999px; }
+.sgs .meter.green i { background: var(--green); } .sgs .meter.amber i { background: #f59e0b; } .sgs .meter.red i { background: var(--red); }
+.sgs .wf { display: grid; grid-template-columns: 130px 1fr 110px; gap: 6px 10px; align-items: center; font-size: 12.8px; }
+.sgs .wf .bar { height: 14px; border-radius: 4px; background: var(--bg); position: relative; }
+.sgs .wf .bar i { position: absolute; top: 0; bottom: 0; border-radius: 4px; }
+.sgs .wf .v { text-align: right; font-weight: 600; }
+.sgs .wf .total { font-weight: 800; }
+
+/* Charts */
+.sgs-chart { position: relative; width: 100%; }
+.sgs-chart svg { display: block; width: 100%; overflow: visible; }
+.sgs-chart .axis text { fill: var(--faint); font-size: 10.5px; }
+.sgs-chart .grid line { stroke: #eef2f7; }
+.sgs-chart .tip { position: absolute; pointer-events: none; background: var(--ink); color: #fff; padding: 7px 10px; border-radius: 8px; font-size: 12px; box-shadow: 0 6px 16px rgba(0,0,0,.18); white-space: nowrap; z-index: 5; transform: translate(-50%, -110%); }
+.sgs-chart .tip b { display: block; margin-bottom: 2px; }
+.sgs-chart .tip i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
+.sgs-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); }
+.sgs-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
+.sgs .spark { display: inline-block; vertical-align: middle; }
+
+/* Lists */
+.sgs .list { list-style: none; margin: 0; padding: 0; }
+.sgs .list li { display: flex; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid #f1f5f9; align-items: center; }
+.sgs .list li:last-child { border-bottom: 0; }
+.sgs .action-card { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; cursor: pointer; text-decoration: none !important; color: var(--ink) !important; }
+.sgs .action-card:hover { border-color: var(--ink-2); }
+.sgs .action-card .ic { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; font-size: 16px; flex: 0 0 34px; }
+.sgs .action-card b { display: block; font-size: 13px; }
+.sgs .action-card span { color: var(--muted); font-size: 12px; }
+.sgs .ic.red { background: var(--red-bg); } .sgs .ic.amber { background: var(--amber-bg); } .sgs .ic.green { background: var(--green-bg); } .sgs .ic.blue { background: var(--blue-bg); } .sgs .ic.violet { background: var(--violet-bg); }
+
+/* Drawer & modal */
+.sgs-drawer-bg { position: fixed; inset: 0; background: rgba(15, 23, 42, .35); z-index: 99990; }
+.sgs-drawer { position: fixed; top: 32px; right: 0; bottom: 0; width: min(760px, 96vw); background: #fff; z-index: 99991; box-shadow: -10px 0 30px rgba(0,0,0,.15); display: flex; flex-direction: column; }
+.sgs-drawer header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); gap: 10px; }
+.sgs-drawer .body { overflow-y: auto; padding: 18px 20px 40px; flex: 1; }
+.sgs-drawer .body > * + * { margin-top: 14px; }
+@media (max-width: 782px) { .sgs-drawer { top: 46px; } }
+
+/* Toast */
+.sgs-toasts { position: fixed; right: 20px; bottom: 20px; z-index: 99999; display: flex; flex-direction: column; gap: 8px; }
+.sgs-toast { background: var(--ink); color: #fff; padding: 10px 14px; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.2); font-size: 13px; max-width: 420px; animation: sgs-in .2s ease; }
+.sgs-toast.bad { background: var(--red); }
+.sgs-toast.ok { background: var(--green); }
+@keyframes sgs-in { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }
+
+/* Findings */
+.sgs .finding { display: flex; gap: 8px; padding: 7px 0; border-bottom: 1px dashed var(--line); font-size: 12.8px; }
+.sgs .finding:last-child { border-bottom: 0; }
+.sgs .pre { white-space: pre-wrap; background: #f8fafc; border: 1px solid var(--line); border-radius: 8px; padding: 12px; font-size: 12.5px; font-family: inherit; }
+.sgs .kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 12px; font-size: 12.8px; }
+.sgs .kv dt { color: var(--muted); }
+.sgs .kv dd { margin: 0; word-break: break-word; }
+.sgs .skeleton { background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 50%, #eef2f7 75%); background-size: 200% 100%; animation: sgs-sk 1.2s infinite; border-radius: 10px; min-height: 90px; }
+@keyframes sgs-sk { to { background-position: -200% 0; } }
+.sgs .help { color: var(--muted); font-size: 12px; }
+.sgs .divider { height: 1px; background: var(--line); margin: 14px 0; }
+.sgs img.thumb { width: 40px; height: 40px; object-fit: contain; border-radius: 6px; background: #fff; border: 1px solid var(--line); }
diff --git a/sg-commerce-pro/assets/css/widget.css b/sg-commerce-pro/assets/css/widget.css
new file mode 100644
index 0000000..f2c389c
--- /dev/null
+++ b/sg-commerce-pro/assets/css/widget.css
@@ -0,0 +1,112 @@
+/*
+ * Seven Gum Commerce — frontend widget styles.
+ * Self-contained, theme-agnostic.
+ */
+.sg-widget {
+ font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ box-sizing: border-box;
+ color: #1a1a1a;
+ max-width: 420px;
+ margin: 24px 0;
+}
+.sg-widget *, .sg-widget *::before, .sg-widget *::after { box-sizing: border-box; }
+
+.sg-widget--card {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 16px;
+ padding: 20px;
+ box-shadow: 0 4px 18px rgba(0,0,0,0.06);
+ display: grid;
+ grid-template-columns: 110px 1fr;
+ gap: 18px;
+ align-items: center;
+}
+.sg-widget--default { background: #fff; padding: 16px 0; }
+.sg-widget--compact {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ font-size: 14px;
+}
+.sg-widget--compact .sg-widget__image,
+.sg-widget--compact .sg-widget__desc { display: none; }
+
+.sg-widget__image img {
+ width: 100%;
+ height: auto;
+ border-radius: 10px;
+ display: block;
+}
+
+.sg-widget__name {
+ margin: 0 0 6px;
+ font-size: 17px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: #0a0a0a;
+}
+.sg-widget__desc {
+ margin: 0 0 12px;
+ font-size: 13.5px;
+ line-height: 1.5;
+ color: #4b5563;
+}
+.sg-widget__meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+.sg-widget__price {
+ font-size: 18px;
+ font-weight: 700;
+ color: #0a0a0a;
+}
+.sg-widget__price small {
+ font-size: 11px;
+ font-weight: 500;
+ color: #6b7280;
+ margin-left: 2px;
+}
+.sg-widget__stock {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 3px 9px;
+ border-radius: 999px;
+}
+.sg-widget__stock--ok { background: rgba(22, 163, 74, 0.1); color: #16a34a; }
+.sg-widget__stock--out { background: rgba(220, 38, 38, 0.1); color: #dc2626; }
+
+.sg-widget__cta {
+ display: inline-block;
+ padding: 10px 18px;
+ background: #0a0a0a;
+ color: #fff !important;
+ text-decoration: none;
+ border-radius: 8px;
+ font-size: 13.5px;
+ font-weight: 600;
+ transition: background 0.2s ease;
+}
+.sg-widget__cta:hover { background: #262626; color: #fff !important; }
+
+.sg-widget--error {
+ padding: 12px 16px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+@media (max-width: 480px) {
+ .sg-widget--card { grid-template-columns: 1fr; }
+}
diff --git a/sg-commerce-pro/assets/js/admin.js b/sg-commerce-pro/assets/js/admin.js
new file mode 100644
index 0000000..1b3cc76
--- /dev/null
+++ b/sg-commerce-pro/assets/js/admin.js
@@ -0,0 +1,11 @@
+/* Seven Gum Commerce — admin JS enhancements (minimal). */
+(function() {
+ // Auto-dismiss flash notices after 6 seconds.
+ setTimeout(function() {
+ var notices = document.querySelectorAll('.notice.is-dismissible');
+ notices.forEach(function(n) { n.style.transition = 'opacity 0.4s'; n.style.opacity = '0'; });
+ setTimeout(function() {
+ notices.forEach(function(n) { n.style.display = 'none'; });
+ }, 400);
+ }, 6000);
+})();
diff --git a/sg-commerce-pro/assets/js/suite.js b/sg-commerce-pro/assets/js/suite.js
new file mode 100644
index 0000000..d27d386
--- /dev/null
+++ b/sg-commerce-pro/assets/js/suite.js
@@ -0,0 +1,1516 @@
+/*!
+ * Amazon Seller Suite — wp-admin single-page app.
+ * Vanilla JS (no build step), talks to /wp-json/sg-commerce/v1/suite/*.
+ */
+(function () {
+ 'use strict';
+
+ var CFG = window.SGSuite || {};
+ var root = document.getElementById('sgs-app');
+ if (!root) { return; }
+
+ /* ================================================================== */
+ /* State */
+ /* ================================================================== */
+
+ var LS_KEY = 'sgsuite.v1';
+ var saved = {};
+ try { saved = JSON.parse(window.localStorage.getItem(LS_KEY) || '{}') || {}; } catch (e) { saved = {}; }
+
+ var state = {
+ market: saved.market || CFG.primary || 'US',
+ preset: saved.preset || '30',
+ from: null,
+ to: null,
+ demo: !!CFG.demo,
+ connected: !!CFG.connected,
+ ads: !!CFG.ads,
+ unread: 0
+ };
+
+ function persist() {
+ try { window.localStorage.setItem(LS_KEY, JSON.stringify({ market: state.market, preset: state.preset })); } catch (e) { /* private mode */ }
+ }
+
+ function marketInfo() {
+ var m = (CFG.markets || []).filter(function (x) { return x.code === state.market; })[0];
+ return m || { code: state.market, currency: 'USD', tld: 'com' };
+ }
+
+ /* ================================================================== */
+ /* Utilities */
+ /* ================================================================== */
+
+ function h(tag, attrs) {
+ var el = document.createElement(tag);
+ if (attrs) {
+ Object.keys(attrs).forEach(function (k) {
+ var v = attrs[k];
+ if (v === null || v === undefined || v === false) { return; }
+ if (k === 'class') { el.className = v; }
+ else if (k === 'style' && typeof v === 'object') { Object.assign(el.style, v); }
+ else if (k.indexOf('on') === 0 && typeof v === 'function') { el.addEventListener(k.slice(2), v); }
+ else if (v === true) { el.setAttribute(k, ''); }
+ else { el.setAttribute(k, v); }
+ });
+ }
+ for (var i = 2; i < arguments.length; i++) { add(el, arguments[i]); }
+ return el;
+ }
+ function add(el, c) {
+ if (c === null || c === undefined || c === false) { return; }
+ if (Array.isArray(c)) { c.forEach(function (x) { add(el, x); }); return; }
+ if (typeof c !== 'object') { el.appendChild(document.createTextNode(String(c))); return; } // text, never markup
+ if (c instanceof Node) { el.appendChild(c); }
+ }
+ /** Return the element of `allowed` equal to `value` (a fresh, trusted string), else `fallback`. */
+ function pick(allowed, value, fallback) {
+ for (var i = 0; i < allowed.length; i++) {
+ if (allowed[i] === value) { return allowed[i]; }
+ }
+ return fallback;
+ }
+ function clear(el) { while (el.firstChild) { el.removeChild(el.firstChild); } return el; }
+
+ function num(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; }
+ function money(v, digits) {
+ if (v === null || v === undefined || v === '') { return '—'; }
+ if (typeof digits !== 'number') { digits = undefined; } // table fmt() passes the row as 2nd arg
+ var cur = marketInfo().currency || 'USD';
+ try {
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur, minimumFractionDigits: digits === undefined ? 2 : digits, maximumFractionDigits: digits === undefined ? 2 : digits }).format(num(v));
+ } catch (e) { return cur + ' ' + num(v).toFixed(2); }
+ }
+ function money0(v) { return money(v, 0); }
+ function int(v) { if (v === null || v === undefined || v === '') { return '—'; } return Math.round(num(v)).toLocaleString(); }
+ function pct(v, d) { if (v === null || v === undefined || v === '') { return '—'; } return num(v).toFixed(d === undefined ? 1 : d) + '%'; }
+ function signed(v, fn) { var s = (fn || money)(v); return num(v) > 0 ? '+' + s : s; }
+ function tone(v) { return num(v) > 0 ? 'pos' : (num(v) < 0 ? 'neg' : ''); }
+ function shortDate(d) { if (!d) { return '—'; } var x = new Date(String(d).replace(' ', 'T') + (String(d).length <= 10 ? 'T00:00:00' : '')); return isNaN(x) ? d : x.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
+ function dateTime(d) { if (!d) { return '—'; } var x = new Date(String(d).replace(' ', 'T') + 'Z'); return isNaN(x) ? d : x.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }
+ function ymd(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
+ function addDays(s, n) { var d = new Date(s + 'T00:00:00'); d.setDate(d.getDate() + n); return ymd(d); }
+ function amazonUrl(asin) { return 'https://www.amazon.' + (marketInfo().tld || 'com') + '/dp/' + encodeURIComponent(asin); }
+ function delta(cur, prev, invert) {
+ cur = num(cur); prev = num(prev);
+ if (!prev) { return null; }
+ var d = (cur - prev) / Math.abs(prev) * 100;
+ var good = invert ? d < 0 : d > 0;
+ return h('span', { class: 'delta ' + (good ? 'up' : 'down') }, (d > 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1) + '%');
+ }
+
+ function rangeParams() {
+ var today = ymd(new Date());
+ if (state.preset === 'custom' && state.from && state.to) { return { from: state.from, to: state.to }; }
+ if (state.preset === 'mtd') { return { from: today.slice(0, 8) + '01', to: today }; }
+ if (state.preset === 'lm') {
+ var d = new Date(); d.setDate(1); d.setDate(0);
+ return { from: ymd(d).slice(0, 8) + '01', to: ymd(d) };
+ }
+ if (state.preset === 'ytd') { return { from: today.slice(0, 5) + '01-01', to: today }; }
+ var n = parseInt(state.preset, 10) || 30;
+ return { from: addDays(today, -(n - 1)), to: today };
+ }
+
+ /* ================================================================== */
+ /* API */
+ /* ================================================================== */
+
+ function api(path, opts) {
+ opts = opts || {};
+ var url = String(CFG.root || '').replace(/\/$/, '') + path;
+ var q = Object.assign({ market: state.market }, opts.query || {});
+ var qs = Object.keys(q).filter(function (k) { return q[k] !== undefined && q[k] !== null && q[k] !== ''; }).map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(q[k]); }).join('&');
+ url += (url.indexOf('?') >= 0 ? '&' : '?') + qs;
+ var init = { method: opts.method || 'GET', credentials: 'same-origin', headers: { 'X-WP-Nonce': CFG.nonce, 'Accept': 'application/json' } };
+ if (opts.body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(opts.body); }
+ return fetch(url, init).then(function (res) {
+ return res.text().then(function (txt) {
+ var data = null;
+ try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = null; }
+ if (!res.ok) {
+ var msg = (data && (data.message || data.code)) || ('HTTP ' + res.status);
+ throw new Error(msg);
+ }
+ return data;
+ });
+ });
+ }
+
+ /* ================================================================== */
+ /* UI components */
+ /* ================================================================== */
+
+ var toasts = h('div', { class: 'sgs-toasts' });
+ document.body.appendChild(toasts);
+ function toast(msg, kind) {
+ var t = h('div', { class: 'sgs-toast ' + (kind || '') });
+ t.textContent = String(msg); // message text is never parsed as markup
+ toasts.appendChild(t);
+ setTimeout(function () { t.remove(); }, kind === 'bad' ? 7000 : 3500);
+ }
+
+ function btn(label, onClick, cls, attrs) {
+ var b = h('button', Object.assign({ type: 'button', class: 'btn ' + (cls || '') }, attrs || {}), label);
+ b.addEventListener('click', function (e) {
+ if (b.disabled) { return; }
+ var r = onClick(e, b);
+ if (r && typeof r.then === 'function') {
+ var old = b.textContent;
+ b.disabled = true; b.textContent = '…';
+ r.then(function () { b.disabled = false; b.textContent = old; }, function (err) { b.disabled = false; b.textContent = old; toast(err.message || String(err), 'bad'); });
+ }
+ });
+ return b;
+ }
+
+ function card(title, body, opts) {
+ opts = opts || {};
+ return h('div', { class: 'sgs-card' + (opts.flush ? ' flush' : '') },
+ (title || opts.actions) ? h('div', { class: 'sgs-card-head' }, h('div', null, title ? h('h3', null, title) : null, opts.sub ? h('p', null, opts.sub) : null), opts.actions || null) : null,
+ body);
+ }
+
+ function kpi(label, value, sub, cls) {
+ return h('div', { class: 'sgs-kpi ' + (cls || '') }, h('div', { class: 'lbl' }, label), h('div', { class: 'val', title: typeof value === 'string' ? value : '' }, value), sub ? h('div', { class: 'sub' }, sub) : null);
+ }
+
+ function badge(text, color) { return h('span', { class: 'b ' + (color || '') }, text); }
+
+ function empty(title, text, action) {
+ return h('div', { class: 'sgs-empty' }, h('b', null, title), text ? h('div', null, text) : null, action ? h('div', { style: { marginTop: '12px' } }, action) : null);
+ }
+
+ function meter(p, color) {
+ p = Math.max(0, Math.min(100, num(p)));
+ return h('div', { class: 'meter ' + (color || '') }, h('i', { style: { width: p + '%' } }));
+ }
+
+ function seg(options, value, onChange) {
+ var wrap = h('div', { class: 'seg' });
+ options.forEach(function (o) {
+ wrap.appendChild(h('button', { type: 'button', class: o[0] === value ? 'on' : '', onclick: function () { onChange(o[0]); } }, o[1]));
+ });
+ return wrap;
+ }
+
+ function field(label, input, help) {
+ return h('div', { class: 'field' }, h('label', null, label), input, help ? h('small', null, help) : null);
+ }
+
+ function drawer(title, content, actions) {
+ var bg = h('div', { class: 'sgs-drawer-bg' });
+ var body = h('div', { class: 'body' }, content);
+ var close = function () { bg.remove(); panel.remove(); document.removeEventListener('keydown', onKey); };
+ var onKey = function (e) { if (e.key === 'Escape') { close(); } };
+ var panel = h('div', { class: 'sgs-drawer sgs', role: 'dialog', 'aria-label': title },
+ h('header', null, h('h2', null, title), h('div', { class: 'btns' }, actions || null, btn('Close', close, 'ghost'))),
+ body);
+ bg.addEventListener('click', close);
+ document.addEventListener('keydown', onKey);
+ document.body.appendChild(bg);
+ document.body.appendChild(panel);
+ return { close: close, body: body };
+ }
+
+ function copy(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text).then(function () { toast('Copied to clipboard', 'ok'); });
+ }
+ var ta = h('textarea', null, text); document.body.appendChild(ta); ta.select();
+ try { document.execCommand('copy'); toast('Copied to clipboard', 'ok'); } catch (e) { /* ignore */ }
+ ta.remove();
+ return Promise.resolve();
+ }
+
+ function downloadCsv(name, columns, rows) {
+ var cols = columns.filter(function (c) { return c.key && c.csv !== false; });
+ var lines = [cols.map(function (c) { return '"' + String(c.label).replace(/"/g, '""') + '"'; }).join(',')];
+ rows.forEach(function (r) {
+ lines.push(cols.map(function (c) {
+ var v = c.csvValue ? c.csvValue(r) : r[c.key];
+ if (v === null || v === undefined) { v = ''; }
+ return '"' + String(v).replace(/"/g, '""') + '"';
+ }).join(','));
+ });
+ var blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
+ var a = h('a', { href: URL.createObjectURL(blob), download: name + '-' + ymd(new Date()) + '.csv' });
+ document.body.appendChild(a); a.click(); a.remove();
+ }
+
+ /**
+ * Sortable, searchable, paginated table with CSV export.
+ * columns: [{key, label, align:'r'|'c', fmt(v,row), render(row)->Node, sort:false, sum:true}]
+ */
+ function table(opts) {
+ var cols = opts.columns;
+ var all = (opts.rows || []).slice();
+ var sortKey = opts.sort || null;
+ var sortDir = opts.dir || -1;
+ var page = 0;
+ var per = opts.per || 50;
+ var query = '';
+ var selected = {};
+
+ var wrap = h('div', { class: 'sgs-card flush' });
+ var tbody = h('tbody');
+ var tfoot = h('tfoot');
+ var pager = h('div', { class: 'sgs-pager' });
+ var thead = h('thead');
+ var search = opts.search === false ? null : h('input', { type: 'text', placeholder: opts.placeholder || 'Search…', oninput: function (e) { query = e.target.value.toLowerCase(); page = 0; draw(); } });
+
+ var tools = h('div', { class: 'sgs-table-tools' },
+ h('div', { class: 'btns' }, search, opts.tools || null),
+ h('div', { class: 'btns' }, opts.selectable ? opts.bulk(function () { return all.filter(function (r) { return selected[r[opts.selectable]]; }); }) : null,
+ opts.csv ? btn('Export CSV', function () { downloadCsv(opts.csv, cols, filtered()); }, 'sm') : null));
+
+ if (opts.title) {
+ wrap.appendChild(h('div', { class: 'sgs-card-head' }, h('div', null, h('h3', null, opts.title), opts.sub ? h('p', null, opts.sub) : null), opts.actions || null));
+ }
+ wrap.appendChild(tools);
+ wrap.appendChild(h('div', { class: 'sgs-table-wrap' }, h('table', { class: 't' }, thead, tbody, tfoot)));
+ wrap.appendChild(pager);
+
+ function filtered() {
+ var rows = all;
+ if (query) {
+ rows = rows.filter(function (r) {
+ return cols.some(function (c) { var v = r[c.key]; return v !== null && v !== undefined && String(v).toLowerCase().indexOf(query) >= 0; });
+ });
+ }
+ if (sortKey) {
+ rows = rows.slice().sort(function (a, b) {
+ var x = a[sortKey], y = b[sortKey];
+ var nx = parseFloat(x), ny = parseFloat(y);
+ if (!isNaN(nx) && !isNaN(ny) && String(nx) !== 'NaN') { return (nx - ny) * sortDir; }
+ if (x === null || x === undefined) { return 1; }
+ if (y === null || y === undefined) { return -1; }
+ return String(x).localeCompare(String(y)) * sortDir;
+ });
+ }
+ return rows;
+ }
+
+ function head() {
+ clear(thead);
+ var tr = h('tr');
+ if (opts.selectable) {
+ var cb = h('input', { type: 'checkbox', onchange: function (e) { filtered().forEach(function (r) { selected[r[opts.selectable]] = e.target.checked; }); draw(); } });
+ tr.appendChild(h('th', { style: { width: '28px' } }, cb));
+ }
+ cols.forEach(function (c) {
+ var th = h('th', { class: c.align || '', title: c.title || '' }, c.label, sortKey === c.key ? h('span', { class: 'arrow' }, sortDir > 0 ? '▲' : '▼') : null);
+ if (c.sort !== false && c.key) {
+ th.addEventListener('click', function () { if (sortKey === c.key) { sortDir = -sortDir; } else { sortKey = c.key; sortDir = -1; } draw(); });
+ }
+ tr.appendChild(th);
+ });
+ thead.appendChild(tr);
+ }
+
+ function draw() {
+ head();
+ clear(tbody); clear(tfoot); clear(pager);
+ var rows = filtered();
+ if (!rows.length) {
+ tbody.appendChild(h('tr', null, h('td', { colspan: cols.length + (opts.selectable ? 1 : 0) }, opts.empty || empty('Nothing here yet', 'No rows match.'))));
+ return;
+ }
+ var pages = Math.ceil(rows.length / per);
+ page = Math.min(page, pages - 1);
+ rows.slice(page * per, page * per + per).forEach(function (r) {
+ var tr = h('tr', { class: opts.onRow ? 'click' : '' });
+ if (opts.selectable) {
+ var id = r[opts.selectable];
+ tr.appendChild(h('td', { onclick: function (e) { e.stopPropagation(); } }, h('input', { type: 'checkbox', checked: !!selected[id], onchange: function (e) { selected[id] = e.target.checked; } })));
+ }
+ cols.forEach(function (c) {
+ var content = c.render ? c.render(r) : (c.fmt ? c.fmt(r[c.key], r) : (r[c.key] === null || r[c.key] === undefined || r[c.key] === '' ? '—' : r[c.key]));
+ var cls = (c.align || '') + (c.tone ? ' ' + tone(r[c.key]) : '') + (c.mono ? ' mono' : '');
+ tr.appendChild(h('td', { class: cls }, content));
+ });
+ if (opts.onRow) { tr.addEventListener('click', function () { opts.onRow(r); }); }
+ tbody.appendChild(tr);
+ });
+ if (cols.some(function (c) { return c.sum; })) {
+ var ft = h('tr');
+ if (opts.selectable) { ft.appendChild(h('td')); }
+ cols.forEach(function (c, i) {
+ if (c.sum) {
+ var s = rows.reduce(function (a, r) { return a + num(r[c.key]); }, 0);
+ ft.appendChild(h('td', { class: (c.align || '') + (c.tone ? ' ' + tone(s) : '') }, c.fmt ? c.fmt(s, {}) : s));
+ } else {
+ ft.appendChild(h('td', { class: c.align || '' }, i === 0 ? 'Total (' + rows.length + ')' : ''));
+ }
+ });
+ tfoot.appendChild(ft);
+ }
+ if (pages > 1) {
+ pager.appendChild(h('span', null, (page * per + 1) + '–' + Math.min(rows.length, page * per + per) + ' of ' + rows.length));
+ pager.appendChild(btn('‹', function () { page = Math.max(0, page - 1); draw(); }, 'sm', { disabled: page === 0 }));
+ pager.appendChild(btn('›', function () { page = Math.min(pages - 1, page + 1); draw(); }, 'sm', { disabled: page >= pages - 1 }));
+ } else {
+ pager.appendChild(h('span', null, rows.length + ' rows'));
+ }
+ }
+
+ draw();
+ wrap.setRows = function (rows) { all = rows.slice(); selected = {}; draw(); };
+ wrap.clearSelection = function () { selected = {}; draw(); };
+ return wrap;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* SVG charts */
+ /* ------------------------------------------------------------------ */
+
+ var NS = 'http://www.w3.org/2000/svg';
+ function s(tag, attrs) {
+ var el = document.createElementNS(NS, tag);
+ Object.keys(attrs || {}).forEach(function (k) { el.setAttribute(k, attrs[k]); });
+ for (var i = 2; i < arguments.length; i++) { if (arguments[i]) { el.appendChild(arguments[i]); } }
+ return el;
+ }
+
+ function niceMax(v) {
+ if (v <= 0) { return 1; }
+ var p = Math.pow(10, Math.floor(Math.log10(v)));
+ var n = v / p;
+ return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p;
+ }
+
+ /**
+ * chart({labels, series:[{label, values, type:'bar'|'line'|'area', color, axis:'left'|'right', fmt}], height, fmt})
+ */
+ function chart(o) {
+ var W = 900, H = o.height || 240, pl = 56, pr = o.series.some(function (x) { return x.axis === 'right'; }) ? 56 : 14, pt = 10, pb = 26;
+ var iw = W - pl - pr, ih = H - pt - pb;
+ var labels = o.labels || [];
+ var n = Math.max(1, labels.length);
+ function range(axis) {
+ var vals = [];
+ o.series.filter(function (x) { return (x.axis || 'left') === axis; }).forEach(function (x) { x.values.forEach(function (v) { if (v !== null && v !== undefined) { vals.push(num(v)); } }); });
+ var max = Math.max.apply(null, vals.concat([0])), min = Math.min.apply(null, vals.concat([0]));
+ var top = niceMax(Math.max(max, Math.abs(min) * 0.2));
+ var bottom = min < 0 ? -niceMax(Math.abs(min)) : 0;
+ return [bottom, top];
+ }
+ var L = range('left'), R = range('right');
+ function y(v, axis) { var r = axis === 'right' ? R : L; return pt + ih - (num(v) - r[0]) / (r[1] - r[0] || 1) * ih; }
+ function x(i) { return pl + (i + 0.5) * (iw / n); }
+
+ var svg = s('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', height: H, role: 'img', 'aria-label': o.title || 'chart' });
+ var grid = s('g', { class: 'grid' });
+ var axis = s('g', { class: 'axis' });
+ for (var g = 0; g <= 4; g++) {
+ var vy = L[0] + (L[1] - L[0]) * g / 4;
+ var yy = y(vy, 'left');
+ grid.appendChild(s('line', { x1: pl, x2: W - pr, y1: yy, y2: yy }));
+ var t = s('text', { x: pl - 8, y: yy + 3, 'text-anchor': 'end' }); t.textContent = (o.fmt || money0)(vy); axis.appendChild(t);
+ if (pr > 20) {
+ var vr = R[0] + (R[1] - R[0]) * g / 4;
+ var tr = s('text', { x: W - pr + 8, y: y(vr, 'right') + 3, 'text-anchor': 'start' });
+ tr.textContent = (o.series.filter(function (x) { return x.axis === 'right'; })[0].fmt || int)(vr); axis.appendChild(tr);
+ }
+ }
+ var step = Math.max(1, Math.ceil(n / 10));
+ labels.forEach(function (l, i) {
+ if (i % step !== 0 && i !== n - 1) { return; }
+ var t = s('text', { x: x(i), y: H - 6, 'text-anchor': 'middle' }); t.textContent = o.labelFmt ? o.labelFmt(l) : shortDate(l); axis.appendChild(t);
+ });
+ svg.appendChild(grid); svg.appendChild(axis);
+ if (L[0] < 0) { svg.appendChild(s('line', { x1: pl, x2: W - pr, y1: y(0, 'left'), y2: y(0, 'left'), stroke: '#cbd5e1' })); }
+
+ var bars = o.series.filter(function (x) { return x.type === 'bar'; });
+ var bw = Math.max(2, (iw / n) * 0.72 / Math.max(1, bars.length));
+ bars.forEach(function (ser, si) {
+ ser.values.forEach(function (v, i) {
+ if (v === null || v === undefined) { return; }
+ var y0 = y(0, ser.axis), y1 = y(v, ser.axis);
+ var xx = x(i) - (bw * bars.length) / 2 + si * bw;
+ svg.appendChild(s('rect', { x: xx, y: Math.min(y0, y1), width: bw - 1, height: Math.max(1, Math.abs(y1 - y0)), rx: 2, fill: ser.color, opacity: 0.9 }));
+ });
+ });
+ o.series.filter(function (x) { return x.type !== 'bar'; }).forEach(function (ser) {
+ var d = '';
+ ser.values.forEach(function (v, i) { if (v === null || v === undefined) { return; } d += (d ? 'L' : 'M') + x(i).toFixed(1) + ',' + y(v, ser.axis).toFixed(1); });
+ if (!d) { return; }
+ if (ser.type === 'area') {
+ svg.appendChild(s('path', { d: d + 'L' + x(n - 1) + ',' + y(0, ser.axis) + 'L' + x(0) + ',' + y(0, ser.axis) + 'Z', fill: ser.color, opacity: 0.1 }));
+ }
+ svg.appendChild(s('path', { d: d, fill: 'none', stroke: ser.color, 'stroke-width': 2.2, 'stroke-linejoin': 'round', 'vector-effect': 'non-scaling-stroke' }));
+ });
+
+ var wrap = h('div', { class: 'sgs-chart' });
+ var tip = h('div', { class: 'tip', style: { display: 'none' } });
+ var cursor = s('line', { y1: pt, y2: pt + ih, stroke: '#94a3b8', 'stroke-dasharray': '3 3', opacity: 0 });
+ svg.appendChild(cursor);
+ svg.addEventListener('mousemove', function (e) {
+ var rect = svg.getBoundingClientRect();
+ var px = (e.clientX - rect.left) / rect.width * W;
+ var i = Math.max(0, Math.min(n - 1, Math.floor((px - pl) / (iw / n))));
+ cursor.setAttribute('x1', x(i)); cursor.setAttribute('x2', x(i)); cursor.setAttribute('opacity', 1);
+ clear(tip);
+ tip.appendChild(h('b', null, o.labelFmt ? o.labelFmt(labels[i]) : shortDate(labels[i])));
+ o.series.forEach(function (ser) {
+ tip.appendChild(h('div', null, h('i', { style: { background: ser.color } }), ser.label + ': ', (ser.fmt || o.fmt || money)(ser.values[i])));
+ });
+ tip.style.display = 'block';
+ tip.style.left = (x(i) / W * 100) + '%';
+ tip.style.top = ((e.clientY - rect.top)) + 'px';
+ });
+ svg.addEventListener('mouseleave', function () { tip.style.display = 'none'; cursor.setAttribute('opacity', 0); });
+ wrap.appendChild(svg); wrap.appendChild(tip);
+ var legend = h('div', { class: 'sgs-legend' });
+ o.series.forEach(function (ser) { legend.appendChild(h('span', null, h('i', { style: { background: ser.color } }), ser.label)); });
+ return h('div', null, legend, h('div', { style: { height: '8px' } }), wrap);
+ }
+
+ function sparkline(values, color, w, hgt) {
+ w = w || 90; hgt = hgt || 24;
+ var vals = values.map(num);
+ var max = Math.max.apply(null, vals.concat([0.0001])), min = Math.min.apply(null, vals.concat([0]));
+ var d = vals.map(function (v, i) { return (i ? 'L' : 'M') + (i / Math.max(1, vals.length - 1) * w).toFixed(1) + ',' + (hgt - 2 - (v - min) / (max - min || 1) * (hgt - 4)).toFixed(1); }).join('');
+ return s('svg', { width: w, height: hgt, class: 'spark' }, s('path', { d: d, fill: 'none', stroke: color || '#2563eb', 'stroke-width': 1.6 }));
+ }
+
+ var C = { sales: '#2563eb', profit: '#059669', ads: '#f59e0b', refunds: '#dc2626', fees: '#94a3b8', units: '#7c3aed', grey: '#cbd5e1' };
+
+ /* ================================================================== */
+ /* Shell */
+ /* ================================================================== */
+
+ var ROUTES = [
+ ['', 'Dashboard'], ['profit', 'Profit'], ['orders', 'Orders'], ['restock', 'Inventory'], ['ppc', 'PPC'],
+ ['keywords', 'Keywords'], ['listings', 'Listings'], ['monitor', 'Hijackers & Buy Box'], ['reviews', 'Reviews'],
+ ['reimbursements', 'Reimbursements'], ['analytics', 'Traffic & Returns'], ['research', 'Research'],
+ ['reports', 'Reports'], ['alerts', 'Alerts'], ['settings', 'Settings']
+ ];
+
+ var headerSlot = h('div');
+ var nav = h('nav', { class: 'sgs-nav', 'aria-label': 'Amazon Suite sections' });
+ var bannerSlot = h('div');
+ var main = h('main');
+
+ function header() {
+ var sel = h('select', { 'aria-label': 'Marketplace', onchange: function (e) { state.market = e.target.value; persist(); route(); } });
+ (CFG.markets || [{ code: state.market }]).forEach(function (m) { sel.appendChild(h('option', { value: m.code, selected: m.code === state.market }, m.code + (m.currency ? ' · ' + m.currency : ''))); });
+ return h('div', { class: 'sgs-top' },
+ h('div', { class: 'sgs-brand' }, h('div', { class: 'sgs-logo' }, '7'), h('div', null, h('h1', null, 'Amazon Seller Suite'), h('small', null, 'Profit · PPC · Inventory · Reviews · Reimbursements · Protection'))),
+ h('div', { class: 'sgs-controls' },
+ h('span', { class: 'sgs-pill ' + (state.connected ? 'ok' : 'bad') }, h('span', { class: 'dot' }), state.connected ? 'SP-API connected' : 'SP-API not connected'),
+ h('span', { class: 'sgs-pill ' + (state.ads ? 'ok' : 'warn') }, h('span', { class: 'dot' }), state.ads ? 'Ads API connected' : 'Ads API off'),
+ state.demo ? h('span', { class: 'sgs-pill' }, '🧪 Demo data') : null,
+ sel));
+ }
+
+ function rangeBar(onChange) {
+ var r = rangeParams();
+ var from = h('input', { type: 'date', value: r.from, onchange: function () { state.preset = 'custom'; state.from = from.value; state.to = to.value; persist(); onChange(); } });
+ var to = h('input', { type: 'date', value: r.to, onchange: function () { state.preset = 'custom'; state.from = from.value; state.to = to.value; persist(); onChange(); } });
+ return h('div', { class: 'btns' },
+ seg([['7', '7D'], ['14', '14D'], ['30', '30D'], ['mtd', 'MTD'], ['lm', 'Last month'], ['90', '90D'], ['ytd', 'YTD']], state.preset, function (v) { state.preset = v; persist(); onChange(); }),
+ from, h('span', { class: 'muted' }, '→'), to);
+ }
+
+ function pageHead(title, sub, actions) {
+ return h('div', { class: 'sgs-page-head' }, h('div', null, h('h2', null, title), sub ? h('p', null, sub) : null), actions ? h('div', { class: 'btns' }, actions) : null);
+ }
+
+ function banners() {
+ clear(bannerSlot);
+ if (state.demo) {
+ bannerSlot.appendChild(h('div', { class: 'sgs-banner demo' },
+ h('span', null, h('b', null, 'Demo mode. '), 'You are looking at a realistic sample account for the 7 SEVEN catalog. Clear it any time; your real data is never touched.'),
+ btn('Clear demo data', function () { return api('/demo', { method: 'DELETE' }).then(function () { state.demo = false; toast('Demo data removed', 'ok'); route(); }); }, 'sm')));
+ } else if (!state.connected) {
+ bannerSlot.appendChild(h('div', { class: 'sgs-banner warn' },
+ h('span', null, h('b', null, 'Amazon is not connected yet. '), 'Add your LWA credentials under Seven Gum → Amazon Connect, or explore with a demo account first.'),
+ h('div', { class: 'btns' },
+ h('a', { class: 'btn sm', href: CFG.coreUrl }, 'Connect Amazon'),
+ btn('Load demo account', function () { return seedDemo(); }, 'sm primary'))));
+ }
+ }
+
+ function seedDemo() {
+ toast('Building the demo account (≈20–40 s)…');
+ return api('/demo', { method: 'POST' }).then(function () { state.demo = true; toast('Demo account ready', 'ok'); route(); });
+ }
+
+ function drawNav(active) {
+ clear(nav);
+ ROUTES.forEach(function (r) {
+ nav.appendChild(h('a', { href: '#/' + r[0], class: r[0] === active ? 'on' : '' }, r[1], r[0] === 'alerts' && state.unread ? h('span', { class: 'badge' }, state.unread) : null));
+ });
+ }
+
+ function loading() {
+ return h('div', { class: 'sgs-stack' }, h('div', { class: 'sgs-grid k4' }, [1, 2, 3, 4].map(function () { return h('div', { class: 'skeleton' }); })), h('div', { class: 'skeleton', style: { minHeight: '260px' } }));
+ }
+
+ function failure(err) {
+ return h('div', { class: 'sgs-banner bad' }, h('span', null, h('b', null, 'Could not load this view. '), err.message || String(err)), btn('Retry', route, 'sm'));
+ }
+
+ var token = 0;
+ function route() {
+ var hash = (window.location.hash || '').replace(/^#\/?/, '');
+ var name = hash.split('?')[0];
+ var page = pick(Object.keys(PAGES), name, '');
+ var render = PAGES[page];
+ drawNav(page);
+ clear(headerSlot).appendChild(header());
+ banners();
+ clear(main).appendChild(loading());
+ var my = ++token;
+ Promise.resolve().then(function () { return render(); }).then(function (node) {
+ if (my !== token) { return; }
+ // Pages may refresh connection/demo/unread state — redraw the chrome for the CURRENT route only.
+ drawNav(page);
+ clear(headerSlot).appendChild(header());
+ banners();
+ clear(main).appendChild(node);
+ }).catch(function (err) {
+ if (my !== token) { return; }
+ clear(main).appendChild(failure(err));
+ });
+ }
+
+ /* ================================================================== */
+ /* Pages */
+ /* ================================================================== */
+
+ var PAGES = {};
+
+ /* ---------- Dashboard ---------- */
+ PAGES[''] = function () {
+ return api('/overview').then(function (d) {
+ state.unread = d.unread || 0;
+ state.connected = d.connected; state.ads = d.ads_ready; state.demo = d.demo;
+ var P = d.periods;
+ function period(key, title, hl) {
+ var t = P[key];
+ return h('div', { class: 'sgs-period' + (hl ? ' hl' : '') },
+ h('div', { class: 'head' }, h('b', null, title), h('span', null, t.from === t.to ? shortDate(t.from) : shortDate(t.from) + ' – ' + shortDate(t.to))),
+ h('div', { class: 'big' }, money(t.sales)),
+ h('dl', null,
+ h('dt', null, 'Orders / units'), h('dd', null, int(t.orders) + ' / ' + int(t.units)),
+ h('dt', null, 'Ad spend'), h('dd', null, money(-t.ads)),
+ h('dt', null, 'Amazon fees'), h('dd', null, money(-t.amazon_fees)),
+ h('dt', null, 'Net profit'), h('dd', { class: hl ? '' : tone(t.net_profit) }, money(t.net_profit)),
+ h('dt', null, 'Margin'), h('dd', null, pct(t.margin_pct))));
+ }
+ var F = P.forecast;
+ var kpis = h('div', { class: 'sgs-grid k5' },
+ period('today', 'Today', true), period('yesterday', 'Yesterday'), period('mtd', 'Month to date'),
+ h('div', { class: 'sgs-period' },
+ h('div', { class: 'head' }, h('b', null, 'Forecast this month'), h('span', null, F.basis || '')),
+ h('div', { class: 'big' }, money(F.sales)),
+ h('dl', null, h('dt', null, 'Units'), h('dd', null, int(F.units)), h('dt', null, 'Net profit'), h('dd', { class: tone(F.net_profit) }, money(F.net_profit)))),
+ period('last_month', 'Last month'));
+
+ var series = d.series || [];
+ var trend = card('Sales & net profit — last 30 days', chart({
+ labels: series.map(function (r) { return r.date; }),
+ series: [
+ { label: 'Sales', values: series.map(function (r) { return r.sales; }), type: 'bar', color: C.sales },
+ { label: 'Net profit', values: series.map(function (r) { return r.net_profit; }), type: 'line', color: C.profit },
+ { label: 'Ad spend', values: series.map(function (r) { return r.ads; }), type: 'line', color: C.ads }
+ ]
+ }));
+
+ var T = d.totals_30;
+ var unit = h('div', { class: 'sgs-grid k4' },
+ kpi('30-day net profit', money(T.net_profit), 'Margin ' + pct(T.margin_pct) + ' · ROI ' + pct(T.roi_pct), num(T.net_profit) >= 0 ? 'tone-green' : 'tone-red'),
+ kpi('TACoS', pct(T.tacos_pct), 'ACoS ' + pct(T.acos_pct) + ' · spend ' + money(-T.ads)),
+ kpi('Conversion rate', pct(d.conversion), int(d.sessions) + ' sessions'),
+ kpi('Refund rate', pct(T.refund_rate_pct), money(-T.refunds) + ' refunded'));
+
+ var actions = h('div', { class: 'sgs-grid k3' });
+ function act(icon, color, title, sub, href) { actions.appendChild(h('a', { class: 'action-card', href: href }, h('div', { class: 'ic ' + color }, icon), h('div', null, h('b', null, title), h('span', null, sub)))); }
+ var R = d.restock;
+ act('📦', R.reorder || R.out_of_stock ? 'red' : 'green', R.out_of_stock + ' out of stock · ' + R.reorder + ' to reorder', R.lost_sales_per_day > 0 ? 'Losing ≈' + money(R.lost_sales_per_day) + '/day in sales' : 'Suggested PO: ' + int(R.order_units) + ' units (' + money0(R.order_value) + ')', '#/restock');
+ act('🛡️', d.buybox.hijacked ? 'red' : 'green', d.buybox.hijacked ? d.buybox.hijacked + ' listing(s) with other sellers' : 'No hijackers detected', 'Buy Box held on ' + d.buybox.winning + '/' + d.buybox.asins + ' ASINs (' + pct(d.buybox.pct) + ')', '#/monitor');
+ act('💸', d.reimburse.open ? 'amber' : 'green', money(d.reimburse.value) + ' recoverable', d.reimburse.open + ' open reimbursement case(s) ready to file', '#/reimbursements');
+ act('🎯', 'blue', 'PPC: ' + money(d.ads.wasted_spend) + ' wasted spend', 'Search terms with clicks but no orders (30 days) · ROAS ' + (d.ads.roas || '—'), '#/ppc');
+ act('📝', d.listings.avg_score !== null && d.listings.avg_score < 80 ? 'amber' : 'green', 'Listing score ' + (d.listings.avg_score === null ? '—' : d.listings.avg_score + '/100'), d.listings.audited + ' listing(s) audited', '#/listings');
+ act('⭐', 'violet', int(d.reviews.sent) + ' review requests sent', '30 days · ' + int(d.reviews.ineligible) + ' ineligible', '#/reviews');
+
+ var top = table({
+ title: 'Top products — 30 days', search: false, per: 8, sort: 'sales',
+ columns: [
+ { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, r.title)); } },
+ { key: 'units', label: 'Units', align: 'r', fmt: int },
+ { key: 'sales', label: 'Sales', align: 'r', fmt: money },
+ { key: 'net_profit', label: 'Net profit', align: 'r', fmt: money, tone: true },
+ { key: 'margin_pct', label: 'Margin', align: 'r', fmt: function (v) { return pct(v); } }
+ ],
+ rows: d.top_skus
+ });
+ var alerts = card('Latest alerts', d.alerts && d.alerts.length ? h('ul', { class: 'list' }, d.alerts.map(function (a) {
+ return h('li', null, h('div', null, h('b', null, a.title), h('div', { class: 'small muted' }, dateTime(a.created_at))), badge(a.level, a.level === 'critical' ? 'red' : (a.level === 'warning' ? 'amber' : 'blue')));
+ })) : empty('All quiet', 'No alerts yet.'), { actions: h('a', { href: '#/alerts', class: 'btn sm' }, 'All alerts') });
+
+ var missing = d.missing_costs && d.missing_costs.length ? h('div', { class: 'sgs-banner warn' }, h('span', null, h('b', null, d.missing_costs.length + ' SKU(s) have no product cost — '), 'profit is overstated until you add COGS.'), h('a', { class: 'btn sm', href: '#/profit?tab=costs' }, 'Add costs')) : null;
+
+ return h('div', { class: 'sgs-stack' },
+ pageHead('Dashboard', 'Live net profit after every Amazon fee, refund, ad dollar and unit cost — plus everything that needs your attention today.', [btn('Refresh', route, 'sm')]),
+ missing, kpis, h('div', { class: 'sgs-grid k21' }, trend, alerts), unit, actions, top);
+ });
+ };
+
+ /* ---------- Profit ---------- */
+ PAGES.profit = function () {
+ var tab = (window.location.hash.split('tab=')[1] || 'pnl');
+ var wrap = h('div', { class: 'sgs-stack' });
+ var body = h('div');
+ function setTab(t) { tab = t; history.replaceState(null, '', '#/profit?tab=' + t); draw(); }
+ function draw() {
+ clear(wrap);
+ wrap.appendChild(pageHead('Profit & P&L', 'Sellerboard-grade profit: settled fees by order, estimated fees for unsettled orders, refunds, storage, ads, COGS and your own expenses.', [seg([['pnl', 'P&L'], ['costs', 'Product costs (COGS)'], ['expenses', 'Expenses']], tab, setTab)]));
+ wrap.appendChild(body);
+ clear(body).appendChild(loading());
+ (tab === 'costs' ? costsView() : tab === 'expenses' ? expensesView() : pnlView()).then(function (n) { clear(body).appendChild(n); }, function (e) { clear(body).appendChild(failure(e)); });
+ }
+ function pnlView() {
+ return api('/profit', { query: rangeParams() }).then(function (d) {
+ var T = d.totals, P = d.previous || {};
+ var k = h('div', { class: 'sgs-grid k5' },
+ kpi('Sales', h('span', null, money(T.sales), delta(T.sales, P.sales)), int(T.orders) + ' orders · ' + int(T.units) + ' units'),
+ kpi('Net profit', h('span', null, money(T.net_profit), delta(T.net_profit, P.net_profit)), 'Margin ' + pct(T.margin_pct), num(T.net_profit) >= 0 ? 'tone-green' : 'tone-red'),
+ kpi('ROI', pct(T.roi_pct), 'on ' + money(-T.cogs) + ' COGS'),
+ kpi('Amazon fees', money(-(T.amazon_fees + T.account_fees)), pct(T.sales ? -(T.amazon_fees + T.account_fees) / T.sales * 100 : null) + ' of sales' + (T.fees_estimated ? ' · ' + money(-T.fees_estimated) + ' estimated' : '')),
+ kpi('Ad spend', money(-T.ads), 'TACoS ' + pct(T.tacos_pct) + ' · ACoS ' + pct(T.acos_pct)));
+
+ var max = Math.max.apply(null, d.waterfall.map(function (w) { return Math.abs(num(w.value)); }).concat([1]));
+ var wf = h('div', { class: 'wf' });
+ d.waterfall.forEach(function (w) {
+ var v = num(w.value);
+ wf.appendChild(h('div', null, w.label));
+ wf.appendChild(h('div', { class: 'bar' }, h('i', { style: { left: '0', width: (Math.abs(v) / max * 100) + '%', background: v >= 0 ? C.profit : C.refunds, opacity: 0.8 } })));
+ wf.appendChild(h('div', { class: 'v ' + tone(v) }, money(v)));
+ });
+ wf.appendChild(h('div', { class: 'total' }, 'Net profit'));
+ wf.appendChild(h('div', { class: 'bar' }, h('i', { style: { width: (Math.abs(num(T.net_profit)) / max * 100) + '%', background: C.sales } })));
+ wf.appendChild(h('div', { class: 'v total ' + tone(T.net_profit) }, money(T.net_profit)));
+
+ var fees = h('ul', { class: 'list' }, d.fee_breakdown.slice(0, 12).map(function (f) { return h('li', null, h('span', null, f.type), h('b', { class: tone(f.amount) }, money(f.amount))); }));
+
+ var series = d.series;
+ var ch = card('Daily trend', chart({
+ labels: series.map(function (r) { return r.date; }),
+ series: [
+ { label: 'Sales', values: series.map(function (r) { return r.sales; }), type: 'bar', color: C.sales },
+ { label: 'Net profit', values: series.map(function (r) { return r.net_profit; }), type: 'line', color: C.profit },
+ { label: 'Fees', values: series.map(function (r) { return r.fees; }), type: 'line', color: C.fees },
+ { label: 'Ads', values: series.map(function (r) { return r.ads; }), type: 'line', color: C.ads }
+ ]
+ }), { actions: rangeBar(draw) });
+
+ var skus = table({
+ title: 'Profit by product', sub: 'Ad spend is allocated to SKUs by sales share. Click a row to edit its cost.', csv: 'profit-by-sku', sort: 'sales',
+ columns: [
+ { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), !r.has_cost ? h('span', null, ' ', badge('no cost', 'amber')) : null, h('span', { class: 'title muted small' }, r.title)); } },
+ { key: 'units', label: 'Units', align: 'r', fmt: int, sum: true },
+ { key: 'sales', label: 'Sales', align: 'r', fmt: money, sum: true },
+ { key: 'refunds', label: 'Refunds', align: 'r', fmt: money, sum: true },
+ { key: 'amazon_fees', label: 'Amazon fees', align: 'r', fmt: money, sum: true },
+ { key: 'ads', label: 'Ads (alloc.)', align: 'r', fmt: money, sum: true },
+ { key: 'cogs', label: 'COGS', align: 'r', fmt: money, sum: true },
+ { key: 'net_profit', label: 'Net profit', align: 'r', fmt: money, tone: true, sum: true },
+ { key: 'margin_pct', label: 'Margin', align: 'r', fmt: function (v) { return pct(v); } },
+ { key: 'roi_pct', label: 'ROI', align: 'r', fmt: function (v) { return pct(v); } }
+ ],
+ rows: d.skus,
+ onRow: function () { setTab('costs'); }
+ });
+ return h('div', { class: 'sgs-stack' }, k, ch,
+ h('div', { class: 'sgs-grid k2' }, card('Profit waterfall', wf, { sub: shortDate(d.from) + ' – ' + shortDate(d.to) }), card('Fee breakdown', fees, { sub: 'Every Amazon fee type, settled + estimated' })),
+ skus);
+ });
+ }
+ function costsView() {
+ return api('/costs').then(function (d) {
+ var rows = d.rows;
+ function inp(r, k, step) {
+ return h('input', { type: 'number', step: step || '0.01', min: '0', value: r[k] || '', style: { width: '90px' }, onchange: function (e) { r[k] = e.target.value; r._dirty = true; } });
+ }
+ var t = table({
+ title: 'Product costs (landed COGS)', sub: 'Landed cost = unit + inbound freight + prep + other. Lead time, MOQ and case pack drive the restock planner.', csv: 'product-costs', per: 100, sort: 'sku', dir: 1,
+ columns: [
+ { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, r.title)); } },
+ { key: 'unit_cost', label: 'Unit', align: 'r', render: function (r) { return inp(r, 'unit_cost'); } },
+ { key: 'inbound_per_unit', label: 'Inbound/unit', align: 'r', render: function (r) { return inp(r, 'inbound_per_unit'); } },
+ { key: 'prep_per_unit', label: 'Prep/unit', align: 'r', render: function (r) { return inp(r, 'prep_per_unit'); } },
+ { key: 'other_per_unit', label: 'Other/unit', align: 'r', render: function (r) { return inp(r, 'other_per_unit'); } },
+ { key: 'lead_time_days', label: 'Lead days', align: 'r', render: function (r) { return inp(r, 'lead_time_days', '1'); } },
+ { key: 'moq', label: 'MOQ', align: 'r', render: function (r) { return inp(r, 'moq', '1'); } },
+ { key: 'case_pack', label: 'Case pack', align: 'r', render: function (r) { return inp(r, 'case_pack', '1'); } },
+ { key: 'landed', label: 'Landed', align: 'r', fmt: money },
+ { key: 'source', label: 'Source', render: function (r) { return badge(r.source, r.source === 'none' ? 'amber' : 'green'); } }
+ ],
+ rows: rows,
+ actions: btn('Save changes', function () {
+ var dirty = rows.filter(function (r) { return r._dirty; });
+ if (!dirty.length) { toast('No changes'); return Promise.resolve(); }
+ return api('/costs', { method: 'POST', body: { rows: dirty } }).then(function (res) { toast(res.saved + ' cost(s) saved', 'ok'); draw(); });
+ }, 'primary')
+ });
+ return t;
+ });
+ }
+ function expensesView() {
+ return api('/expenses').then(function (d) {
+ var label = h('input', { type: 'text', placeholder: 'e.g. Photography, VA, software' });
+ var amount = h('input', { type: 'number', step: '0.01', min: '0', placeholder: '0.00' });
+ var rec = h('select', null, h('option', { value: 'monthly' }, 'Monthly'), h('option', { value: 'once' }, 'One-off'));
+ var start = h('input', { type: 'date', value: ymd(new Date()) });
+ var cat = h('select', null, ['software', 'staff', 'marketing', 'samples', 'logistics', 'other'].map(function (c) { return h('option', { value: c }, c); }));
+ var form = card('Add expense', h('div', { class: 'form-grid' },
+ field('Label', label), field('Amount', amount), field('Recurrence', rec), field('Category', cat), field('Start date', start),
+ h('div', { class: 'field' }, h('label', null, ' '), btn('Add expense', function () {
+ return api('/expenses', { method: 'POST', body: { label: label.value, amount: amount.value, recurrence: rec.value, start_date: start.value, category: cat.value } }).then(function () { toast('Expense added', 'ok'); draw(); });
+ }, 'primary'))), { sub: 'Monthly expenses are pro-rated per day in every P&L range.' });
+ var t = table({
+ title: 'Expenses', csv: 'expenses', columns: [
+ { key: 'label', label: 'Label' }, { key: 'category', label: 'Category' },
+ { key: 'amount', label: 'Amount', align: 'r', fmt: money }, { key: 'recurrence', label: 'Recurrence' },
+ { key: 'start_date', label: 'Start' }, { key: 'end_date', label: 'End' },
+ { key: 'id', label: '', sort: false, render: function (r) { return btn('Delete', function () { return api('/expenses/' + r.id, { method: 'DELETE' }).then(draw); }, 'sm danger'); } }
+ ], rows: d.rows, empty: empty('No expenses yet', 'Add software subscriptions, staff, photography, samples…')
+ });
+ return h('div', { class: 'sgs-stack' }, form, t);
+ });
+ }
+ draw();
+ return wrap;
+ };
+
+ /* ---------- Orders ---------- */
+ PAGES.orders = function () {
+ var wrap = h('div', { class: 'sgs-stack' });
+ var q = h('input', { type: 'text', placeholder: 'Order ID, SKU or ASIN…' });
+ var status = h('select', null, ['', 'Pending', 'Unshipped', 'Shipped', 'Canceled'].map(function (s) { return h('option', { value: s }, s || 'All statuses'); }));
+ var slot = h('div');
+ function load() {
+ clear(slot).appendChild(loading());
+ api('/orders', { query: Object.assign(rangeParams(), { q: q.value, status: status.value }) }).then(function (d) {
+ clear(slot).appendChild(table({
+ title: d.orders.length + ' orders', sub: 'Per-order profit. Fees are settled amounts when available, otherwise estimated from the SKU’s recent fee history.', csv: 'orders', search: false, sort: 'purchase_date',
+ columns: [
+ { key: 'purchase_date', label: 'Date', fmt: function (v) { return dateTime(v); } },
+ { key: 'amazon_order_id', label: 'Order', mono: true },
+ { key: 'status', label: 'Status', render: function (r) { return badge(r.status, { Shipped: 'green', Canceled: 'red', Pending: 'amber', Unshipped: 'blue' }[r.status] || ''); } },
+ { key: 'items', label: 'Items', sort: false, csvValue: function (r) { return r.items.map(function (i) { return i.qty + 'x ' + i.sku; }).join('; '); }, render: function (r) { return h('div', { class: 'small' }, r.items.map(function (i) { return h('div', null, i.qty + '× ', h('b', null, i.sku)); })); } },
+ { key: 'units', label: 'Units', align: 'r', fmt: int, sum: true },
+ { key: 'sales', label: 'Sales', align: 'r', fmt: money, sum: true },
+ { key: 'fees', label: 'Fees', align: 'r', sum: true, fmt: money, render: function (r) { return h('span', null, money(r.fees), r.fees_estimated ? h('span', { class: 'muted small', title: 'Estimated — not settled yet' }, ' est.') : null); } },
+ { key: 'refund', label: 'Refund', align: 'r', fmt: money, sum: true },
+ { key: 'cogs', label: 'COGS', align: 'r', fmt: money, sum: true },
+ { key: 'profit', label: 'Profit', align: 'r', fmt: money, tone: true, sum: true },
+ { key: 'fulfillment_channel', label: 'Ch.', fmt: function (v) { return v === 'AFN' ? 'FBA' : 'FBM'; } }
+ ],
+ rows: d.orders, empty: empty('No orders in this range', state.connected ? 'Orders sync every 15 minutes.' : 'Connect Amazon or load the demo account.')
+ }));
+ }, function (e) { clear(slot).appendChild(failure(e)); });
+ }
+ wrap.appendChild(pageHead('Orders', 'Every order with its own profit — near real time from the Orders API, backfilled from the all-orders report.', [q, status, btn('Search', load, 'primary sm'), rangeBar(load)]));
+ wrap.appendChild(slot);
+ q.addEventListener('keydown', function (e) { if (e.key === 'Enter') { load(); } });
+ load();
+ return wrap;
+ };
+
+ /* ---------- Restock ---------- */
+ var STATUS = {
+ out_of_stock: ['Out of stock', 'red'], critical: ['Critical', 'red'], stockout_risk: ['Gap before inbound', 'red'], reorder_now: ['Reorder now', 'amber'],
+ reorder_soon: ['Reorder soon', 'blue'], healthy: ['Healthy', 'green'], overstock: ['Overstock', 'violet'], no_sales: ['No sales', '']
+ };
+ PAGES.restock = function () {
+ return Promise.all([api('/restock'), api('/inventory-health')]).then(function (res) {
+ var d = res[0], ih = res[1], S = d.summary;
+ var k = h('div', { class: 'sgs-grid k5' },
+ kpi('SKUs tracked', int(S.skus)),
+ kpi('Need reorder', int(S.reorder), 'critical + reorder now', S.reorder ? 'tone-amber' : ''),
+ kpi('Out of stock', int(S.out_of_stock), S.lost_sales_per_day > 0 ? '≈' + money(S.lost_sales_per_day) + '/day lost' : 'none losing sales', S.out_of_stock ? 'tone-red' : 'tone-green'),
+ kpi('Suggested PO', int(S.order_units) + ' units', money(S.order_value) + ' at landed cost'),
+ kpi('Overstock', int(S.overstock), '> overstock threshold days of cover'));
+ var t = table({
+ title: 'Restock planner', sub: 'Sorted by urgency. Velocity blends 7/30/90-day sales (stock-out days excluded). Order qty covers lead time + target cover, rounded to case pack and MOQ.', csv: 'restock-plan', per: 100,
+ columns: [
+ { key: 'status', label: 'Status', render: function (r) { var s = STATUS[r.status] || [r.status, '']; return badge(s[0], s[1]); }, csvValue: function (r) { return r.status; } },
+ { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, r.title)); } },
+ { key: 'fulfillable', label: 'Available', align: 'r', fmt: int },
+ { key: 'inbound', label: 'Inbound', align: 'r', fmt: int },
+ { key: 'velocity', label: 'Units/day', align: 'r', fmt: function (v) { return num(v).toFixed(1); } },
+ { key: 'trend_pct', label: '7d vs 30d', align: 'r', render: function (r) { return r.trend_pct === null ? '—' : h('span', { class: tone(r.trend_pct) }, signed(r.trend_pct, function (v) { return pct(v, 0); })); } },
+ { key: 'days_of_cover', label: 'Days of cover', align: 'r', render: function (r) { return r.days_of_cover === null ? '—' : h('div', null, int(r.days_of_cover), meter(r.days_of_cover / 1.2, r.days_of_cover < r.lead_time ? 'red' : (r.days_of_cover < r.lead_time + 30 ? 'amber' : 'green'))); } },
+ { key: 'stockout_date', label: 'Stock-out', fmt: shortDate },
+ { key: 'reorder_by', label: 'Order by', render: function (r) { return r.reorder_by ? h('span', { class: r.reorder_by <= ymd(new Date()) ? 'neg' : '' }, shortDate(r.reorder_by)) : '—'; } },
+ { key: 'lead_time', label: 'Lead', align: 'r', fmt: function (v) { return v + 'd'; } },
+ { key: 'order_qty', label: 'Order qty', align: 'r', render: function (r) { return r.order_qty ? h('b', null, int(r.order_qty)) : '—'; }, sum: true, fmt: int },
+ { key: 'order_value', label: 'PO value', align: 'r', fmt: money, sum: true }
+ ],
+ rows: d.rows, empty: empty('No inventory yet', 'Run a product sync from Seven Gum → Products, or load the demo account.')
+ });
+ var aged = table({
+ title: 'Inventory age & storage (FBA inventory planning report)', sub: ih.snapshot ? 'Snapshot ' + ih.snapshot : 'Requested daily via the Reports API.', csv: 'inventory-age',
+ columns: [
+ { key: 'sku', label: 'SKU' }, { key: 'available', label: 'Available', align: 'r', fmt: int },
+ { key: 'age_0_90', label: '0–90d', align: 'r', fmt: int }, { key: 'age_91_180', label: '91–180d', align: 'r', fmt: int },
+ { key: 'age_181_270', label: '181–270d', align: 'r', fmt: int }, { key: 'age_271_365', label: '271–365d', align: 'r', fmt: int },
+ { key: 'age_365_plus', label: '365d+', align: 'r', fmt: int },
+ { key: 'days_of_supply', label: 'Days supply', align: 'r', fmt: int },
+ { key: 'est_storage_cost', label: 'Storage next mo.', align: 'r', fmt: money, sum: true },
+ { key: 'recommended_action', label: 'Amazon recommends' }
+ ], rows: ih.rows
+ });
+ return h('div', { class: 'sgs-stack' }, pageHead('Inventory & Restock', 'Never stock out, never over-pay storage: demand forecasting, reorder dates, PO quantities and aged-inventory risk.', [h('a', { class: 'btn sm', href: '#/profit?tab=costs' }, 'Edit lead times & case packs')]), k, t, aged);
+ });
+ };
+
+ /* ---------- PPC ---------- */
+ PAGES.ppc = function () {
+ var tab = (window.location.hash.split('tab=')[1] || 'optimizer');
+ var wrap = h('div', { class: 'sgs-stack' });
+ var body = h('div', { class: 'sgs-stack' });
+ function setTab(t) { tab = t; history.replaceState(null, '', '#/ppc?tab=' + t); draw(); }
+ function draw() {
+ clear(wrap);
+ clear(body).appendChild(loading());
+ wrap.appendChild(pageHead('PPC Manager', 'Sponsored Products automation: rule-based bid optimisation to your target ACoS, search-term harvesting, negative keywords and budget pacing — always reviewable before it touches Amazon.', [rangeBar(draw)]));
+ api('/ads/overview', { query: rangeParams() }).then(function (o) {
+ var T = o.totals;
+ var k = h('div', { class: 'sgs-grid k5' },
+ kpi('Spend', money(T.cost), int(T.clicks) + ' clicks · CPC ' + money(T.cpc)),
+ kpi('Ad sales', money(T.sales), int(T.orders) + ' orders · CVR ' + pct(T.cvr)),
+ kpi('ACoS', pct(T.acos), 'ROAS ' + (T.roas || '—'), num(T.acos) > 40 ? 'tone-red' : 'tone-green'),
+ kpi('CTR', pct(T.ctr, 2), int(T.impressions) + ' impressions'),
+ kpi('Wasted spend', money(T.wasted_spend), 'search terms with 0 orders', T.wasted_spend > 0 ? 'tone-amber' : ''));
+ var ch = card('Spend vs ad sales', chart({
+ labels: o.series.map(function (r) { return r.date; }),
+ series: [
+ { label: 'Ad sales', values: o.series.map(function (r) { return r.sales; }), type: 'bar', color: C.sales },
+ { label: 'Spend', values: o.series.map(function (r) { return r.cost; }), type: 'bar', color: C.ads },
+ { label: 'ACoS', values: o.series.map(function (r) { return r.acos; }), type: 'line', color: C.refunds, axis: 'right', fmt: function (v) { return pct(v); } }
+ ], height: 220
+ }));
+ var info = !o.ready && !state.demo ? h('div', { class: 'sgs-banner info' }, h('span', null, h('b', null, 'Amazon Ads API not connected. '), 'Add your Ads API credentials and pick a profile in Settings to sync campaigns and apply changes.'), h('a', { class: 'btn sm', href: '#/settings' }, 'Connect Ads API')) : null;
+ clear(wrap);
+ wrap.appendChild(pageHead('PPC Manager', 'Sponsored Products automation: rule-based bid optimisation to your target ACoS, search-term harvesting, negative keywords and budget pacing — always reviewable before it touches Amazon.', [rangeBar(draw)]));
+ add(wrap, [info, k, ch, h('div', null, seg([['optimizer', 'Optimizer' + (o.planned ? ' (' + o.planned + ')' : '')], ['campaigns', 'Campaigns'], ['targets', 'Keywords & targets'], ['terms', 'Search terms'], ['history', 'Change log']], tab, setTab)), body]);
+ var views = { optimizer: optimizer, campaigns: campaigns, targets: targets, terms: terms, history: historyView };
+ var view = views[pick(Object.keys(views), tab, 'optimizer')];
+ view().then(function (n) { clear(body).appendChild(n); }, function (e) { clear(body).appendChild(failure(e)); });
+ }, function (e) { clear(wrap).appendChild(failure(e)); });
+ }
+ function kcols() {
+ return [
+ { key: 'impressions', label: 'Impr.', align: 'r', fmt: int, sum: true },
+ { key: 'clicks', label: 'Clicks', align: 'r', fmt: int, sum: true },
+ { key: 'ctr', label: 'CTR', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'cpc', label: 'CPC', align: 'r', fmt: money },
+ { key: 'cost', label: 'Spend', align: 'r', fmt: money, sum: true },
+ { key: 'sales', label: 'Sales', align: 'r', fmt: money, sum: true },
+ { key: 'orders', label: 'Orders', align: 'r', fmt: int, sum: true },
+ { key: 'cvr', label: 'CVR', align: 'r', fmt: function (v) { return pct(v); } },
+ { key: 'acos', label: 'ACoS', align: 'r', render: function (r) { return r.acos === null ? '—' : h('span', { class: num(r.acos) > 40 ? 'neg' : (num(r.acos) < 25 ? 'pos' : '') }, pct(r.acos)); } }
+ ];
+ }
+ function optimizer() {
+ return api('/ads/actions').then(function (d) {
+ var KIND = { bid: ['Bid', 'blue'], pause: ['Pause', 'red'], negative: ['Negative', 'red'], harvest: ['Harvest → exact', 'green'], harvest_asin: ['Harvest ASIN', 'green'], budget: ['Budget', 'violet'] };
+ var t = table({
+ title: d.planned.length + ' recommended changes', sub: 'Nothing is pushed until you apply it. Bids move toward (revenue per click × target ACoS) and never jump more than the max step.', csv: 'ppc-plan', selectable: 'id', sort: 'kind', dir: 1, per: 100,
+ bulk: function (getSel) {
+ return h('div', { class: 'btns' },
+ btn('Apply selected', function () { var ids = getSel().map(function (r) { return r.id; }); if (!ids.length) { toast('Select rows first'); return Promise.resolve(); } return api('/ads/apply', { method: 'POST', body: { ids: ids } }).then(function (r) { toast(r.applied + ' applied' + (r.failed ? ', ' + r.failed + ' failed' : ''), r.failed ? 'bad' : 'ok'); draw(); }); }, 'green sm'),
+ btn('Dismiss selected', function () { var ids = getSel().map(function (r) { return r.id; }); if (!ids.length) { return Promise.resolve(); } return api('/ads/dismiss', { method: 'POST', body: { ids: ids } }).then(function () { draw(); }); }, 'sm'));
+ },
+ columns: [
+ { key: 'kind', label: 'Action', render: function (r) { var k = KIND[r.kind] || [r.kind, '']; return badge(k[0], k[1]); } },
+ { key: 'label', label: 'Keyword / target / campaign', render: function (r) { return h('b', null, r.label); } },
+ { key: 'old_value', label: 'Now', align: 'r' },
+ { key: 'new_value', label: 'Proposed', align: 'r', render: function (r) { var up = num(r.new_value) > num(r.old_value); return h('b', { class: r.kind === 'bid' || r.kind === 'budget' ? (up ? 'pos' : 'neg') : '' }, r.new_value); } },
+ { key: 'reason', label: 'Why', render: function (r) { return h('span', { class: 'small muted' }, r.reason); } }
+ ],
+ rows: d.planned,
+ actions: btn('Re-run optimizer', function () { return api('/ads/optimize', { method: 'POST' }).then(function (r) { toast(r.bids + ' bid, ' + r.terms + ' search-term and ' + r.budgets + ' budget changes planned', 'ok'); draw(); }); }, 'primary'),
+ empty: empty('No changes recommended', 'Run the optimizer after your ads data syncs (daily).')
+ });
+ return t;
+ });
+ }
+ function campaigns() {
+ return api('/ads/campaigns', { query: rangeParams() }).then(function (d) {
+ return table({ title: 'Campaigns', csv: 'campaigns', sort: 'cost', columns: [
+ { key: 'name', label: 'Campaign', render: function (r) { return h('div', null, h('b', null, r.name), h('div', { class: 'small muted' }, r.targeting_type + ' · ' + (r.bidding_strategy || '').replace(/_/g, ' ').toLowerCase())); } },
+ { key: 'state', label: 'State', render: function (r) { return badge(r.state, r.state === 'ENABLED' ? 'green' : ''); } },
+ { key: 'daily_budget', label: 'Budget/day', align: 'r', fmt: money }
+ ].concat(kcols()), rows: d.rows, onRow: function (r) { targetsDrawer(r); } });
+ });
+ }
+ function targetsDrawer(c) {
+ var dr = drawer(c.name, loading());
+ api('/ads/targets', { query: Object.assign(rangeParams(), { campaign: c.campaign_id }) }).then(function (d) {
+ clear(dr.body).appendChild(table({ title: 'Keywords & targets', search: false, sort: 'cost', columns: [
+ { key: 'keyword_text', label: 'Keyword / target', render: function (r) { return h('div', null, h('b', null, r.keyword_text), ' ', badge(r.match_type)); } },
+ { key: 'bid', label: 'Bid', align: 'r', fmt: money }
+ ].concat(kcols()), rows: d.rows }));
+ });
+ }
+ function targets() {
+ return api('/ads/targets', { query: rangeParams() }).then(function (d) {
+ return table({ title: 'Keywords & product targets', csv: 'ppc-targets', sort: 'cost', columns: [
+ { key: 'keyword_text', label: 'Keyword / target', render: function (r) { return h('div', null, h('b', null, r.keyword_text), ' ', badge(r.match_type), h('div', { class: 'small muted' }, r.campaign)); } },
+ { key: 'state', label: 'State', render: function (r) { return badge(r.state, r.state === 'ENABLED' ? 'green' : ''); } },
+ { key: 'bid', label: 'Bid', align: 'r', fmt: money }
+ ].concat(kcols()), rows: d.rows });
+ });
+ }
+ function terms() {
+ return api('/ads/search-terms', { query: rangeParams() }).then(function (d) {
+ return table({ title: 'Customer search terms', sub: 'What shoppers actually typed. Zero-order terms with many clicks are negative candidates; converting terms are harvest candidates.', csv: 'search-terms', sort: 'cost', columns: [
+ { key: 'search_term', label: 'Search term', render: function (r) { return h('div', null, h('b', null, r.search_term), h('div', { class: 'small muted' }, 'via ' + r.keyword_text + ' (' + r.match_type + ')')); } }
+ ].concat(kcols()), rows: d.rows });
+ });
+ }
+ function historyView() {
+ return api('/ads/actions').then(function (d) {
+ return table({ title: 'Change log', csv: 'ppc-change-log', sort: 'applied_at', columns: [
+ { key: 'applied_at', label: 'When', fmt: function (v, r) { return dateTime(v || r.created_at); } },
+ { key: 'kind', label: 'Action' }, { key: 'label', label: 'Entity' },
+ { key: 'old_value', label: 'From', align: 'r' }, { key: 'new_value', label: 'To', align: 'r' },
+ { key: 'status', label: 'Status', render: function (r) { return badge(r.status, { applied: 'green', failed: 'red', dismissed: '' }[r.status]); } },
+ { key: 'error', label: 'Error', render: function (r) { return h('span', { class: 'small neg' }, r.error || ''); } }
+ ], rows: d.history });
+ });
+ }
+ draw();
+ return wrap;
+ };
+
+ /* ---------- Keywords ---------- */
+ PAGES.keywords = function () {
+ return api('/keywords').then(function (d) {
+ var asinSel = h('select', null, d.asins.map(function (a) { return h('option', { value: a }, a); }));
+ var kwInput = h('textarea', { placeholder: 'One keyword per line', rows: 3 });
+ var prio = h('select', null, h('option', { value: 1 }, 'High'), h('option', { value: 2, selected: true }, 'Medium'), h('option', { value: 3 }, 'Low'));
+ var addCard = card('Track keywords', h('div', { class: 'form-grid' },
+ field('ASIN', asinSel), field('Priority', prio), h('div', { class: 'field', style: { gridColumn: 'span 2' } }, h('label', null, 'Keywords'), kwInput),
+ h('div', { class: 'field' }, h('label', null, ' '), btn('Add', function () {
+ return api('/keywords', { method: 'POST', body: { asin: asinSel.value, keywords: kwInput.value, priority: prio.value } }).then(function (r) { toast(r.added + ' keyword(s) tracked', 'ok'); route(); });
+ }, 'primary'))), { sub: 'Share of search comes from Brand Analytics Search Query Performance (weekly, brand-registered sellers).' });
+
+ var tracked = table({
+ title: 'Tracked keywords — share of search', sub: d.rows[0] && d.rows[0].week ? 'Week of ' + shortDate(d.rows[0].week) : 'Waiting for the first weekly SQP report.', csv: 'tracked-keywords', sort: 'volume',
+ columns: [
+ { key: 'keyword', label: 'Keyword', render: function (r) { return h('div', null, h('b', null, r.keyword), ' ', badge(['', 'High', 'Med', 'Low'][r.priority] || '', r.priority == 1 ? 'dark' : '')); } },
+ { key: 'asin', label: 'ASIN', mono: true, render: function (r) { return h('a', { href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, r.asin); } },
+ { key: 'volume', label: 'Search volume / wk', align: 'r', fmt: int },
+ { key: 'impr_share', label: 'Impr. share', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'click_share', label: 'Click share', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'purchase_share', label: 'Purchase share', align: 'r', render: function (r) { return h('span', null, pct(r.purchase_share, 2), r.purchase_share_prev !== null ? delta(r.purchase_share, r.purchase_share_prev) : null); } },
+ { key: 'ctr', label: 'CTR', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'cvr', label: 'CVR', align: 'r', fmt: function (v) { return pct(v, 1); } },
+ { key: 'trend', label: '8-wk impr. share', sort: false, csv: false, render: function (r) { return r.trend && r.trend.length ? sparkline(r.trend, C.sales) : '—'; } },
+ { key: 'id', label: '', sort: false, csv: false, render: function (r) { return btn('×', function () { return api('/keywords/' + r.id, { method: 'DELETE' }).then(route); }, 'sm ghost', { title: 'Stop tracking' }); } }
+ ], rows: d.rows, empty: empty('No keywords tracked', 'Add keywords above or discover them below.')
+ });
+
+ var resAsin = h('input', { type: 'text', placeholder: 'ASIN(s), comma separated', value: d.asins.slice(0, 3).join(', ') });
+ var resSlot = h('div');
+ var research = card('Keyword research', h('div', { class: 'sgs-stack' },
+ h('div', { class: 'btns' }, resAsin, btn('Find keywords', function () {
+ clear(resSlot).appendChild(loading());
+ return api('/keywords/research', { method: 'POST', body: { asins: resAsin.value.split(/[\s,]+/).filter(Boolean) } }).then(function (r) {
+ clear(resSlot).appendChild(table({ search: true, csv: 'keyword-ideas', sort: 'orders', columns: [
+ { key: 'keyword', label: 'Keyword', render: function (k) { return h('b', null, k.keyword); } },
+ { key: 'source', label: 'Source', render: function (k) { return badge({ amazon_ads: 'Amazon Ads', your_ads: 'Your search terms', both: 'Both' }[k.source] || k.source, k.source === 'both' ? 'green' : 'blue'); } },
+ { key: 'suggested_bid', label: 'Suggested bid', align: 'r', fmt: money },
+ { key: 'impression_rank', label: 'Impr. rank', align: 'r' },
+ { key: 'orders', label: 'Your orders', align: 'r', fmt: int },
+ { key: 'acos', label: 'Your ACoS', align: 'r', fmt: function (v) { return pct(v); } },
+ { key: 'keyword', label: '', sort: false, csv: false, render: function (k) { return btn('Track', function () { return api('/keywords', { method: 'POST', body: { asin: (resAsin.value.split(/[\s,]+/)[0] || asinSel.value), keywords: [k.keyword] } }).then(function () { toast('Tracking “' + k.keyword + '”', 'ok'); }); }, 'sm'); } }
+ ], rows: r.rows, empty: empty('No ideas yet', 'Connect the Ads API for Amazon keyword recommendations; converting search terms appear once ads data syncs.') }));
+ });
+ }, 'primary')), resSlot), { sub: 'Amazon Ads keyword recommendations for your ASINs + search terms that already convert in your campaigns.' });
+
+ var qAsin = h('select', null, d.asins.map(function (a) { return h('option', { value: a }, a); }));
+ var qSlot = h('div');
+ var sqp = card('All search queries for an ASIN (Brand Analytics)', h('div', { class: 'sgs-stack' }, h('div', { class: 'btns' }, qAsin, btn('Load', function () {
+ return api('/keywords/queries', { query: { asin: qAsin.value } }).then(function (r) {
+ clear(qSlot).appendChild(table({ search: true, csv: 'sqp-queries', sort: 'query_volume', columns: [
+ { key: 'keyword', label: 'Query', render: function (k) { return h('b', null, k.keyword); } },
+ { key: 'query_volume', label: 'Volume', align: 'r', fmt: int },
+ { key: 'impr_share', label: 'Impr. share', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'click_share', label: 'Click share', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'purchase_share', label: 'Purchase share', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'purchases_asin', label: 'Your purchases', align: 'r', fmt: int }
+ ], rows: r.rows }));
+ });
+ }, 'sm')), qSlot));
+
+ return h('div', { class: 'sgs-stack' }, pageHead('Keywords', 'Track share of search per keyword, discover new keywords and see every query shoppers use to find your ASINs.'), addCard, tracked, research, sqp);
+ });
+ };
+
+ /* ---------- Listings ---------- */
+ PAGES.listings = function () {
+ return api('/listings').then(function (d) {
+ var avg = d.rows.length ? Math.round(d.rows.reduce(function (a, r) { return a + num(r.score); }, 0) / d.rows.length) : null;
+ var sup = d.rows.filter(function (r) { return r.status === 'suppressed'; }).length;
+ var k = h('div', { class: 'sgs-grid k4' },
+ kpi('Average listing score', avg === null ? '—' : avg + '/100', d.rows.length + ' listings audited', avg !== null && avg < 75 ? 'tone-amber' : 'tone-green'),
+ kpi('Suppressed / inactive', int(sup), 'not buyable right now', sup ? 'tone-red' : 'tone-green'),
+ kpi('Errors to fix', int(d.rows.reduce(function (a, r) { return a + r.findings.filter(function (f) { return f.severity === 'error'; }).length; }, 0)), 'policy & suppression risks'),
+ kpi('Below grade B', int(d.rows.filter(function (r) { return r.score < 80; }).length), 'conversion upside'));
+ var warn = !d.seller_id_set && !state.demo ? h('div', { class: 'sgs-banner warn' }, h('span', null, h('b', null, 'Seller ID needed. '), 'Listing audits read your listings through the Listings Items API, which needs your Merchant Token.'), h('a', { class: 'btn sm', href: '#/settings' }, 'Add Seller ID')) : null;
+ var t = table({
+ title: 'Listing quality', sub: 'Title, bullets, description, backend keywords, images, Amazon issues and tracked-keyword coverage. Click a row for the full audit and an AI rewrite.', csv: 'listing-audit', sort: 'score', dir: 1,
+ columns: [
+ { key: 'score', label: 'Score', render: function (r) { var g = r.score >= 90 ? 'A' : r.score >= 80 ? 'B' : r.score >= 65 ? 'C' : r.score >= 50 ? 'D' : 'F'; return h('div', { class: 'btns' }, h('span', { class: 'grade ' + g }, g), h('b', null, r.score)); } },
+ { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, (r.snapshot && r.snapshot.title) || '')); } },
+ { key: 'status', label: 'Status', render: function (r) { return badge(r.status, r.status === 'active' ? 'green' : (r.status === 'suppressed' ? 'red' : '')); } },
+ { key: 'errors', label: 'Errors', align: 'r', render: function (r) { var n = r.findings.filter(function (f) { return f.severity === 'error'; }).length; return n ? h('b', { class: 'neg' }, n) : '0'; } },
+ { key: 'warnings', label: 'Warnings', align: 'r', render: function (r) { return String(r.findings.filter(function (f) { return f.severity === 'warning'; }).length); } },
+ { key: 'audited_at', label: 'Audited', fmt: function (v) { return dateTime(v); } }
+ ],
+ rows: d.rows, onRow: listingDrawer,
+ actions: btn('Audit all listings', function () { return api('/listings/audit', { method: 'POST', body: {} }).then(function (r) { toast(r.audited + ' listing(s) audited', 'ok'); route(); }); }, 'primary'),
+ empty: empty('No audits yet', 'Click “Audit all listings”.')
+ });
+ return h('div', { class: 'sgs-stack' }, pageHead('Listing Optimizer', 'Grade every listing against Amazon’s style guide and the 2025 title policy, catch suppressions, and rewrite copy with AI in your brand voice.'), warn, k, t);
+ });
+ };
+
+ function findingsList(findings) {
+ var SEV = { error: ['Error', 'red'], warning: ['Warning', 'amber'], info: ['Tip', 'blue'] };
+ return h('div', null, findings.length ? findings.map(function (f) { var s = SEV[f.severity] || [f.severity, '']; return h('div', { class: 'finding' }, badge(s[0], s[1]), h('span', { class: 'muted small', style: { minWidth: '80px' } }, f.section), h('span', null, f.message)); }) : empty('Perfect', 'No findings.'));
+ }
+
+ function listingDrawer(r) {
+ var c = r.snapshot || {};
+ var aiSlot = h('div');
+ var content = h('div', { class: 'sgs-stack' },
+ h('div', { class: 'sgs-grid k3' }, kpi('Score', r.score + '/100'), kpi('Status', r.status), kpi('Images', (c.images || []).length)),
+ card('Findings', findingsList(r.findings)),
+ card('Current content', h('dl', { class: 'kv' },
+ h('dt', null, 'Title (' + (c.title || '').length + ' chars)'), h('dd', null, c.title || '—'),
+ h('dt', null, 'Bullets'), h('dd', null, (c.bullets || []).length ? h('ol', { style: { margin: 0, paddingLeft: '18px' } }, (c.bullets || []).map(function (b) { return h('li', null, b); })) : '—'),
+ h('dt', null, 'Backend terms'), h('dd', null, c.backend || '—'),
+ h('dt', null, 'Description'), h('dd', { class: 'small' }, (c.description || '—').slice(0, 600)))),
+ card('AI rewrite', aiSlot, { sub: 'Uses your AI provider (Seven Gum → AI) with your tracked keywords. Review before publishing — nothing is pushed automatically.' }));
+ aiSlot.appendChild(btn('Generate optimized copy', function () {
+ clear(aiSlot).appendChild(loading());
+ return api('/listings/ai', { method: 'POST', body: { sku: r.sku } }).then(function (x) {
+ var dft = x.draft;
+ var text = 'TITLE:\n' + dft.title + '\n\nBULLETS:\n- ' + dft.bullets.join('\n- ') + '\n\nBACKEND SEARCH TERMS:\n' + dft.backend;
+ clear(aiSlot).appendChild(h('div', { class: 'sgs-stack' },
+ h('div', { class: 'btns' }, badge('New score ' + x.score.score + '/100', x.score.score >= r.score ? 'green' : 'amber'), h('span', { class: 'muted small' }, x.provider + ' · ' + x.model), btn('Copy', function () { return copy(text); }, 'sm')),
+ h('div', { class: 'pre' }, text), findingsList(x.score.findings)));
+ }, function (e) { clear(aiSlot).appendChild(failure(e)); });
+ }, 'primary'));
+ drawer(r.sku, content, [
+ r.asin ? h('a', { class: 'btn sm', href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, 'View on Amazon') : null,
+ btn('Re-audit', function () { return api('/listings/audit', { method: 'POST', body: { sku: r.sku } }).then(function () { toast('Audited', 'ok'); route(); }); }, 'sm')
+ ]);
+ }
+
+ /* ---------- Monitor ---------- */
+ PAGES.monitor = function () {
+ return api('/monitor').then(function (d) {
+ var mine = d.rows.filter(function (r) { return +r.is_mine; });
+ var won = mine.filter(function (r) { return +r.buybox_is_mine; }).length;
+ var k = h('div', { class: 'sgs-grid k4' },
+ kpi('Buy Box share', mine.length ? pct(won / mine.length * 100) : '—', won + ' of ' + mine.length + ' ASINs', won === mine.length ? 'tone-green' : 'tone-amber'),
+ kpi('Hijackers / other sellers', int(d.hijacks.length), 'on your listings right now', d.hijacks.length ? 'tone-red' : 'tone-green'),
+ kpi('ASINs watched', int(d.watching), 'yours + competitors'),
+ kpi('Competitors', int(d.rows.length - mine.length), 'add more in Settings'));
+ var hij = d.hijacks.length ? table({ title: '⚠️ Sellers on your listings', search: false, columns: [
+ { key: 'asin', label: 'ASIN', mono: true, render: function (r) { return h('a', { href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, r.asin); } },
+ { key: 'seller_id', label: 'Seller', mono: true, render: function (r) { return h('a', { href: 'https://www.amazon.' + marketInfo().tld + '/sp?seller=' + encodeURIComponent(r.seller_id), target: '_blank', rel: 'noopener' }, r.seller_id); } },
+ { key: 'is_fba', label: 'Fulfilment', render: function (r) { return +r.is_fba ? 'FBA' : 'FBM'; } },
+ { key: 'last_price', label: 'Price', align: 'r', fmt: money },
+ { key: 'first_seen', label: 'First seen', fmt: function (v) { return dateTime(v); } }
+ ], rows: d.hijacks }) : null;
+ var t = table({
+ title: 'Listing watch', sub: 'Buy Box owner, price, offers and BSR from the Product Pricing API every 15 minutes (rotating batches). Click for price/BSR history.', csv: 'listing-watch', sort: 'is_mine',
+ columns: [
+ { key: 'asin', label: 'ASIN', mono: true, render: function (r) { return h('div', null, h('b', null, r.asin), ' ', +r.is_mine ? badge('yours', 'dark') : badge('competitor')); } },
+ { key: 'buybox_is_mine', label: 'Buy Box', render: function (r) { return +r.is_mine ? (+r.buybox_is_mine ? badge('Winning', 'green') : badge(r.buybox_seller ? 'Lost' : 'Suppressed', 'red')) : (r.buybox_seller || '—'); } },
+ { key: 'buybox_price', label: 'Buy Box price', align: 'r', fmt: money },
+ { key: 'my_price', label: 'Your price', align: 'r', fmt: money },
+ { key: 'lowest_price', label: 'Lowest', align: 'r', fmt: money },
+ { key: 'offer_count', label: 'Offers', align: 'r', fmt: int },
+ { key: 'other_sellers', label: 'Other sellers', align: 'r', render: function (r) { return +r.is_mine && +r.other_sellers ? h('b', { class: 'neg' }, r.other_sellers) : String(r.other_sellers || 0); } },
+ { key: 'bsr', label: 'BSR', align: 'r', fmt: int },
+ { key: 'captured_at', label: 'Checked', fmt: function (v) { return dateTime(v); } }
+ ],
+ rows: d.rows, onRow: monitorDrawer,
+ actions: btn('Check now', function () { return api('/monitor/run', { method: 'POST' }).then(function (r) { toast(r.checked + ' ASIN(s) checked, ' + r.alerts + ' alert(s)', 'ok'); route(); }); }, 'primary'),
+ empty: empty('Nothing watched yet', 'Your ASINs appear after the first product sync.')
+ });
+ return h('div', { class: 'sgs-stack' }, pageHead('Hijackers & Buy Box', 'Instant alerts when a new seller jumps on your listing, when you lose the Buy Box, or when a competitor slashes price — with Keepa-style history.'), k, hij, t);
+ });
+ };
+
+ function monitorDrawer(r) {
+ var dr = drawer(r.asin + (+r.is_mine ? ' (yours)' : ''), loading(), [h('a', { class: 'btn sm', href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, 'View on Amazon')]);
+ api('/monitor/history', { query: { asin: r.asin, days: 90 } }).then(function (d) {
+ var hst = d.history;
+ clear(dr.body).appendChild(h('div', { class: 'sgs-stack' },
+ card('Price history', chart({ labels: hst.map(function (x) { return x.captured_at; }), series: [
+ { label: 'Buy Box', values: hst.map(function (x) { return x.buybox_price; }), type: 'line', color: C.sales },
+ { label: 'Lowest', values: hst.map(function (x) { return x.lowest_price; }), type: 'line', color: C.refunds },
+ { label: 'Your price', values: hst.map(function (x) { return x.my_price; }), type: 'line', color: C.profit }
+ ], height: 200, fmt: money })),
+ card('Best Sellers Rank', chart({ labels: hst.map(function (x) { return x.captured_at; }), series: [
+ { label: 'BSR (lower is better)', values: hst.map(function (x) { return x.bsr; }), type: 'area', color: C.units, fmt: int }
+ ], height: 170, fmt: int })),
+ table({ title: 'Sellers seen on this listing', search: false, columns: [
+ { key: 'seller_id', label: 'Seller', mono: true, render: function (x) { return h('span', null, x.seller_id, ' ', +x.is_me ? badge('you', 'dark') : null); } },
+ { key: 'is_fba', label: 'FBA', render: function (x) { return +x.is_fba ? 'FBA' : 'FBM'; } },
+ { key: 'last_price', label: 'Price', align: 'r', fmt: money },
+ { key: 'active', label: 'Active', render: function (x) { return +x.active ? badge('active', 'green') : badge('gone'); } },
+ { key: 'first_seen', label: 'First seen', fmt: function (v) { return dateTime(v); } },
+ { key: 'last_seen', label: 'Last seen', fmt: function (v) { return dateTime(v); } }
+ ], rows: d.sellers })));
+ }, function (e) { clear(dr.body).appendChild(failure(e)); });
+ }
+
+ /* ---------- Reviews ---------- */
+ PAGES.reviews = function () {
+ return Promise.all([api('/reviews'), api('/feedback')]).then(function (res) {
+ var d = res[0], fb = res[1];
+ var off = !d.enabled ? h('div', { class: 'sgs-banner info' }, h('span', null, h('b', null, 'Automatic review requests are off. '), 'Turn them on in Settings — the suite then sends Amazon’s own “Request a Review” for every eligible order (5–30 days after delivery), skipping refunded and returned orders.'), h('a', { class: 'btn sm', href: '#/settings' }, 'Enable')) : null;
+ var k = h('div', { class: 'sgs-grid k4' },
+ kpi('Requests sent (30d)', int(d.by_status.sent), 'via Solicitations API', 'tone-green'),
+ kpi('Waiting for window', int(d.pending), 'eligible orders queued'),
+ kpi('Ineligible', int(d.by_status.ineligible), 'outside window / already requested'),
+ kpi('Seller feedback', fb.avg === null ? '—' : fb.avg + '★', fb.count + ' ratings · ' + pct(fb.negative_pct) + ' negative', num(fb.negative_pct) > 5 ? 'tone-red' : ''));
+ var ch = card('Requests sent per day', chart({ labels: d.daily.map(function (x) { return x.d; }), series: [{ label: 'Sent', values: d.daily.map(function (x) { return x.sent; }), type: 'bar', color: C.units, fmt: int }], height: 180, fmt: int }));
+ var t = table({ title: 'Recent requests', csv: 'review-requests', sort: 'attempted_at', columns: [
+ { key: 'attempted_at', label: 'When', fmt: function (v) { return dateTime(v); } },
+ { key: 'amazon_order_id', label: 'Order', mono: true },
+ { key: 'status', label: 'Result', render: function (r) { return badge(r.status, { sent: 'green', ineligible: '', error: 'red' }[r.status]); } },
+ { key: 'message', label: 'Note', render: function (r) { return h('span', { class: 'small muted' }, r.message); } }
+ ], rows: d.recent, actions: btn('Send eligible now', function () { return api('/reviews/run', { method: 'POST' }).then(function (r) { toast(r.sent + ' sent, ' + r.ineligible + ' ineligible', 'ok'); route(); }); }, 'primary') });
+ var f = table({ title: 'Seller feedback', csv: 'seller-feedback', sort: 'feedback_date', columns: [
+ { key: 'feedback_date', label: 'Date', fmt: shortDate },
+ { key: 'rating', label: 'Rating', render: function (r) { return h('span', { class: +r.rating <= 2 ? 'neg' : '' }, '★'.repeat(+r.rating) + '☆'.repeat(5 - +r.rating)); } },
+ { key: 'comments', label: 'Comment' }, { key: 'amazon_order_id', label: 'Order', mono: true }
+ ], rows: fb.rows });
+ return h('div', { class: 'sgs-stack' }, pageHead('Review Requests', 'Fully Amazon-compliant review automation — the same request as the Seller Central button, sent at the best moment for every order.'), off, k, ch, t, f);
+ });
+ };
+
+ /* ---------- Reimbursements ---------- */
+ PAGES.reimbursements = function () {
+ var status = (window.location.hash.split('status=')[1] || 'open');
+ return api('/reimbursements', { query: { status: status } }).then(function (d) {
+ var KIND = { lost_warehouse: ['Lost in warehouse', 'red'], damaged_warehouse: ['Damaged in warehouse', 'amber'], refund_no_return: ['Refunded, never returned', 'violet'], fee_overcharge: ['FBA fee overcharge', 'blue'] };
+ var open = d.rows.filter(function (r) { return r.status === 'open'; });
+ var k = h('div', { class: 'sgs-grid k4' },
+ kpi('Recoverable (open)', money(open.reduce(function (a, r) { return a + num(r.est_amount); }, 0)), open.length + ' case(s) ready to file', 'tone-green'),
+ kpi('Reimbursed by Amazon (12 mo)', money(d.summary.reimbursed_12m), 'from the reimbursements report'),
+ kpi('Lost / damaged units', int(d.rows.filter(function (r) { return r.kind.indexOf('warehouse') >= 0; }).reduce(function (a, r) { return a + num(r.qty); }, 0)), 'in this view'),
+ kpi('Refund-no-return orders', int(d.rows.filter(function (r) { return r.kind === 'refund_no_return'; }).length), '> 45 days old'));
+ var t = table({
+ title: 'Claims', sub: 'Each case comes with a ready-to-paste Seller Central message. Click a row → copy → open a case → mark as filed.', csv: 'reimbursement-claims', sort: 'est_amount',
+ tools: seg([['open', 'Open'], ['filed', 'Filed'], ['reimbursed', 'Reimbursed'], ['dismissed', 'Dismissed'], ['all', 'All']], status, function (v) { window.location.hash = '#/reimbursements?status=' + v; }),
+ columns: [
+ { key: 'kind', label: 'Type', render: function (r) { var x = KIND[r.kind] || [r.kind, '']; return badge(x[0], x[1]); } },
+ { key: 'sku', label: 'SKU / order', render: function (r) { return h('div', null, h('b', null, r.sku || '—'), h('div', { class: 'small muted mono' }, r.reference_id || r.fnsku)); } },
+ { key: 'qty', label: 'Units', align: 'r', fmt: int, sum: true },
+ { key: 'est_amount', label: 'Est. value', align: 'r', fmt: money, sum: true },
+ { key: 'event_date', label: 'Event', fmt: shortDate },
+ { key: 'status', label: 'Status', render: function (r) { return badge(r.status, { open: 'amber', filed: 'blue', reimbursed: 'green', dismissed: '' }[r.status]); } },
+ { key: 'amazon_case_id', label: 'Case ID', mono: true }
+ ],
+ rows: d.rows, onRow: caseDrawer,
+ actions: btn('Scan now', function () { return api('/reimbursements/scan', { method: 'POST' }).then(function (r) { toast(r.new + ' new case(s), ' + money(r.value) + ' total', 'ok'); route(); }); }, 'primary'),
+ empty: empty('Nothing to claim', 'The auditor runs daily over your inventory ledger, refunds and fees.')
+ });
+ var recent = table({ title: 'Reimbursements received', csv: 'reimbursements', columns: [
+ { key: 'approval_date', label: 'Date', fmt: shortDate }, { key: 'reason', label: 'Reason' }, { key: 'sku', label: 'SKU' },
+ { key: 'qty_cash', label: 'Units (cash)', align: 'r', fmt: int }, { key: 'qty_inventory', label: 'Units (inventory)', align: 'r', fmt: int },
+ { key: 'amount', label: 'Amount', align: 'r', fmt: money, sum: true }, { key: 'case_id', label: 'Case', mono: true }
+ ], rows: d.recent });
+ return h('div', { class: 'sgs-stack' }, pageHead('Reimbursements', 'Find money Amazon owes you: lost and damaged FBA inventory, refunds that were never returned, and FBA fee overcharges.'), k, t, recent);
+ });
+ };
+
+ function caseDrawer(r) {
+ var caseId = h('input', { type: 'text', value: r.amazon_case_id || '', placeholder: 'Seller Central case ID' });
+ function set(status) { return api('/reimbursements/' + r.id, { method: 'POST', body: { status: status, amazon_case_id: caseId.value } }).then(function () { toast('Case updated', 'ok'); dr.close(); route(); }); }
+ var ev = r.evidence || {};
+ var dr = drawer('Claim · ' + (r.sku || r.reference_id), h('div', { class: 'sgs-stack' },
+ h('dl', { class: 'kv' }, Object.keys(ev).map(function (k) { return [h('dt', null, k.replace(/_/g, ' ')), h('dd', null, String(ev[k]))]; })),
+ card('Message for Seller Support', h('div', { class: 'sgs-stack' }, h('div', { class: 'pre' }, r.notes || ''), h('div', { class: 'btns' }, btn('Copy message', function () { return copy(r.notes || ''); }, 'primary'), h('a', { class: 'btn', href: 'https://sellercentral.amazon.' + (marketInfo().tld || 'com') + '/help/hub/support', target: '_blank', rel: 'noopener' }, 'Open Seller Support')))),
+ card('Track', h('div', { class: 'btns' }, caseId, btn('Mark filed', function () { return set('filed'); }), btn('Reimbursed', function () { return set('reimbursed'); }, 'green'), btn('Dismiss', function () { return set('dismissed'); }, 'danger'))))
+ );
+ }
+
+ /* ---------- Analytics: traffic, returns ---------- */
+ PAGES.analytics = function () {
+ var wrap = h('div', { class: 'sgs-stack' });
+ function draw() {
+ clear(wrap).appendChild(loading());
+ Promise.all([api('/traffic', { query: rangeParams() }), api('/returns', { query: rangeParams() })]).then(function (res) {
+ var t = res[0], r = res[1];
+ var S = t.series;
+ var sess = S.reduce(function (a, x) { return a + num(x.sessions); }, 0), units = S.reduce(function (a, x) { return a + num(x.units); }, 0);
+ var bb = S.length ? S.reduce(function (a, x) { return a + num(x.buy_box_pct); }, 0) / S.length : null;
+ var retUnits = r.by_reason.reduce(function (a, x) { return a + num(x.units); }, 0);
+ clear(wrap);
+ add(wrap, [
+ pageHead('Traffic & Returns', 'Business Reports (sessions, page views, conversion, Buy Box %) per day and per ASIN, plus why customers return your products.', [rangeBar(draw)]),
+ h('div', { class: 'sgs-grid k4' }, kpi('Sessions', int(sess)), kpi('Unit session %', sess ? pct(units / sess * 100, 2) : '—', int(units) + ' units'), kpi('Avg Buy Box %', pct(bb)), kpi('Returned units', int(retUnits), r.by_sku.length + ' SKUs')),
+ card('Sessions & conversion', chart({ labels: S.map(function (x) { return x.date; }), series: [
+ { label: 'Sessions', values: S.map(function (x) { return x.sessions; }), type: 'bar', color: C.grey, fmt: int },
+ { label: 'Unit session %', values: S.map(function (x) { return num(x.sessions) ? num(x.units) / num(x.sessions) * 100 : null; }), type: 'line', color: C.profit, axis: 'right', fmt: function (v) { return pct(v, 1); } }
+ ], fmt: int, height: 220 })),
+ table({ title: 'By ASIN', csv: 'traffic-by-asin', sort: 'sessions', columns: [
+ { key: 'asin', label: 'ASIN', mono: true, render: function (x) { return h('div', null, h('b', null, x.asin), h('div', { class: 'small muted' }, x.sku)); } },
+ { key: 'sessions', label: 'Sessions', align: 'r', fmt: int, sum: true }, { key: 'page_views', label: 'Page views', align: 'r', fmt: int, sum: true },
+ { key: 'units', label: 'Units', align: 'r', fmt: int, sum: true }, { key: 'cvr', label: 'Unit session %', align: 'r', fmt: function (v) { return pct(v, 2); } },
+ { key: 'buy_box_pct', label: 'Buy Box %', align: 'r', render: function (x) { return h('span', { class: num(x.buy_box_pct) < 90 ? 'neg' : '' }, pct(x.buy_box_pct)); } },
+ { key: 'sales', label: 'Ordered sales', align: 'r', fmt: money, sum: true }
+ ], rows: t.asins }),
+ h('div', { class: 'sgs-grid k2' },
+ card('Return reasons', h('ul', { class: 'list' }, r.by_reason.map(function (x) { return h('li', null, h('span', null, x.reason.replace(/_/g, ' ').toLowerCase()), h('div', { class: 'btns', style: { minWidth: '160px' } }, meter(num(x.units) / Math.max(1, retUnits) * 100, 'amber'), h('b', null, int(x.units)))); }))),
+ table({ title: 'Returns by SKU', search: false, columns: [{ key: 'sku', label: 'SKU' }, { key: 'units', label: 'Returned', align: 'r', fmt: int }, { key: 'sellable', label: 'Back to stock', align: 'r', fmt: int }], rows: r.by_sku })),
+ table({ title: 'Return log', csv: 'returns', columns: [
+ { key: 'return_date', label: 'Date', fmt: shortDate }, { key: 'amazon_order_id', label: 'Order', mono: true }, { key: 'sku', label: 'SKU' },
+ { key: 'qty', label: 'Qty', align: 'r' }, { key: 'reason', label: 'Reason' }, { key: 'disposition', label: 'Disposition', render: function (x) { return badge(x.disposition, x.disposition === 'SELLABLE' ? 'green' : 'red'); } },
+ { key: 'comments', label: 'Customer comment' }
+ ], rows: r.rows })
+ ]);
+ }, function (e) { clear(wrap).appendChild(failure(e)); });
+ }
+ draw();
+ return wrap;
+ };
+
+ /* ---------- Research ---------- */
+ PAGES.research = function () {
+ var kw = h('input', { type: 'text', placeholder: 'e.g. sugar free gum', style: { minWidth: '320px' } });
+ var slot = h('div');
+ function search() {
+ if (!kw.value.trim()) { return Promise.resolve(); }
+ clear(slot).appendChild(loading());
+ return api('/research/search', { method: 'POST', body: { keywords: kw.value } }).then(function (d) {
+ var S = d.summary;
+ clear(slot).appendChild(h('div', { class: 'sgs-stack' },
+ h('div', { class: 'sgs-grid k5' },
+ kpi('Opportunity score', S.opportunity === null ? '—' : S.opportunity + '/100', 'demand vs competition vs price', num(S.opportunity) >= 60 ? 'tone-green' : (num(S.opportunity) >= 40 ? 'tone-amber' : 'tone-red')),
+ kpi('Avg price', money(S.avg_price)), kpi('Avg monthly units', int(S.avg_units), 'BSR estimate ±40%'),
+ kpi('Page-1 revenue / mo', money0(S.total_revenue)), kpi('Top-3 share', pct(S.top3_share_pct), 'revenue concentration')),
+ table({ title: 'Results', csv: 'research-' + kw.value.replace(/\W+/g, '-'), sort: 'est_revenue', columns: [
+ { key: 'image', label: '', sort: false, csv: false, render: function (r) { return r.image ? h('img', { class: 'thumb', src: r.image, alt: '', loading: 'lazy' }) : ''; } },
+ { key: 'title', label: 'Product', render: function (r) { return h('div', null, h('a', { href: amazonUrl(r.asin), target: '_blank', rel: 'noopener', class: 'title' }, r.title), h('div', { class: 'small muted' }, r.asin + ' · ' + (r.brand || '—') + ' · ' + (r.category || ''))); } },
+ { key: 'price', label: 'Price', align: 'r', fmt: money },
+ { key: 'bsr', label: 'BSR', align: 'r', fmt: int },
+ { key: 'est_units', label: 'Est. units/mo', align: 'r', fmt: int },
+ { key: 'est_revenue', label: 'Est. revenue/mo', align: 'r', fmt: money0 },
+ { key: 'offers', label: 'Offers', align: 'r', fmt: int },
+ { key: 'asin', label: '', sort: false, csv: false, render: function (r) { return btn('Fees', function () { return feeLookup(r.asin, r.price); }, 'sm'); } }
+ ], rows: d.items })));
+ }, function (e) { clear(slot).appendChild(failure(e)); });
+ }
+ kw.addEventListener('keydown', function (e) { if (e.key === 'Enter') { search(); } });
+
+ var f = {};
+ ['price', 'unit_cost', 'inbound', 'referral_pct', 'fba_fee', 'storage_per_unit', 'tacos_pct', 'return_pct', 'cvr_pct'].forEach(function (k) { f[k] = h('input', { type: 'number', step: '0.01', min: '0' }); });
+ f.price.value = '16.99'; f.unit_cost.value = '4.20'; f.inbound.value = '0.50'; f.referral_pct.value = '15'; f.fba_fee.value = '4.02'; f.storage_per_unit.value = '0.10'; f.tacos_pct.value = '10'; f.return_pct.value = '2'; f.cvr_pct.value = '12';
+ var out = h('div');
+ function calc() {
+ var body = {}; Object.keys(f).forEach(function (k) { body[k] = f[k].value; });
+ return api('/research/calc', { method: 'POST', body: body }).then(function (r) {
+ clear(out).appendChild(h('div', { class: 'sgs-grid k4' },
+ kpi('Profit / unit', money(r.profit_per_unit), 'Margin ' + pct(r.margin_pct), num(r.profit_per_unit) >= 0 ? 'tone-green' : 'tone-red'),
+ kpi('ROI', pct(r.roi_pct), 'on ' + money(r.cogs) + ' landed cost'),
+ kpi('Break-even ACoS', pct(r.breakeven_acos), 'Max CPC ' + money(r.max_cpc) + ' at your CVR'),
+ kpi('Break-even price', money(r.breakeven_price), 'Amazon fees ' + money(r.amazon_fees))));
+ });
+ }
+ var calcCard = card('FBA profitability calculator', h('div', { class: 'sgs-stack' }, h('div', { class: 'form-grid' },
+ field('Sale price', f.price), field('Unit cost', f.unit_cost), field('Inbound / unit', f.inbound), field('Referral %', f.referral_pct, 'Grocery: 8% ≤ $15, 15% above'),
+ field('FBA fee', f.fba_fee, 'Use “Fees” on a result for Amazon’s exact estimate'), field('Storage / unit', f.storage_per_unit), field('TACoS %', f.tacos_pct), field('Return rate %', f.return_pct), field('Ad conversion %', f.cvr_pct)),
+ h('div', { class: 'btns' }, btn('Calculate', calc, 'primary')), out));
+
+ function feeLookup(asin, price) {
+ return api('/research/fees', { method: 'POST', body: { asin: asin, price: price || f.price.value } }).then(function (r) {
+ f.fba_fee.value = num(r.fba).toFixed(2);
+ if (r.price) { f.price.value = r.price; f.referral_pct.value = r.price ? (num(r.referral) / num(r.price) * 100).toFixed(1) : f.referral_pct.value; }
+ toast('Fees for ' + asin + ': ' + money(r.total) + ' (referral ' + money(r.referral) + ', FBA ' + money(r.fba) + ')', 'ok');
+ return calc();
+ });
+ }
+ var asinFee = h('input', { type: 'text', placeholder: 'ASIN' });
+ var feeCard = card('Exact Amazon fees for any ASIN', h('div', { class: 'btns' }, asinFee, btn('Get fees', function () { return feeLookup(asinFee.value.trim().toUpperCase(), f.price.value); })), { sub: 'Product Fees API — fills the calculator.' });
+
+ return h('div', { class: 'sgs-stack' },
+ pageHead('Product Research', 'Size up any niche from the official catalog: BSR-based sales estimates, page-1 revenue, competition, and exact FBA fees — then model your profit.'),
+ card('Niche explorer', h('div', { class: 'sgs-stack' }, h('div', { class: 'btns' }, kw, btn('Search Amazon', search, 'primary')), slot)),
+ h('div', { class: 'sgs-grid k21' }, calcCard, feeCard));
+ };
+
+ /* ---------- Reports ---------- */
+ PAGES.reports = function () {
+ return api('/reports').then(function (d) {
+ var cat = table({ title: 'Report catalog', search: false, columns: [
+ { key: 'label', label: 'Report', render: function (r) { return h('div', null, h('b', null, r.label), h('div', { class: 'small muted mono' }, r.type)); } },
+ { key: 'schedule', label: 'Auto', render: function (r) { return badge(r.schedule, r.schedule === 'daily' ? 'green' : 'blue'); } },
+ { key: 'window', label: 'Window', render: function (r) { return r.window ? r.window + ' days' : 'snapshot'; } },
+ { key: 'type', label: '', sort: false, render: function (r) { return btn('Request now', function () { return api('/reports', { method: 'POST', body: { type: r.type } }).then(function () { toast('Requested ' + r.label, 'ok'); route(); }); }, 'sm'); } }
+ ], rows: d.catalog });
+ var STATUS_C = { completed: 'green', requested: 'blue', processing: 'blue', failed: 'red', empty: '' };
+ var rows = table({ title: 'Recent report runs', csv: 'report-runs', sort: 'requested_at', columns: [
+ { key: 'requested_at', label: 'Requested', fmt: function (v) { return dateTime(v); } },
+ { key: 'report_type', label: 'Type', render: function (r) { return h('span', { class: 'small mono' }, r.report_type); } },
+ { key: 'source', label: 'API', render: function (r) { return badge(r.source === 'ads' ? 'Ads' : 'SP'); } },
+ { key: 'market', label: 'Mkt' },
+ { key: 'data_start', label: 'Range', render: function (r) { return r.data_start ? shortDate(r.data_start) + ' – ' + shortDate(r.data_end) : '—'; } },
+ { key: 'status', label: 'Status', render: function (r) { return badge(r.status, STATUS_C[r.status]); } },
+ { key: 'rows_ingested', label: 'Rows', align: 'r', fmt: int },
+ { key: 'error', label: 'Error', render: function (r) { return h('span', { class: 'small neg' }, r.error || ''); } }
+ ], rows: d.rows, actions: btn('Poll now', function () { return api('/reports/poll', { method: 'POST' }).then(function (r) { toast(r.completed + ' completed, ' + r.rows + ' rows ingested', 'ok'); route(); }); }, 'primary') });
+ return h('div', { class: 'sgs-stack' }, pageHead('Reports Center', 'Every Amazon report the suite uses — requested on schedule, polled, downloaded, decompressed and ingested automatically.'), cat, rows, jobsCard(d.jobs));
+ });
+ };
+
+ function jobsCard(jobs) {
+ var keys = Object.keys(jobs || {});
+ return card('Background jobs', keys.length ? h('ul', { class: 'list' }, keys.map(function (k) {
+ var j = jobs[k];
+ return h('li', null, h('span', null, h('b', null, k), ' ', h('span', { class: 'muted small' }, new Date(j.at * 1000).toLocaleString())), j.ok ? badge('ok', 'green') : h('span', { class: 'small neg', title: j.error }, '✕ ' + (j.error || '').slice(0, 90)));
+ })) : empty('No runs yet', 'Jobs run on WP-Cron every 15 minutes, hourly and daily.'));
+ }
+
+ /* ---------- Alerts ---------- */
+ PAGES.alerts = function () {
+ return api('/alerts').then(function (d) {
+ state.unread = d.unread;
+ return h('div', { class: 'sgs-stack' }, pageHead('Alerts', 'Hijackers, Buy Box losses, listing changes, negative feedback, competitor price drops and more. Critical alerts can also go to email (Settings).', [btn('Mark all read', function () { return api('/alerts', { method: 'POST' }).then(route); })]),
+ table({ title: d.rows.length + ' alerts', csv: 'alerts', sort: 'created_at', columns: [
+ { key: 'created_at', label: 'When', fmt: function (v) { return dateTime(v); } },
+ { key: 'level', label: 'Level', render: function (r) { return badge(r.level, r.level === 'critical' ? 'red' : (r.level === 'warning' ? 'amber' : 'blue')); } },
+ { key: 'category', label: 'Category' },
+ { key: 'title', label: 'Alert', render: function (r) { return h('div', null, h('b', { style: { fontWeight: +r.is_read ? 400 : 700 } }, r.title), r.message ? h('div', { class: 'small muted' }, r.message) : null); } }
+ ], rows: d.rows, empty: empty('No alerts', 'You will see hijacker, Buy Box, feedback and listing-change alerts here.') }));
+ });
+ };
+
+ /* ---------- Settings ---------- */
+ PAGES.settings = function () {
+ return api('/settings').then(function (d) {
+ var S = d.settings;
+ var inputs = {};
+ function inp(key, type, attrs) {
+ var el;
+ if (type === 'bool') { el = h('input', { type: 'checkbox', checked: !!S[key] }); }
+ else if (type === 'select') { el = h('select', null, attrs.options.map(function (o) { return h('option', { value: o[0], selected: String(S[key]) === String(o[0]) }, o[1]); })); }
+ else { el = h('input', Object.assign({ type: type || 'text', value: S[key] === null || S[key] === undefined ? '' : S[key] }, attrs || {})); }
+ inputs[key] = el;
+ return el;
+ }
+ function chk(key, label) { return h('label', { class: 'check' }, inp(key, 'bool'), label); }
+ function secret(key, label, help) {
+ var el = h('input', { type: 'password', autocomplete: 'off', placeholder: S['has_' + key] ? '•••••••• saved — leave blank to keep' : '' });
+ inputs[key] = el;
+ return field(label, el, help);
+ }
+ var sellerId = h('input', { type: 'text', placeholder: S.seller_id_set ? '•••••• saved — leave blank to keep' : 'e.g. A1B2C3D4E5F6G7' });
+ inputs.amazon_seller_id = sellerId;
+
+ function collect() {
+ var out = {};
+ Object.keys(inputs).forEach(function (k) {
+ var el = inputs[k];
+ out[k] = el.type === 'checkbox' ? el.checked : el.value;
+ });
+ return out;
+ }
+ function save() { return api('/settings', { method: 'POST', body: collect() }).then(function () { toast('Settings saved', 'ok'); }); }
+
+ var profileSlot = h('div');
+ var g = function (title, sub, children) { return card(title, h('div', { class: 'form-grid' }, children), { sub: sub }); };
+
+ var sync = g('Data sync', 'Orders every 15 min, finances hourly, reports daily/weekly. First run backfills history.', [
+ chk('suite_sync_enabled', 'Background sync enabled'),
+ field('Backfill days', inp('suite_backfill_days', 'number', { min: 7, max: 730 })),
+ field('Order items per tick', inp('suite_order_items_per_run', 'number', { min: 5, max: 100 }), 'Orders API item calls per 15 min'),
+ field('Seller ID (Merchant Token)', sellerId, 'Seller Central → Settings → Account Info → Merchant Token. Needed for listing audits & hijacker detection.')
+ ]);
+ var profit = g('Profit', null, [
+ field('Default unit cost', inp('suite_default_unit_cost', 'number', { step: '0.01' }), 'Used when a SKU has no cost'),
+ chk('suite_include_tax', 'Count collected tax as revenue (not recommended)')
+ ]);
+ var restock = g('Restock planner', null, [
+ field('Default lead time (days)', inp('suite_lead_time_days', 'number')), field('Safety stock (days)', inp('suite_safety_days', 'number')),
+ field('Target cover after arrival (days)', inp('suite_target_cover_days', 'number')), field('Overstock threshold (days)', inp('suite_overstock_days', 'number'))
+ ]);
+ var ads = g('Amazon Ads API (PPC)', 'Create an LWA security profile approved for the Advertising API, then generate a refresh token with scope advertising::campaign_management. Leave client ID/secret blank to reuse the SP-API app.', [
+ chk('suite_ads_enabled', 'Sync ads daily'),
+ field('Region', inp('suite_ads_region', 'select', { options: [['na', 'North America'], ['eu', 'Europe / Middle East'], ['fe', 'Far East']] })),
+ secret('ads_client_id', 'Ads client ID'), secret('ads_client_secret', 'Ads client secret'), secret('ads_refresh_token', 'Ads refresh token'),
+ field('Profile ID', inp('suite_ads_profile_id'), 'Use “Test & list profiles”'),
+ h('div', { class: 'field' }, h('label', null, ' '), btn('Test & list profiles', function () {
+ return api('/ads/test', { method: 'POST', body: collect() }).then(function (r) {
+ clear(profileSlot).appendChild(h('ul', { class: 'list' }, r.profiles.map(function (p) {
+ return h('li', null, h('span', null, h('b', null, p.countryCode + ' · ' + p.name), ' ', h('span', { class: 'muted mono small' }, p.profileId + ' · ' + p.type + ' · ' + p.currency)), btn('Use', function () { inputs.suite_ads_profile_id.value = p.profileId; return save(); }, 'sm'));
+ })));
+ toast('Ads API connected — ' + r.profiles.length + ' profile(s)', 'ok');
+ });
+ })),
+ h('div', { style: { gridColumn: '1 / -1' } }, profileSlot)
+ ]);
+ var opt = g('PPC optimizer rules', 'Plans are reviewed in PPC → Optimizer. Auto-apply pushes only bid changes and negatives.', [
+ field('Target ACoS %', inp('suite_ads_target_acos', 'number', { step: '0.5' })), field('Lookback days', inp('suite_ads_lookback_days', 'number')),
+ field('Min clicks before cutting', inp('suite_ads_min_clicks', 'number')), field('Max bid step %', inp('suite_ads_max_step_pct', 'number')),
+ field('Min bid', inp('suite_ads_min_bid', 'number', { step: '0.01' })), field('Max bid', inp('suite_ads_max_bid', 'number', { step: '0.01' })),
+ field('Negate after clicks (0 orders)', inp('suite_ads_neg_min_clicks', 'number')), field('Harvest after orders', inp('suite_ads_harvest_min_orders', 'number')),
+ field('Harvest → campaign ID', inp('suite_ads_harvest_campaign')), field('Harvest → ad group ID', inp('suite_ads_harvest_ad_group')),
+ chk('suite_ads_raise_low_impr', 'Raise bids on low-impression keywords'), chk('suite_ads_auto_apply', 'Auto-apply bids & negatives daily')
+ ]);
+ var reviews = g('Review requests', 'Uses Amazon’s Solicitations API — the compliant “Request a Review” button.', [
+ chk('suite_reviews_enabled', 'Send review requests automatically'),
+ field('Days after delivery', inp('suite_reviews_delay_days', 'number', { min: 5, max: 29 })), field('Daily cap', inp('suite_reviews_daily_cap', 'number')),
+ chk('suite_reviews_skip_refunded', 'Skip refunded / returned orders')
+ ]);
+ var mon = g('Monitoring & alerts', null, [
+ chk('suite_monitor_enabled', 'Monitor Buy Box, hijackers & competitors'),
+ field('Competitor price-drop alert %', inp('suite_monitor_price_drop_pct', 'number')),
+ field('Extra competitor ASINs', inp('suite_monitor_extra_asins', 'text', { placeholder: 'B0…, B0…' })),
+ field('Alert email', inp('suite_alert_email', 'email', { placeholder: 'defaults to site admin email' })),
+ field('Email these levels', inp('suite_alert_email_levels', 'select', { options: [['critical', 'Critical only'], ['critical,warning', 'Critical + warning'], ['critical,warning,info', 'Everything'], ['none', 'Never']] }))
+ ]);
+ var nr = d.next_runs || {};
+ var ops = card('Run now', h('div', { class: 'sgs-stack' },
+ h('div', { class: 'btns' },
+ [['orders', 'Sync orders'], ['finance', 'Sync finances'], ['reports', 'Request daily reports'], ['weekly', 'Request weekly reports'], ['poll', 'Poll reports'], ['backfill', 'Backfill history']].map(function (j) {
+ return btn(j[1], function () { return api('/sync', { method: 'POST', body: { job: j[0] } }).then(function (r) { toast(j[1] + ': ' + JSON.stringify(r.result).slice(0, 120), 'ok'); }); }, 'sm');
+ })),
+ h('div', { class: 'small muted' }, 'Next runs — tick: ' + (nr.sg_suite_tick ? dateTime(nr.sg_suite_tick) : 'not scheduled') + ' · hourly: ' + (nr.sg_suite_hourly ? dateTime(nr.sg_suite_hourly) : 'not scheduled') + ' · daily: ' + (nr.sg_suite_daily ? dateTime(nr.sg_suite_daily) : 'not scheduled')),
+ h('div', { class: 'divider' }),
+ h('div', { class: 'btns' }, state.demo ? btn('Remove demo data', function () { return api('/demo', { method: 'DELETE' }).then(function () { state.demo = false; route(); }); }, 'danger') : btn('Load demo account', seedDemo))
+ ), { sub: d.connected ? 'SP-API connected.' : 'SP-API not connected — set credentials in Seven Gum → Amazon Connect.' });
+
+ return h('div', { class: 'sgs-stack' },
+ pageHead('Suite Settings', 'Everything is configurable; secrets are encrypted with AES-256-GCM and never sent back to the browser.', [btn('Save settings', save, 'primary')]),
+ h('div', { class: 'sgs-grid k2' }, sync, profit), ads, opt, h('div', { class: 'sgs-grid k2' }, restock, reviews), mon, ops, jobsCard(d.jobs),
+ h('div', { class: 'btns' }, btn('Save settings', save, 'primary')));
+ });
+ };
+
+ /* ================================================================== */
+ /* Boot */
+ /* ================================================================== */
+
+ clear(root);
+ add(root, [headerSlot, nav, bannerSlot, main]);
+ window.addEventListener('hashchange', route);
+ route();
+})();
diff --git a/sg-commerce-pro/assets/js/widget.js b/sg-commerce-pro/assets/js/widget.js
new file mode 100644
index 0000000..3b193c5
--- /dev/null
+++ b/sg-commerce-pro/assets/js/widget.js
@@ -0,0 +1,54 @@
+/*
+ * Seven Gum Commerce — widget live refresh.
+ * Polls REST endpoint every 60s, updates price + stock chip in place.
+ */
+(function () {
+ if (!window.SG_COMMERCE || !window.SG_COMMERCE.rest) return;
+ var REST = window.SG_COMMERCE.rest;
+ var REFRESH_MS = 60 * 1000;
+
+ function refresh(el) {
+ var sku = el.dataset.sku;
+ var market = el.dataset.market;
+ if (!sku || !market) return;
+
+ fetch(REST + 'product/' + encodeURIComponent(market) + '/' + encodeURIComponent(sku), {
+ headers: { 'Accept': 'application/json' }
+ })
+ .then(function(r) { return r.ok ? r.json() : null; })
+ .then(function(data) {
+ if (!data) return;
+ var priceEl = el.querySelector('[data-role="price"]');
+ if (priceEl && data.price !== null && typeof data.price !== 'undefined') {
+ var small = priceEl.querySelector('small');
+ priceEl.textContent = Number(data.price).toFixed(2) + ' ';
+ if (small) priceEl.appendChild(small);
+ else {
+ var s = document.createElement('small');
+ s.textContent = data.currency || '';
+ priceEl.appendChild(s);
+ }
+ }
+ var stockEl = el.querySelector('[data-role="stock"]');
+ if (stockEl) {
+ stockEl.textContent = data.in_stock ? 'In stock' : 'Out of stock';
+ stockEl.classList.toggle('sg-widget__stock--ok', !!data.in_stock);
+ stockEl.classList.toggle('sg-widget__stock--out', !data.in_stock);
+ }
+ })
+ .catch(function() { /* silent */ });
+ }
+
+ function init() {
+ var widgets = document.querySelectorAll('.sg-widget[data-sku][data-market]');
+ widgets.forEach(function(w) {
+ setInterval(function() { refresh(w); }, REFRESH_MS);
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/sg-commerce-pro/sg-commerce.php b/sg-commerce-pro/sg-commerce.php
new file mode 100644
index 0000000..1ddc5aa
--- /dev/null
+++ b/sg-commerce-pro/sg-commerce.php
@@ -0,0 +1,128 @@
+boot();
+ } catch ( \Throwable $e ) {
+ error_log( '[SG Commerce] Boot failure: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
+ add_action( 'admin_notices', static function () use ( $e ): void {
+ if ( ! current_user_can( 'manage_options' ) ) return;
+ printf(
+ '',
+ esc_html( $e->getMessage() )
+ );
+ } );
+ }
+}, 10 );
diff --git a/sg-commerce-pro/src/AI/AIModule.php b/sg-commerce-pro/src/AI/AIModule.php
new file mode 100644
index 0000000..a09ae8c
--- /dev/null
+++ b/sg-commerce-pro/src/AI/AIModule.php
@@ -0,0 +1,79 @@
+container;
+
+ // Bind the active provider as ProviderInterface.
+ $c->singleton( ProviderInterface::class, function ( Container $c ): ProviderInterface {
+ $active = (string) $c->get( SettingsRepository::class )->get( 'ai_provider', 'ollama' );
+ return match ( $active ) {
+ // Future providers: openai, anthropic
+ 'ollama' => $c->get( OllamaProvider::class ),
+ default => $c->get( OllamaProvider::class ),
+ };
+ } );
+
+ // Copywriter — depends on the provider above.
+ $c->singleton( Copywriter::class, static fn( Container $c ) =>
+ new Copywriter(
+ $c->get( ProviderInterface::class ),
+ $c->get( PromptRegistry::class ),
+ $c->get( ProductRepository::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ // Async generation queue handler.
+ add_action( 'sg_commerce_ai_describe', function ( $args ): void {
+ if ( ! is_array( $args ) || empty( $args['product_id'] ) ) return;
+ try {
+ $this->container->get( Copywriter::class )->describe( (int) $args['product_id'] );
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->error( 'queued describe failed: ' . $e->getMessage() );
+ }
+ } );
+
+ add_action( 'sg_commerce_ai_translate', function ( $args ): void {
+ if ( ! is_array( $args ) || empty( $args['product_id'] ) || empty( $args['market'] ) ) return;
+ try {
+ $this->container->get( Copywriter::class )->translate(
+ (int) $args['product_id'],
+ (string) $args['market']
+ );
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->error( 'queued translate failed: ' . $e->getMessage() );
+ }
+ } );
+ }
+}
diff --git a/sg-commerce-pro/src/AI/Copywriter.php b/sg-commerce-pro/src/AI/Copywriter.php
new file mode 100644
index 0000000..c8b3bd6
--- /dev/null
+++ b/sg-commerce-pro/src/AI/Copywriter.php
@@ -0,0 +1,158 @@
+products->find( $product_id );
+ if ( null === $product ) {
+ throw new \RuntimeException( "Product #{$product_id} not found." );
+ }
+
+ $prompt = $this->prompts->describe_product( $product );
+ $result = $this->provider->generate( $prompt, array(
+ 'system' => $this->prompts->brand_system(),
+ 'temperature' => 0.8,
+ 'max_tokens' => 220,
+ ) );
+
+ $this->save_description( $product_id, 'en', $result );
+ $this->logger->info( 'Described product', array(
+ 'product_id' => $product_id,
+ 'tokens' => $result['tokens'],
+ 'duration' => $result['duration_ms'],
+ ) );
+ return $result['text'];
+ }
+
+ /**
+ * Translate the English description into the language of $target_market.
+ * If no English description exists, generates one first.
+ */
+ public function translate( int $product_id, string $target_market ): string {
+ $mp = $this->marketplaces->get( $target_market );
+ if ( null === $mp ) {
+ throw new \RuntimeException( "Unknown marketplace: {$target_market}" );
+ }
+ $lang_code = (string) $mp['language'];
+
+ $english = $this->find_description( $product_id, 'en' );
+ if ( null === $english ) {
+ // Auto-generate the English source first.
+ $english = $this->describe( $product_id );
+ }
+
+ if ( 'en' === $lang_code ) {
+ // Target market is English-speaking; English copy is the answer.
+ $this->save_description( $product_id, 'en', array(
+ 'text' => $english,
+ 'provider' => $this->provider->id(),
+ 'model' => '-',
+ 'tokens' => 0,
+ 'duration_ms' => 0,
+ ) );
+ return $english;
+ }
+
+ $prompt = $this->prompts->translate( $english, $this->prompts->language_name( $lang_code ) );
+ $result = $this->provider->generate( $prompt, array(
+ 'system' => $this->prompts->brand_system(),
+ 'temperature' => 0.4,
+ 'max_tokens' => 260,
+ ) );
+
+ $this->save_description( $product_id, $lang_code, $result );
+ $this->logger->info( 'Translated product', array(
+ 'product_id' => $product_id,
+ 'language' => $lang_code,
+ 'tokens' => $result['tokens'],
+ ) );
+ return $result['text'];
+ }
+
+ /** Look up the stored description for a product+language. */
+ public function find_description( int $product_id, string $language ): ?string {
+ global $wpdb;
+ $row = $wpdb->get_var( $wpdb->prepare(
+ "SELECT content FROM {$wpdb->prefix}sg_descriptions WHERE product_id = %d AND language = %s LIMIT 1",
+ $product_id,
+ $language
+ ) );
+ return is_string( $row ) ? $row : null;
+ }
+
+ /** All stored descriptions for a product, keyed by language code. */
+ public function descriptions_for( int $product_id ): array {
+ global $wpdb;
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT language, content, provider, model, generated_at
+ FROM {$wpdb->prefix}sg_descriptions
+ WHERE product_id = %d
+ ORDER BY language ASC",
+ $product_id
+ ), ARRAY_A );
+ $out = array();
+ foreach ( (array) $rows as $r ) {
+ $out[ (string) $r['language'] ] = $r;
+ }
+ return $out;
+ }
+
+ private function save_description( int $product_id, string $language, array $result ): void {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare(
+ "INSERT INTO {$wpdb->prefix}sg_descriptions
+ (product_id, language, content, provider, model, temperature, tokens, generation_ms, generated_at)
+ VALUES (%d, %s, %s, %s, %s, %f, %d, %d, %s)
+ ON DUPLICATE KEY UPDATE
+ content = VALUES(content),
+ provider = VALUES(provider),
+ model = VALUES(model),
+ temperature = VALUES(temperature),
+ tokens = VALUES(tokens),
+ generation_ms = VALUES(generation_ms),
+ generated_at = VALUES(generated_at)",
+ $product_id,
+ $language,
+ (string) $result['text'],
+ (string) ( $result['provider'] ?? '' ),
+ (string) ( $result['model'] ?? '' ),
+ 0.0,
+ (int) ( $result['tokens'] ?? 0 ),
+ (int) ( $result['duration_ms'] ?? 0 ),
+ current_time( 'mysql', true )
+ ) );
+ }
+}
diff --git a/sg-commerce-pro/src/AI/PromptRegistry.php b/sg-commerce-pro/src/AI/PromptRegistry.php
new file mode 100644
index 0000000..251ae4b
--- /dev/null
+++ b/sg-commerce-pro/src/AI/PromptRegistry.php
@@ -0,0 +1,98 @@
+settings->get( 'ai_brand_voice', 'modern, clean, playful but confident' );
+ return "You are a senior copywriter for Seven Gum, a premium sugar-free chewing gum brand sold internationally.\n"
+ . "Product line: nine flavors including Cookie, Mint, Cinnamon, fruit varieties.\n"
+ . "Brand voice: {$voice}. Never cheesy or salesy.\n"
+ . "Focus on sensory experience (taste, texture, freshness) and lifestyle (on-the-go, post-meal, after coffee).";
+ }
+
+ public function describe_product( array $product ): string {
+ $sku = (string) ( $product['sku'] ?? '' );
+ $asin = (string) ( $product['asin'] ?? '' );
+ $name = (string) ( $product['product_name'] ?? '' );
+ $market = (string) ( $product['market'] ?? '' );
+
+ return "Write a product description for this Amazon listing:\n"
+ . " - SKU: {$sku}\n"
+ . ( '' !== $asin ? " - ASIN: {$asin}\n" : '' )
+ . " - Product name: " . ( '' !== $name ? $name : '(unknown — infer from SKU if possible)' ) . "\n"
+ . " - Market: {$market}\n"
+ . "\n"
+ . "Requirements:\n"
+ . " - 60-80 words\n"
+ . " - Lead with the flavor experience\n"
+ . " - Mention 'sugar-free' once, naturally\n"
+ . " - End with a short, confident call-to-action\n"
+ . " - Output ONLY the description. No headings, no markdown, no preamble.";
+ }
+
+ public function translate( string $english, string $target_language_name ): string {
+ return "Translate this product description from English to {$target_language_name}.\n"
+ . "Keep the same brand voice (modern, clean, confident) and the same approximate length.\n"
+ . "Localize idioms — never word-for-word.\n"
+ . "Output ONLY the translation. No preamble, no notes.\n"
+ . "\n"
+ . "English source:\n"
+ . $english;
+ }
+
+ public function competitor_summary( array $competitor_data ): string {
+ $asin = (string) ( $competitor_data['asin'] ?? '' );
+ $name = (string) ( $competitor_data['product_name'] ?? '' );
+ $price = (string) ( $competitor_data['last_price'] ?? '' );
+ $seller = (string) ( $competitor_data['seller_name'] ?? '' );
+
+ return "Summarize this competing Amazon listing in 2-3 sentences for a sales briefing:\n"
+ . " - ASIN: {$asin}\n"
+ . " - Product: {$name}\n"
+ . " - Seller: {$seller}\n"
+ . " - Price: {$price}\n"
+ . "\n"
+ . "Focus on positioning differences vs Seven Gum (premium sugar-free gum). "
+ . "Output ONLY the summary, no headings.";
+ }
+
+ /** Map ISO language codes to readable names for the translate prompt. */
+ public function language_name( string $code ): string {
+ return match ( $code ) {
+ 'en' => 'English',
+ 'de' => 'German',
+ 'fr' => 'French',
+ 'es' => 'Spanish',
+ 'it' => 'Italian',
+ 'nl' => 'Dutch',
+ 'sv' => 'Swedish',
+ 'pl' => 'Polish',
+ 'tr' => 'Turkish',
+ 'fa' => 'Persian (Farsi)',
+ 'ar' => 'Arabic',
+ 'pt' => 'Portuguese',
+ 'ja' => 'Japanese',
+ default => $code,
+ };
+ }
+}
diff --git a/sg-commerce-pro/src/AI/ProviderInterface.php b/sg-commerce-pro/src/AI/ProviderInterface.php
new file mode 100644
index 0000000..7ca5dd1
--- /dev/null
+++ b/sg-commerce-pro/src/AI/ProviderInterface.php
@@ -0,0 +1,62 @@
+
+ */
+ public function list_models(): array;
+}
diff --git a/sg-commerce-pro/src/AI/Providers/OllamaProvider.php b/sg-commerce-pro/src/AI/Providers/OllamaProvider.php
new file mode 100644
index 0000000..3c8ee0b
--- /dev/null
+++ b/sg-commerce-pro/src/AI/Providers/OllamaProvider.php
@@ -0,0 +1,177 @@
+default_model() );
+ $temperature = (float) ( $options['temperature'] ?? (float) $this->settings->get( 'ai_temperature', 0.7 ) );
+ $system = isset( $options['system'] ) ? (string) $options['system'] : null;
+ $max_tokens = isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : null;
+
+ // Cache lookup.
+ $cache_key = $this->cache_key( $prompt, $model, $temperature, $system );
+ $cached = $this->cache->get( $cache_key );
+ if ( is_array( $cached ) && isset( $cached['text'] ) ) {
+ $this->logger->debug( 'Ollama cache hit', array( 'model' => $model ) );
+ return $cached;
+ }
+
+ $body = array(
+ 'model' => $model,
+ 'prompt' => $prompt,
+ 'stream' => false,
+ 'options' => array(
+ 'temperature' => $temperature,
+ ),
+ 'keep_alive' => '5m',
+ );
+ if ( null !== $system ) {
+ $body['system'] = $system;
+ }
+ if ( null !== $max_tokens && $max_tokens > 0 ) {
+ $body['options']['num_predict'] = $max_tokens;
+ }
+
+ $start = microtime( true );
+ $response = wp_remote_post(
+ $this->base_url() . '/api/generate',
+ array(
+ 'timeout' => 180,
+ 'headers' => $this->headers(),
+ 'body' => wp_json_encode( $body ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ $msg = $response->get_error_message();
+ $this->logger->error( 'Ollama unreachable: ' . $msg );
+ throw new \RuntimeException( 'Ollama unreachable at ' . $this->base_url() . ': ' . $msg );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $raw = (string) wp_remote_retrieve_body( $response );
+ $json = json_decode( $raw, true );
+
+ if ( 200 !== $code ) {
+ $this->logger->error( "Ollama HTTP {$code}", array( 'body' => substr( $raw, 0, 300 ) ) );
+ // Detect "model not found" specifically — most common failure.
+ if ( str_contains( $raw, 'not found' ) || str_contains( $raw, 'model' ) ) {
+ throw new \RuntimeException(
+ "Ollama: model '{$model}' is not installed. Run: ollama pull {$model}"
+ );
+ }
+ throw new \RuntimeException( "Ollama HTTP {$code}: " . substr( $raw, 0, 200 ) );
+ }
+ if ( ! is_array( $json ) || ! isset( $json['response'] ) ) {
+ throw new \RuntimeException( 'Ollama returned malformed response.' );
+ }
+
+ $result = array(
+ 'text' => trim( (string) $json['response'] ),
+ 'provider' => $this->id(),
+ 'model' => $model,
+ 'tokens' => (int) ( $json['eval_count'] ?? 0 ),
+ 'duration_ms' => (int) ( ( microtime( true ) - $start ) * 1000 ),
+ );
+
+ $this->cache->set( $cache_key, $result, 6 * HOUR_IN_SECONDS );
+ return $result;
+ }
+
+ public function is_reachable(): bool {
+ try {
+ $this->list_models();
+ return true;
+ } catch ( \Throwable ) {
+ return false;
+ }
+ }
+
+ public function list_models(): array {
+ $response = wp_remote_get(
+ $this->base_url() . '/api/tags',
+ array( 'timeout' => 15, 'headers' => $this->headers() )
+ );
+ if ( is_wp_error( $response ) ) {
+ throw new \RuntimeException( 'Ollama unreachable: ' . $response->get_error_message() );
+ }
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
+ throw new \RuntimeException( "Ollama HTTP {$code} on /api/tags" );
+ }
+ $json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
+ $out = array();
+ if ( is_array( $json ) && is_array( $json['models'] ?? null ) ) {
+ foreach ( $json['models'] as $m ) {
+ if ( isset( $m['name'] ) ) {
+ $out[] = array(
+ 'id' => (string) $m['name'],
+ 'name' => (string) $m['name'],
+ 'size' => (int) ( $m['size'] ?? 0 ),
+ );
+ }
+ }
+ }
+ return $out;
+ }
+
+ private function base_url(): string {
+ return rtrim( (string) $this->settings->get( 'ollama_url', 'http://127.0.0.1:11434' ), '/' );
+ }
+
+ private function default_model(): string {
+ return (string) $this->settings->get( 'ollama_model', 'llama3.1:8b' );
+ }
+
+ private function headers(): array {
+ $h = array( 'Content-Type' => 'application/json' );
+ $token = (string) $this->settings->get( 'ollama_auth_token', '' );
+ if ( '' !== $token ) {
+ $h['Authorization'] = 'Bearer ' . $token;
+ }
+ return $h;
+ }
+
+ private function cache_key( string $prompt, string $model, float $temp, ?string $system ): string {
+ return 'ai/ollama/' . substr( hash( 'sha256', $prompt . '|' . $model . '|' . $temp . '|' . ( $system ?? '' ) ), 0, 32 );
+ }
+}
diff --git a/sg-commerce-pro/src/API/HeadlessAPI.php b/sg-commerce-pro/src/API/HeadlessAPI.php
new file mode 100644
index 0000000..51d0f67
--- /dev/null
+++ b/sg-commerce-pro/src/API/HeadlessAPI.php
@@ -0,0 +1,297 @@
+` header where the key is
+ * a PAT minted from Settings → API Keys. Falls back to WP application
+ * passwords for users who already use those.
+ *
+ * Rate limit: 60 requests/minute per API key (lightweight transient bucket).
+ *
+ * All endpoints return JSON. Each endpoint documents its schema via
+ * register_rest_route args + rest_field_schema so any OpenAPI tool can
+ * pull specs automatically.
+ *
+ * @package SevenGum\Commerce\API
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\API;
+
+use SevenGum\Commerce\Analytics\SalesAggregator;
+use SevenGum\Commerce\Core\Container;
+use SevenGum\Commerce\Core\Module;
+use SevenGum\Commerce\Database\Repositories\ProductRepository;
+use SevenGum\Commerce\Database\Repositories\SettingsRepository;
+use SevenGum\Commerce\Fulfillment\MCFOrderRepository;
+use SevenGum\Commerce\Logging\Logger;
+
+defined( 'ABSPATH' ) || exit;
+
+final class HeadlessAPI implements Module {
+
+ public const NAMESPACE = 'sg-commerce/v2';
+
+ public function __construct( private Container $container ) {}
+
+ public function id(): string {
+ return 'headless_api';
+ }
+
+ public function register(): void {
+ add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+ }
+
+ public function register_routes(): void {
+ $ns = self::NAMESPACE;
+
+ // GET /products — paginated, filtered
+ register_rest_route( $ns, '/products', array(
+ 'methods' => 'GET',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'list_products' ),
+ 'args' => array(
+ 'market' => array( 'type' => 'string' ),
+ 'search' => array( 'type' => 'string' ),
+ 'in_stock' => array( 'type' => 'boolean' ),
+ 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1 ),
+ 'per_page' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100 ),
+ ),
+ ) );
+
+ register_rest_route( $ns, '/products/(?P\d+)', array(
+ 'methods' => 'GET',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'get_product' ),
+ ) );
+
+ // GET /analytics/kpis
+ register_rest_route( $ns, '/analytics/kpis', array(
+ 'methods' => 'GET',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'kpis' ),
+ ) );
+
+ // GET /analytics/buybox-trend
+ register_rest_route( $ns, '/analytics/buybox-trend', array(
+ 'methods' => 'GET',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'buybox_trend' ),
+ 'args' => array(
+ 'days' => array( 'type' => 'integer', 'default' => 14, 'minimum' => 1, 'maximum' => 90 ),
+ ),
+ ) );
+
+ // GET /mcf/orders
+ register_rest_route( $ns, '/mcf/orders', array(
+ 'methods' => 'GET',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'list_mcf_orders' ),
+ 'args' => array(
+ 'limit' => array( 'type' => 'integer', 'default' => 50, 'maximum' => 200 ),
+ ),
+ ) );
+
+ // POST /mcf/orders
+ register_rest_route( $ns, '/mcf/orders', array(
+ 'methods' => 'POST',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'create_mcf_order' ),
+ ) );
+
+ // GET /meta
+ register_rest_route( $ns, '/meta', array(
+ 'methods' => 'GET',
+ 'permission_callback' => array( $this, 'auth' ),
+ 'callback' => array( $this, 'meta' ),
+ ) );
+
+ // GET /openapi.json
+ register_rest_route( $ns, '/openapi', array(
+ 'methods' => 'GET',
+ 'permission_callback' => '__return_true',
+ 'callback' => array( $this, 'openapi_spec' ),
+ ) );
+ }
+
+ /* -------------------- Auth -------------------- */
+
+ /**
+ * Accepts:
+ * - Authorization: Bearer where is a PAT we issued
+ * - WP application password (automatic via cookie/basic auth)
+ * - manage_options capability (logged-in admin)
+ */
+ public function auth( \WP_REST_Request $request ): bool|\WP_Error {
+ if ( current_user_can( 'manage_options' ) ) {
+ return true;
+ }
+ $auth = (string) $request->get_header( 'authorization' );
+ if ( '' === $auth ) {
+ return new \WP_Error( 'unauthorized', 'API key required', array( 'status' => 401 ) );
+ }
+ if ( ! preg_match( '/^Bearer\s+(.+)$/i', $auth, $m ) ) {
+ return new \WP_Error( 'unauthorized', 'Invalid Authorization header', array( 'status' => 401 ) );
+ }
+ $provided_key = trim( $m[1] );
+
+ $settings = $this->container->get( SettingsRepository::class );
+ $keys = (array) $settings->get( 'api_keys', array() );
+ foreach ( $keys as $key_record ) {
+ if ( ! is_array( $key_record ) ) continue;
+ $stored_hash = (string) ( $key_record['hash'] ?? '' );
+ if ( '' === $stored_hash ) continue;
+ if ( hash_equals( $stored_hash, hash( 'sha256', $provided_key ) ) ) {
+ // Rate limit: 60 req/min per key.
+ if ( ! $this->rate_check( $stored_hash ) ) {
+ return new \WP_Error( 'rate_limited', 'Rate limit exceeded', array( 'status' => 429 ) );
+ }
+ return true;
+ }
+ }
+ return new \WP_Error( 'unauthorized', 'Invalid API key', array( 'status' => 401 ) );
+ }
+
+ private function rate_check( string $key_hash ): bool {
+ $bucket = 'sg_api_rate_' . substr( $key_hash, 0, 16 );
+ $count = (int) get_transient( $bucket );
+ if ( $count >= 60 ) return false;
+ set_transient( $bucket, $count + 1, 60 );
+ return true;
+ }
+
+ /* -------------------- Handlers -------------------- */
+
+ public function list_products( \WP_REST_Request $req ): \WP_REST_Response {
+ $result = $this->container->get( ProductRepository::class )->paginate( array(
+ 'market' => strtoupper( (string) $req->get_param( 'market' ) ),
+ 'search' => (string) $req->get_param( 'search' ),
+ 'in_stock' => (bool) $req->get_param( 'in_stock' ),
+ 'page' => (int) $req->get_param( 'page' ),
+ 'per_page' => (int) $req->get_param( 'per_page' ),
+ ) );
+ return rest_ensure_response( $result );
+ }
+
+ public function get_product( \WP_REST_Request $req ): \WP_REST_Response|\WP_Error {
+ $id = (int) $req->get_param( 'id' );
+ $row = $this->container->get( ProductRepository::class )->find( $id );
+ if ( null === $row ) {
+ return new \WP_Error( 'not_found', 'Product not found', array( 'status' => 404 ) );
+ }
+ return rest_ensure_response( $row );
+ }
+
+ public function kpis(): \WP_REST_Response {
+ return rest_ensure_response(
+ $this->container->get( SalesAggregator::class )->headline_kpis()
+ );
+ }
+
+ public function buybox_trend( \WP_REST_Request $req ): \WP_REST_Response {
+ $days = (int) $req->get_param( 'days' );
+ return rest_ensure_response(
+ $this->container->get( SalesAggregator::class )->buybox_trend( $days )
+ );
+ }
+
+ public function list_mcf_orders( \WP_REST_Request $req ): \WP_REST_Response {
+ $limit = max( 1, min( 200, (int) $req->get_param( 'limit' ) ) );
+ return rest_ensure_response(
+ $this->container->get( MCFOrderRepository::class )->recent( $limit )
+ );
+ }
+
+ public function create_mcf_order( \WP_REST_Request $req ): \WP_REST_Response|\WP_Error {
+ $payload = (array) $req->get_json_params();
+ $result = apply_filters( 'sg_commerce_fulfill_result',
+ do_action( 'sg_commerce_fulfill', $payload ),
+ $payload
+ );
+ // The action returns void — we query the repository by displayable_order_id
+ // if caller passed one, to give them back the row.
+ $display_id = (string) ( $payload['displayable_order_id'] ?? '' );
+ if ( '' !== $display_id ) {
+ global $wpdb;
+ $row = $wpdb->get_row( $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}sg_mcf_orders WHERE displayable_order_id = %s ORDER BY id DESC LIMIT 1",
+ $display_id
+ ), ARRAY_A );
+ if ( $row ) return rest_ensure_response( $row );
+ }
+ return rest_ensure_response( array( 'submitted' => true ) );
+ }
+
+ public function meta(): \WP_REST_Response {
+ $mp = $this->container->get( \SevenGum\Commerce\Amazon\Marketplaces::class );
+ return rest_ensure_response( array(
+ 'plugin_version' => SG_COMMERCE_VERSION,
+ 'db_version' => SG_COMMERCE_DB_VERSION,
+ 'primary_market' => $mp->primary(),
+ 'enabled_markets'=> $mp->enabled_codes(),
+ 'api_version' => '2.0',
+ ) );
+ }
+
+ public function openapi_spec(): \WP_REST_Response {
+ return rest_ensure_response( array(
+ 'openapi' => '3.0.0',
+ 'info' => array(
+ 'title' => 'Seven Gum Commerce API',
+ 'version' => '2.0',
+ 'description' => 'Headless REST API for product catalog, analytics, MCF fulfillment, and more.',
+ ),
+ 'servers' => array( array( 'url' => rest_url( self::NAMESPACE ) ) ),
+ 'paths' => array(
+ '/products' => array( 'get' => array( 'summary' => 'List products' ) ),
+ '/products/{id}' => array( 'get' => array( 'summary' => 'Get one product' ) ),
+ '/analytics/kpis' => array( 'get' => array( 'summary' => 'Headline KPIs' ) ),
+ '/analytics/buybox-trend' => array( 'get' => array( 'summary' => 'Buy Box won/lost per day' ) ),
+ '/mcf/orders' => array(
+ 'get' => array( 'summary' => 'List MCF orders' ),
+ 'post' => array( 'summary' => 'Create an MCF order' ),
+ ),
+ '/meta' => array( 'get' => array( 'summary' => 'Plugin + market metadata' ) ),
+ ),
+ 'components' => array(
+ 'securitySchemes' => array(
+ 'bearer' => array( 'type' => 'http', 'scheme' => 'bearer' ),
+ ),
+ ),
+ 'security' => array( array( 'bearer' => array() ) ),
+ ) );
+ }
+
+ /* -------------------- Key management helpers -------------------- */
+
+ /**
+ * Mint a new API key. Returns the plaintext key ONCE — only hash is stored.
+ */
+ public static function mint_key( Container $container, string $label ): string {
+ $settings = $container->get( SettingsRepository::class );
+ $plaintext = 'sgc_' . bin2hex( random_bytes( 24 ) );
+ $keys = (array) $settings->get( 'api_keys', array() );
+ $keys[] = array(
+ 'label' => $label,
+ 'hash' => hash( 'sha256', $plaintext ),
+ 'created_at' => gmdate( 'c' ),
+ );
+ $settings->set( 'api_keys', $keys );
+ return $plaintext;
+ }
+
+ public static function revoke_key( Container $container, int $index ): bool {
+ $settings = $container->get( SettingsRepository::class );
+ $keys = (array) $settings->get( 'api_keys', array() );
+ if ( ! isset( $keys[ $index ] ) ) return false;
+ unset( $keys[ $index ] );
+ $settings->set( 'api_keys', array_values( $keys ) );
+ return true;
+ }
+}
diff --git a/sg-commerce-pro/src/Admin/AdminModule.php b/sg-commerce-pro/src/Admin/AdminModule.php
new file mode 100644
index 0000000..6561352
--- /dev/null
+++ b/sg-commerce-pro/src/Admin/AdminModule.php
@@ -0,0 +1,938 @@
+ 'handle_save_amazon',
+ 'sg_test_amazon' => 'handle_test_amazon',
+ 'sg_sync_amazon' => 'handle_sync_amazon',
+ 'sg_save_ai' => 'handle_save_ai',
+ 'sg_test_ai' => 'handle_test_ai',
+ 'sg_describe_product' => 'handle_describe_product',
+ 'sg_translate_product' => 'handle_translate_product',
+ 'sg_add_product' => 'handle_add_product',
+ 'sg_delete_product' => 'handle_delete_product',
+ 'sg_update_cost' => 'handle_update_cost',
+ 'sg_save_settings' => 'handle_save_settings',
+ 'sg_save_repricer' => 'handle_save_repricer',
+ 'sg_run_repricer' => 'handle_run_repricer',
+ 'sg_clear_secret' => 'handle_clear_secret',
+ 'sg_mark_alerts_read' => 'handle_mark_alerts_read',
+ 'sg_toggle_rule' => 'handle_toggle_rule',
+ // MCF
+ 'sg_save_mcf' => 'handle_save_mcf',
+ 'sg_cancel_mcf' => 'handle_cancel_mcf',
+ 'sg_refresh_mcf' => 'handle_refresh_mcf',
+ // Injection
+ 'sg_add_injection_rule' => 'handle_add_injection_rule',
+ 'sg_toggle_injection' => 'handle_toggle_injection',
+ 'sg_delete_injection' => 'handle_delete_injection',
+ 'sg_save_injection_settings' => 'handle_save_injection_settings',
+ // Comparisons
+ 'sg_add_comparison' => 'handle_add_comparison',
+ 'sg_delete_comparison' => 'handle_delete_comparison',
+ // v3.2: PII
+ 'sg_pii_purge_now' => 'handle_pii_purge_now',
+ 'sg_pii_save_settings' => 'handle_pii_save_settings',
+ 'sg_pii_erase_subject' => 'handle_pii_erase_subject',
+ // v3.2: Settlements
+ 'sg_settlement_upload' => 'handle_settlement_upload',
+ // v3.2: API keys
+ 'sg_mint_api_key' => 'handle_mint_api_key',
+ 'sg_revoke_api_key' => 'handle_revoke_api_key',
+ // v3.2: Sandbox + SQS
+ 'sg_toggle_sandbox' => 'handle_toggle_sandbox',
+ 'sg_save_sqs' => 'handle_save_sqs',
+ 'sg_test_sqs' => 'handle_test_sqs',
+ // v3.2: Theme bridge
+ 'sg_theme_resync' => 'handle_theme_resync',
+ // v3.3: Inbound plans
+ 'sg_inbound_create' => 'handle_inbound_create',
+ );
+ foreach ( $handlers as $action => $method ) {
+ add_action( "admin_post_{$action}", array( $this, $method ) );
+ }
+ }
+
+ /* -------------------------------------------------- */
+ /* Menu */
+ /* -------------------------------------------------- */
+
+ public function register_menu(): void {
+ $unread = AlertDispatcher::unread_count();
+ $badge = $unread > 0 ? sprintf( ' %d ', $unread, $unread ) : '';
+
+ add_menu_page(
+ 'Seven Gum Commerce',
+ 'Seven Gum' . $badge,
+ self::CAP,
+ self::MENU,
+ array( $this, 'render_dashboard' ),
+ 'dashicons-cart',
+ 56
+ );
+
+ $pages = array(
+ array( '', 'Dashboard', 'render_dashboard' ),
+ array( '-products', 'Products', 'render_products' ),
+ array( '-amazon', 'Amazon Connect', 'render_amazon' ),
+ array( '-ai', 'AI (Ollama)', 'render_ai' ),
+ array( '-analytics', 'Analytics', 'render_analytics' ),
+ array( '-repricer', 'Repricer', 'render_repricer' ),
+ array( '-automation', 'Automation', 'render_automation' ),
+ array( '-fulfillment', 'Fulfillment', 'render_fulfillment' ),
+ array( '-inbound', 'Inbound Plans', 'render_inbound_plans' ),
+ array( '-catalog', 'Catalog Lookup', 'render_catalog_lookup' ),
+ array( '-injection', 'Auto-Injection', 'render_injection' ),
+ array( '-comparisons', 'Comparisons', 'render_comparisons' ),
+ array( '-widgets', 'Widgets', 'render_widgets' ),
+ array( '-theme-bridge','Theme Bridge', 'render_theme_bridge' ),
+ array( '-pii', 'PII Compliance', 'render_pii' ),
+ array( '-settlements', 'Settlements', 'render_settlements' ),
+ array( '-api-keys', 'API Keys', 'render_api_keys' ),
+ array( '-logs', 'Logs', 'render_logs' ),
+ array( '-settings', 'Settings', 'render_settings' ),
+ );
+ foreach ( $pages as $p ) {
+ add_submenu_page( self::MENU, $p[1], $p[1], self::CAP, self::MENU . $p[0], array( $this, $p[2] ) );
+ }
+ }
+
+ public function enqueue_assets( string $hook ): void {
+ if ( ! str_contains( $hook, self::MENU ) ) {
+ return;
+ }
+ wp_enqueue_style( 'sg-commerce-admin', SG_COMMERCE_URL . 'assets/css/admin.css', array(), SG_COMMERCE_VERSION );
+ wp_enqueue_script( 'sg-commerce-admin', SG_COMMERCE_URL . 'assets/js/admin.js', array( 'jquery' ), SG_COMMERCE_VERSION, true );
+ }
+
+ /* -------------------------------------------------- */
+ /* Views — each delegates to a partial */
+ /* -------------------------------------------------- */
+
+ public function render_dashboard(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/dashboard.php';
+ }
+ public function render_products(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/products.php';
+ }
+ public function render_amazon(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/amazon.php';
+ }
+ public function render_ai(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/ai.php';
+ }
+ public function render_analytics(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/analytics.php';
+ }
+ public function render_repricer(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/repricer.php';
+ }
+ public function render_automation(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/automation.php';
+ }
+ public function render_widgets(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/widgets.php';
+ }
+ public function render_fulfillment(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/fulfillment.php';
+ }
+ public function render_injection(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/injection.php';
+ }
+ public function render_comparisons(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/comparisons.php';
+ }
+ public function render_theme_bridge(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/theme-bridge.php';
+ }
+ public function render_pii(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/pii.php';
+ }
+ public function render_settlements(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/settlements.php';
+ }
+ public function render_api_keys(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/api-keys.php';
+ }
+ public function render_inbound_plans(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/inbound-plans.php';
+ }
+ public function render_catalog_lookup(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/catalog-lookup.php';
+ }
+ public function render_logs(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/logs.php';
+ }
+ public function render_settings(): void {
+ $container = $this->container;
+ require SG_COMMERCE_DIR . 'admin/views/settings.php';
+ }
+
+ /* -------------------------------------------------- */
+ /* Handlers */
+ /* -------------------------------------------------- */
+
+ public function handle_save_amazon(): void {
+ $this->verify( 'sg_save_amazon' );
+ $settings = $this->container->get( SettingsRepository::class );
+
+ if ( ! empty( $_POST['amazon_client_id'] ) ) {
+ $settings->set( 'amazon_client_id', sanitize_text_field( wp_unslash( (string) $_POST['amazon_client_id'] ) ) );
+ }
+ if ( ! empty( $_POST['amazon_client_secret'] ) ) {
+ $settings->set( 'amazon_client_secret', wp_unslash( (string) $_POST['amazon_client_secret'] ) );
+ }
+ if ( ! empty( $_POST['amazon_refresh_token'] ) ) {
+ $settings->set( 'amazon_refresh_token', wp_unslash( (string) $_POST['amazon_refresh_token'] ) );
+ }
+ if ( isset( $_POST['amazon_seller_id'] ) ) {
+ $settings->set( 'amazon_seller_id', sanitize_text_field( wp_unslash( (string) $_POST['amazon_seller_id'] ) ) );
+ }
+ $primary = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['amazon_primary_market'] ?? 'US' ) ) ) );
+ $markets = isset( $_POST['amazon_enabled_markets'] ) && is_array( $_POST['amazon_enabled_markets'] )
+ ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['amazon_enabled_markets'] ) )
+ : array( 'US' );
+
+ $settings->update_many( array(
+ 'amazon_primary_market' => $primary,
+ 'amazon_enabled_markets' => array_values( array_unique( $markets ) ),
+ ) );
+
+ $this->container->get( \SevenGum\Commerce\Amazon\LWATokenManager::class )->clear_cache();
+ $this->flash( 'success', __( 'Amazon credentials saved.', 'sg-commerce' ) );
+ $this->redirect( 'amazon' );
+ }
+
+ public function handle_test_amazon(): void {
+ $this->verify( 'sg_test_amazon' );
+ try {
+ $result = $this->container->get( AmazonClient::class )->test_connection();
+ $this->flash( 'success', sprintf(
+ __( 'Amazon accepted us. Seller account participates in %d marketplace(s).', 'sg-commerce' ),
+ (int) $result['participations']
+ ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'amazon' );
+ }
+
+ public function handle_sync_amazon(): void {
+ $this->verify( 'sg_sync_amazon' );
+ $market = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? '' ) ) ) );
+ try {
+ $orchestrator = $this->container->get( SyncOrchestrator::class );
+ if ( '' === $market ) {
+ $result = $orchestrator->sync_all();
+ $msg = sprintf(
+ __( 'Synced %d products across %d markets.', 'sg-commerce' ),
+ (int) $result['total'],
+ count( $result['by_market'] )
+ );
+ if ( ! empty( $result['errors'] ) ) {
+ $msg .= ' ' . __( 'Errors:', 'sg-commerce' ) . ' ' . implode( '; ', array_map(
+ static fn( $m, $e ) => "{$m}: {$e}",
+ array_keys( $result['errors'] ),
+ array_values( $result['errors'] )
+ ) );
+ }
+ } else {
+ $summary = $orchestrator->sync_market( $market );
+ $msg = sprintf( __( 'Synced %d products from %s.', 'sg-commerce' ), (int) $summary['rows'], $market );
+ }
+ $this->flash( 'success', $msg );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', __( 'Sync failed:', 'sg-commerce' ) . ' ' . $e->getMessage() );
+ }
+ $this->redirect( 'products' );
+ }
+
+ public function handle_save_ai(): void {
+ $this->verify( 'sg_save_ai' );
+ $settings = $this->container->get( SettingsRepository::class );
+
+ $settings->update_many( array(
+ 'ai_provider' => sanitize_text_field( wp_unslash( (string) ( $_POST['ai_provider'] ?? 'ollama' ) ) ),
+ 'ollama_url' => esc_url_raw( wp_unslash( (string) ( $_POST['ollama_url'] ?? 'http://127.0.0.1:11434' ) ) ),
+ 'ollama_model' => sanitize_text_field( wp_unslash( (string) ( $_POST['ollama_model'] ?? 'llama3.1:8b' ) ) ),
+ 'ai_temperature' => max( 0.0, min( 2.0, (float) ( $_POST['ai_temperature'] ?? 0.7 ) ) ),
+ 'ai_brand_voice' => sanitize_textarea_field( wp_unslash( (string) ( $_POST['ai_brand_voice'] ?? '' ) ) ),
+ ) );
+
+ if ( ! empty( $_POST['ollama_auth_token'] ) ) {
+ $settings->set( 'ollama_auth_token', wp_unslash( (string) $_POST['ollama_auth_token'] ) );
+ }
+
+ $this->flash( 'success', __( 'AI settings saved.', 'sg-commerce' ) );
+ $this->redirect( 'ai' );
+ }
+
+ public function handle_test_ai(): void {
+ $this->verify( 'sg_test_ai' );
+ try {
+ $models = $this->container->get( OllamaProvider::class )->list_models();
+ $names = array_map( static fn( $m ) => $m['name'], $models );
+ $this->flash( 'success', sprintf(
+ __( 'Ollama is reachable. %d model(s) installed: %s', 'sg-commerce' ),
+ count( $names ),
+ '' !== implode( ', ', $names ) ? implode( ', ', $names ) : '(none — run: ollama pull llama3.1:8b)'
+ ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'ai' );
+ }
+
+ public function handle_describe_product(): void {
+ $this->verify( 'sg_describe_product' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ try {
+ $this->container->get( Copywriter::class )->describe( $id );
+ $this->flash( 'success', sprintf( __( 'Generated English description for product #%d.', 'sg-commerce' ), $id ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', __( 'AI failed:', 'sg-commerce' ) . ' ' . $e->getMessage() );
+ }
+ $this->redirect( 'products' );
+ }
+
+ public function handle_translate_product(): void {
+ $this->verify( 'sg_translate_product' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ $market = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['target_market'] ?? '' ) ) ) );
+ try {
+ $this->container->get( Copywriter::class )->translate( $id, $market );
+ $this->flash( 'success', sprintf( __( 'Translated product #%d for market %s.', 'sg-commerce' ), $id, $market ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', __( 'Translation failed:', 'sg-commerce' ) . ' ' . $e->getMessage() );
+ }
+ $this->redirect( 'products' );
+ }
+
+ public function handle_add_product(): void {
+ $this->verify( 'sg_add_product' );
+ $sku = strtoupper( preg_replace( '/[^A-Z0-9_\-\.]/i', '', wp_unslash( (string) ( $_POST['sku'] ?? '' ) ) ) );
+ if ( '' === $sku ) {
+ $this->flash( 'error', __( 'SKU is required.', 'sg-commerce' ) );
+ $this->redirect( 'products' );
+ return;
+ }
+ try {
+ $this->container->get( ProductRepository::class )->add_manual( array(
+ 'market' => sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? 'US' ) ) ),
+ 'sku' => $sku,
+ 'asin' => preg_replace( '/[^A-Z0-9]/', '', wp_unslash( (string) ( $_POST['asin'] ?? '' ) ) ),
+ 'product_name' => sanitize_text_field( wp_unslash( (string) ( $_POST['product_name'] ?? '' ) ) ),
+ 'image_url' => esc_url_raw( wp_unslash( (string) ( $_POST['image_url'] ?? '' ) ) ),
+ 'cost_price' => isset( $_POST['cost_price'] ) && '' !== $_POST['cost_price'] ? (float) $_POST['cost_price'] : null,
+ ) );
+ $this->flash( 'success', __( 'Product added.', 'sg-commerce' ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'products' );
+ }
+
+ public function handle_delete_product(): void {
+ $this->verify( 'sg_delete_product' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ if ( $id > 0 ) {
+ $this->container->get( ProductRepository::class )->delete( $id );
+ $this->flash( 'success', __( 'Product removed.', 'sg-commerce' ) );
+ }
+ $this->redirect( 'products' );
+ }
+
+ public function handle_update_cost(): void {
+ $this->verify( 'sg_update_cost' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ $cost = (float) ( $_POST['cost'] ?? 0 );
+ if ( $id > 0 && $cost > 0 ) {
+ $this->container->get( ProductRepository::class )->update_cost( $id, $cost );
+ $this->flash( 'success', __( 'Cost updated.', 'sg-commerce' ) );
+ }
+ $this->redirect( 'products' );
+ }
+
+ public function handle_save_settings(): void {
+ $this->verify( 'sg_save_settings' );
+ $settings = $this->container->get( SettingsRepository::class );
+ $settings->update_many( array(
+ 'log_level' => sanitize_text_field( wp_unslash( (string) ( $_POST['log_level'] ?? 'info' ) ) ),
+ 'log_retention_days' => max( 1, (int) ( $_POST['log_retention_days'] ?? 30 ) ),
+ 'sync_pages_per_run' => max( 1, min( 50, (int) ( $_POST['sync_pages_per_run'] ?? 5 ) ) ),
+ 'referral_fee_pct' => max( 0.0, min( 50.0, (float) ( $_POST['referral_fee_pct'] ?? 15.0 ) ) ),
+ 'fba_pick_pack_fee' => max( 0.0, (float) ( $_POST['fba_pick_pack_fee'] ?? 3.0 ) ),
+ ) );
+ $this->flash( 'success', __( 'Settings saved.', 'sg-commerce' ) );
+ $this->redirect( 'settings' );
+ }
+
+ public function handle_save_repricer(): void {
+ $this->verify( 'sg_save_repricer' );
+ $settings = $this->container->get( SettingsRepository::class );
+ $settings->update_many( array(
+ 'repricer_enabled' => ! empty( $_POST['repricer_enabled'] ),
+ 'repricer_strategy' => sanitize_text_field( wp_unslash( (string) ( $_POST['repricer_strategy'] ?? 'match_buybox' ) ) ),
+ 'repricer_dry_run' => ! empty( $_POST['repricer_dry_run'] ),
+ 'repricer_cents_below_buybox'=> max( 0.0, (float) ( $_POST['repricer_cents_below_buybox'] ?? 0.01 ) ),
+ 'repricer_min_margin_pct' => max( 0.0, min( 100.0, (float) ( $_POST['repricer_min_margin_pct'] ?? 15.0 ) ) ),
+ ) );
+ $this->flash( 'success', __( 'Repricer settings saved.', 'sg-commerce' ) );
+ $this->redirect( 'repricer' );
+ }
+
+ public function handle_run_repricer(): void {
+ $this->verify( 'sg_run_repricer' );
+ try {
+ $result = $this->container->get( RepricerEngine::class )->tick();
+ $this->flash( 'success', sprintf(
+ __( 'Repricer ran. Adjusted %d, skipped %d (margin floor).', 'sg-commerce' ),
+ (int) ( $result['adjusted'] ?? 0 ),
+ (int) ( $result['skipped_floor'] ?? 0 )
+ ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'repricer' );
+ }
+
+ public function handle_clear_secret(): void {
+ $this->verify( 'sg_clear_secret' );
+ $key = sanitize_key( (string) ( $_POST['secret'] ?? '' ) );
+ if ( '' !== $key ) {
+ $this->container->get( SettingsRepository::class )->delete( $key );
+ $this->flash( 'success', __( 'Secret cleared.', 'sg-commerce' ) );
+ }
+ $page = sanitize_key( (string) ( $_POST['return'] ?? 'amazon' ) );
+ $this->redirect( $page );
+ }
+
+ public function handle_mark_alerts_read(): void {
+ $this->verify( 'sg_mark_alerts_read' );
+ $count = AlertDispatcher::mark_all_read();
+ $this->flash( 'success', sprintf( __( '%d alert(s) marked as read.', 'sg-commerce' ), $count ) );
+ $this->redirect( 'automation' );
+ }
+
+ public function handle_toggle_rule(): void {
+ $this->verify( 'sg_toggle_rule' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ if ( $id > 0 ) {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}sg_rules SET is_active = 1 - is_active WHERE id = %d",
+ $id
+ ) );
+ }
+ $this->redirect( 'automation' );
+ }
+
+ /* -------------------- MCF handlers -------------------- */
+
+ public function handle_save_mcf(): void {
+ $this->verify( 'sg_save_mcf' );
+ $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class )->update_many( array(
+ 'mcf_auto_wc' => ! empty( $_POST['mcf_auto_wc'] ),
+ 'mcf_default_market' => strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['mcf_default_market'] ?? 'US' ) ) ) ),
+ 'mcf_default_speed' => sanitize_text_field( wp_unslash( (string) ( $_POST['mcf_default_speed'] ?? 'Standard' ) ) ),
+ ) );
+ $this->flash( 'success', __( 'Fulfillment settings saved.', 'sg-commerce' ) );
+ $this->redirect( 'fulfillment' );
+ }
+
+ public function handle_cancel_mcf(): void {
+ $this->verify( 'sg_cancel_mcf' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ try {
+ $repo = $this->container->get( \SevenGum\Commerce\Fulfillment\MCFOrderRepository::class );
+ $row = $repo->find( $id );
+ if ( null === $row ) throw new \RuntimeException( 'MCF order not found.' );
+ $this->container->get( \SevenGum\Commerce\Fulfillment\MCFClient::class )
+ ->cancel( (string) $row['market'], (string) $row['seller_fulfillment_order_id'] );
+ global $wpdb;
+ $wpdb->update( $wpdb->prefix . 'sg_mcf_orders', array( 'status' => 'cancelled' ), array( 'id' => $id ) );
+ $this->flash( 'success', __( 'MCF order cancellation requested.', 'sg-commerce' ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'fulfillment' );
+ }
+
+ public function handle_refresh_mcf(): void {
+ $this->verify( 'sg_refresh_mcf' );
+ try {
+ $fulfillment = $this->container->get( \SevenGum\Commerce\Fulfillment\FulfillmentModule::class );
+ $fulfillment->refresh_pending_tracking();
+ $this->flash( 'success', __( 'MCF tracking refreshed.', 'sg-commerce' ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'fulfillment' );
+ }
+
+ /* -------------------- Injection handlers -------------------- */
+
+ public function handle_add_injection_rule(): void {
+ $this->verify( 'sg_add_injection_rule' );
+ global $wpdb;
+ $pattern = sanitize_text_field( wp_unslash( (string) ( $_POST['pattern'] ?? '' ) ) );
+ $sku = sanitize_text_field( wp_unslash( (string) ( $_POST['sku'] ?? '' ) ) );
+ if ( '' === $pattern || '' === $sku ) {
+ $this->flash( 'error', __( 'Pattern and SKU are required.', 'sg-commerce' ) );
+ $this->redirect( 'injection' );
+ return;
+ }
+ $result = $wpdb->insert( $wpdb->prefix . 'sg_injection_rules', array(
+ 'pattern' => $pattern,
+ 'pattern_type' => in_array( (string) ( $_POST['pattern_type'] ?? 'keyword' ), array( 'keyword', 'regex' ), true )
+ ? (string) $_POST['pattern_type'] : 'keyword',
+ 'sku' => $sku,
+ 'market' => strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? '' ) ) ) ),
+ 'render_as' => in_array( (string) ( $_POST['render_as'] ?? 'link' ), array( 'link', 'widget', 'tooltip' ), true )
+ ? (string) $_POST['render_as'] : 'link',
+ 'priority' => max( 1, (int) ( $_POST['priority'] ?? 10 ) ),
+ 'max_per_post' => max( 1, (int) ( $_POST['max_per_post'] ?? 1 ) ),
+ 'is_active' => 1,
+ 'created_at' => current_time( 'mysql', true ),
+ ), array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ) );
+ if ( false === $result ) {
+ $this->flash( 'error', __( 'Could not save rule.', 'sg-commerce' ) );
+ } else {
+ $this->flash( 'success', __( 'Injection rule added.', 'sg-commerce' ) );
+ }
+ $this->redirect( 'injection' );
+ }
+
+ public function handle_toggle_injection(): void {
+ $this->verify( 'sg_toggle_injection' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ if ( $id > 0 ) {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}sg_injection_rules SET is_active = 1 - is_active WHERE id = %d",
+ $id
+ ) );
+ }
+ $this->redirect( 'injection' );
+ }
+
+ public function handle_delete_injection(): void {
+ $this->verify( 'sg_delete_injection' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ if ( $id > 0 ) {
+ global $wpdb;
+ $wpdb->delete( $wpdb->prefix . 'sg_injection_rules', array( 'id' => $id ), array( '%d' ) );
+ $this->flash( 'success', __( 'Rule removed.', 'sg-commerce' ) );
+ }
+ $this->redirect( 'injection' );
+ }
+
+ public function handle_save_injection_settings(): void {
+ $this->verify( 'sg_save_injection_settings' );
+ $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class )
+ ->set( 'injection_enabled', ! empty( $_POST['injection_enabled'] ) );
+ $this->flash( 'success', __( 'Injection settings saved. Note: re-enable may require a plugin reload.', 'sg-commerce' ) );
+ $this->redirect( 'injection' );
+ }
+
+ /* -------------------- Comparisons handlers -------------------- */
+
+ public function handle_add_comparison(): void {
+ $this->verify( 'sg_add_comparison' );
+ global $wpdb;
+ $slug = sanitize_key( (string) ( $_POST['slug'] ?? '' ) );
+ $title = sanitize_text_field( wp_unslash( (string) ( $_POST['title'] ?? '' ) ) );
+ $skus_raw = (string) ( $_POST['skus'] ?? '' );
+ $skus = array_filter( array_map( 'trim', explode( ',', $skus_raw ) ) );
+ if ( '' === $slug || empty( $skus ) ) {
+ $this->flash( 'error', __( 'Slug and at least one SKU required.', 'sg-commerce' ) );
+ $this->redirect( 'comparisons' );
+ return;
+ }
+ $now = current_time( 'mysql', true );
+ $wpdb->query( $wpdb->prepare(
+ "INSERT INTO {$wpdb->prefix}sg_comparisons (slug, title, skus, market, features, created_at, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE
+ title = VALUES(title),
+ skus = VALUES(skus),
+ market = VALUES(market),
+ features = VALUES(features),
+ updated_at = VALUES(updated_at)",
+ $slug,
+ $title,
+ wp_json_encode( array_values( $skus ) ),
+ strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? '' ) ) ) ),
+ sanitize_text_field( wp_unslash( (string) ( $_POST['features'] ?? '' ) ) ),
+ $now, $now
+ ) );
+ $this->flash( 'success', __( 'Comparison saved.', 'sg-commerce' ) );
+ $this->redirect( 'comparisons' );
+ }
+
+ public function handle_delete_comparison(): void {
+ $this->verify( 'sg_delete_comparison' );
+ $id = (int) ( $_POST['id'] ?? 0 );
+ if ( $id > 0 ) {
+ global $wpdb;
+ $wpdb->delete( $wpdb->prefix . 'sg_comparisons', array( 'id' => $id ), array( '%d' ) );
+ $this->flash( 'success', __( 'Comparison removed.', 'sg-commerce' ) );
+ }
+ $this->redirect( 'comparisons' );
+ }
+
+ /* -------------------- PII handlers (v3.2) -------------------- */
+
+ public function handle_pii_purge_now(): void {
+ $this->verify( 'sg_pii_purge_now' );
+ try {
+ $count = $this->container->get( \SevenGum\Commerce\Security\PIIManager\PIIManager::class )->purge_expired();
+ $this->flash( 'success', sprintf( __( 'Anonymized %d expired orders.', 'sg-commerce' ), $count ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'pii' );
+ }
+
+ public function handle_pii_save_settings(): void {
+ $this->verify( 'sg_pii_save_settings' );
+ $days = max( 1, min( 3650, (int) ( $_POST['pii_retention_days'] ?? 30 ) ) );
+ $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class )
+ ->set( 'pii_retention_days', $days );
+ $this->flash( 'success', __( 'PII retention updated.', 'sg-commerce' ) );
+ $this->redirect( 'pii' );
+ }
+
+ public function handle_pii_erase_subject(): void {
+ $this->verify( 'sg_pii_erase_subject' );
+ $email = sanitize_email( wp_unslash( (string) ( $_POST['email'] ?? '' ) ) );
+ if ( '' === $email ) {
+ $this->flash( 'error', __( 'Email required.', 'sg-commerce' ) );
+ $this->redirect( 'pii' );
+ return;
+ }
+ try {
+ $count = $this->container->get( \SevenGum\Commerce\Security\PIIManager\PIIManager::class )->erase_subject_data( $email );
+ $this->flash( 'success', sprintf( __( 'Erased PII for %d orders.', 'sg-commerce' ), $count ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'pii' );
+ }
+
+ /* -------------------- Settlement handlers (v3.2) -------------------- */
+
+ public function handle_settlement_upload(): void {
+ $this->verify( 'sg_settlement_upload' );
+ if ( empty( $_FILES['settlement_file']['tmp_name'] ) ) {
+ $this->flash( 'error', __( 'No file uploaded.', 'sg-commerce' ) );
+ $this->redirect( 'settlements' );
+ return;
+ }
+ $tmp = (string) $_FILES['settlement_file']['tmp_name'];
+ if ( ! is_uploaded_file( $tmp ) ) {
+ $this->flash( 'error', __( 'Invalid upload.', 'sg-commerce' ) );
+ $this->redirect( 'settlements' );
+ return;
+ }
+ $max_size = 20 * MB_IN_BYTES;
+ if ( filesize( $tmp ) > $max_size ) {
+ $this->flash( 'error', __( 'File too large (max 20MB).', 'sg-commerce' ) );
+ $this->redirect( 'settlements' );
+ return;
+ }
+ try {
+ $content = (string) file_get_contents( $tmp );
+ $parser = new \SevenGum\Commerce\Amazon\ReportsParser\SettlementsParser();
+ $events = $parser->parse( $content );
+
+ $settlement_id = sanitize_text_field( wp_unslash( (string) ( $_POST['settlement_id'] ?? 'MANUAL-' . gmdate( 'YmdHis' ) ) ) );
+
+ global $wpdb;
+ $table = $wpdb->prefix . 'sg_settlements';
+ $imported = 0;
+ foreach ( $events as $e ) {
+ $result = $wpdb->insert( $table, array(
+ 'event_date' => $e['date'] ? gmdate( 'Y-m-d', strtotime( $e['date'] ) ) : null,
+ 'event_type' => $e['type'],
+ 'sku' => $e['sku'],
+ 'order_id' => $e['order_id'],
+ 'marketplace' => $e['marketplace'],
+ 'amount' => $e['amount'],
+ 'currency' => $e['currency'],
+ 'description' => substr( $e['description'], 0, 190 ),
+ 'settlement_id' => $settlement_id,
+ 'imported_at' => current_time( 'mysql', true ),
+ ), array( '%s', '%s', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%s' ) );
+ if ( false !== $result ) $imported++;
+ }
+ $this->flash( 'success', sprintf( __( 'Imported %1$d of %2$d events.', 'sg-commerce' ), $imported, count( $events ) ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'settlements' );
+ }
+
+ /* -------------------- API key handlers (v3.2) -------------------- */
+
+ public function handle_mint_api_key(): void {
+ $this->verify( 'sg_mint_api_key' );
+ $label = sanitize_text_field( wp_unslash( (string) ( $_POST['label'] ?? 'Dashboard' ) ) );
+ $key = \SevenGum\Commerce\API\HeadlessAPI::mint_key( $this->container, $label );
+ // Flash the raw key once so the page shows it.
+ set_transient( 'sg_new_api_key_' . get_current_user_id(), $key, 60 );
+ $this->flash( 'success', __( 'API key minted. Copy it from the page — it will only be shown once.', 'sg-commerce' ) );
+ $this->redirect( 'api-keys' );
+ }
+
+ public function handle_revoke_api_key(): void {
+ $this->verify( 'sg_revoke_api_key' );
+ $index = (int) ( $_POST['index'] ?? -1 );
+ \SevenGum\Commerce\API\HeadlessAPI::revoke_key( $this->container, $index );
+ $this->flash( 'success', __( 'API key revoked.', 'sg-commerce' ) );
+ $this->redirect( 'api-keys' );
+ }
+
+ /* -------------------- Sandbox + SQS handlers (v3.2) -------------------- */
+
+ public function handle_toggle_sandbox(): void {
+ $this->verify( 'sg_toggle_sandbox' );
+ if ( \SevenGum\Commerce\Testing\Sandbox::is_enabled() ) {
+ \SevenGum\Commerce\Testing\Sandbox::disable();
+ $this->flash( 'success', __( 'Sandbox mode disabled.', 'sg-commerce' ) );
+ } else {
+ \SevenGum\Commerce\Testing\Sandbox::enable();
+ $this->flash( 'success', __( 'Sandbox mode enabled — SP-API calls return mocked data.', 'sg-commerce' ) );
+ }
+ $this->redirect( 'settings' );
+ }
+
+ public function handle_save_sqs(): void {
+ $this->verify( 'sg_save_sqs' );
+ $settings = $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class );
+ $enc = $this->container->get( \SevenGum\Commerce\Security\Encryption::class );
+
+ $settings->set( 'sqs_enabled', ! empty( $_POST['sqs_enabled'] ) );
+ $settings->set( 'sqs_region', sanitize_text_field( wp_unslash( (string) ( $_POST['sqs_region'] ?? 'us-east-1' ) ) ) );
+ $settings->set( 'sqs_queue_url', esc_url_raw( wp_unslash( (string) ( $_POST['sqs_queue_url'] ?? '' ) ) ) );
+ $settings->set( 'sqs_access_key', sanitize_text_field( wp_unslash( (string) ( $_POST['sqs_access_key'] ?? '' ) ) ) );
+
+ $new_secret = (string) ( $_POST['sqs_secret_key'] ?? '' );
+ if ( '' !== $new_secret ) {
+ $settings->set( 'sqs_secret_key_enc', $enc->encrypt( $new_secret ) );
+ }
+
+ // Toggle cron based on enabled state.
+ if ( ! empty( $_POST['sqs_enabled'] ) ) {
+ if ( ! wp_next_scheduled( 'sg_commerce_sqs_poll' ) ) {
+ wp_schedule_event( time() + 60, 'sg_five_minutes', 'sg_commerce_sqs_poll' );
+ }
+ } else {
+ wp_clear_scheduled_hook( 'sg_commerce_sqs_poll' );
+ }
+
+ $this->flash( 'success', __( 'SQS settings saved.', 'sg-commerce' ) );
+ $this->redirect( 'settings' );
+ }
+
+ public function handle_test_sqs(): void {
+ $this->verify( 'sg_test_sqs' );
+ try {
+ $processed = $this->container->get( \SevenGum\Commerce\Amazon\SQSConsumer\SQSConsumer::class )->poll();
+ $this->flash( 'success', sprintf( __( 'SQS poll test: %d messages processed.', 'sg-commerce' ), $processed ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'settings' );
+ }
+
+ /* -------------------- Theme bridge handlers (v3.2) -------------------- */
+
+ public function handle_theme_resync(): void {
+ $this->verify( 'sg_theme_resync' );
+ try {
+ $this->container->get( \SevenGum\Commerce\Integration\ThemeBridge::class )->on_sync_completed( array() );
+ $this->flash( 'success', __( 'Theme resync complete. Every mapped product got fresh price + URL.', 'sg-commerce' ) );
+ } catch ( \Throwable $e ) {
+ $this->flash( 'error', $e->getMessage() );
+ }
+ $this->redirect( 'theme-bridge' );
+ }
+
+ /* -------------------- Inbound plan handlers (v3.3) -------------------- */
+
+ public function handle_inbound_create(): void {
+ $this->verify( 'sg_inbound_create' );
+
+ $name = sanitize_text_field( wp_unslash( (string) ( $_POST['plan_name'] ?? '' ) ) );
+ $market = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? 'US' ) ) ) );
+ $msku = sanitize_text_field( wp_unslash( (string) ( $_POST['msku'] ?? '' ) ) );
+ $packs_per_carton = max( 1, (int) ( $_POST['packs_per_carton'] ?? 24 ) );
+ $carton_count = max( 1, (int) ( $_POST['carton_count'] ?? 0 ) );
+ $label_owner = in_array( (string) ( $_POST['label_owner'] ?? 'NONE' ), array( 'NONE', 'SELLER', 'AMAZON' ), true )
+ ? (string) $_POST['label_owner'] : 'NONE';
+
+ if ( '' === $name || '' === $msku || $carton_count < 1 ) {
+ $this->flash( 'error', __( 'Plan name, MSKU, and carton count are required.', 'sg-commerce' ) );
+ $this->redirect( 'inbound' );
+ return;
+ }
+
+ $source_address = array(
+ 'name' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_name'] ?? '' ) ) ),
+ 'address1' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_address1'] ?? '' ) ) ),
+ 'city' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_city'] ?? '' ) ) ),
+ 'state' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_state'] ?? '' ) ) ),
+ 'postal_code' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_postal'] ?? '' ) ) ),
+ 'country_code' => strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['src_country'] ?? 'CN' ) ) ) ),
+ 'phone' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_phone'] ?? '' ) ) ),
+ );
+
+ $total_units = $carton_count * $packs_per_carton;
+
+ // Persist as draft — operator can then advance through STA steps.
+ global $wpdb;
+ $inserted = $wpdb->insert( $wpdb->prefix . 'sg_inbound_plans', array(
+ 'amazon_plan_id' => '',
+ 'name' => $name,
+ 'market' => $market,
+ 'status' => 'draft',
+ 'source_address' => wp_json_encode( $source_address ),
+ 'items' => wp_json_encode( array(
+ array(
+ 'msku' => $msku,
+ 'quantity' => $total_units,
+ 'prep_owner' => 'SELLER',
+ 'label_owner' => $label_owner,
+ ),
+ ) ),
+ 'cartons' => wp_json_encode( array(
+ array(
+ 'template_id' => 'sg-master-24pack',
+ 'quantity' => $carton_count,
+ 'contents' => array( array( 'msku' => $msku, 'quantity' => $packs_per_carton ) ),
+ ),
+ ) ),
+ 'total_cartons' => $carton_count,
+ 'total_units' => $total_units,
+ 'created_at' => current_time( 'mysql', true ),
+ ), array( '%s','%s','%s','%s','%s','%s','%s','%d','%d','%s' ) );
+
+ if ( false === $inserted ) {
+ $this->flash( 'error', __( 'Could not persist plan. DB error:', 'sg-commerce' ) . ' ' . $wpdb->last_error );
+ } else {
+ $this->flash( 'success', sprintf(
+ /* translators: %1$d cartons, %2$d units */
+ __( 'Draft plan saved (%1$d cartons = %2$d units). Next step: advance to Amazon via WP-CLI or admin workflow.', 'sg-commerce' ),
+ $carton_count, $total_units
+ ) );
+ }
+ $this->redirect( 'inbound' );
+ }
+
+ /* -------------------------------------------------- */
+ /* Helpers */
+ /* -------------------------------------------------- */
+
+ private function verify( string $action ): void {
+ if ( ! current_user_can( self::CAP ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'sg-commerce' ) );
+ }
+ check_admin_referer( $action, self::NONCE );
+ }
+
+ private function redirect( string $page ): void {
+ $slug = self::MENU . ( '' !== $page && 'dashboard' !== $page ? '-' . $page : '' );
+ wp_safe_redirect( admin_url( 'admin.php?page=' . $slug ) );
+ exit;
+ }
+
+ public function flash( string $type, string $message ): void {
+ set_transient( 'sg_commerce_notice_' . get_current_user_id(), array( 'type' => $type, 'msg' => $message ), 60 );
+ }
+
+ public static function render_flash(): void {
+ $key = 'sg_commerce_notice_' . get_current_user_id();
+ $notice = get_transient( $key );
+ if ( ! is_array( $notice ) ) return;
+ delete_transient( $key );
+ printf(
+ '',
+ esc_attr( (string) $notice['type'] ),
+ esc_html( (string) $notice['msg'] )
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/AmazonClient.php b/sg-commerce-pro/src/Amazon/AmazonClient.php
new file mode 100644
index 0000000..ff56a60
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/AmazonClient.php
@@ -0,0 +1,198 @@
+tokens->has_credentials();
+ }
+
+ /**
+ * Accessor for marketplace metadata — used by modules that need the
+ * marketplace ID/region without taking a Marketplaces dep of their own.
+ */
+ public function marketplace_info( string $market ): ?array {
+ return $this->marketplaces->get( $market );
+ }
+
+ /**
+ * Send a signed SP-API request.
+ *
+ * @param string $method HTTP method.
+ * @param string $market Marketplace code (US, DE, ...).
+ * @param string $path Path component, e.g. /fba/inventory/v1/summaries
+ * @param array $query Query parameters.
+ * @param array|null $body JSON body for POST/PATCH.
+ * @param string $endpoint_key Rate-limit bucket name (see RateLimiter::LIMITS).
+ *
+ * @return array Decoded JSON response.
+ *
+ * @throws \RuntimeException on permanent failure.
+ */
+ public function request(
+ string $method,
+ string $market,
+ string $path,
+ array $query = array(),
+ ?array $body = null,
+ string $endpoint_key = 'default'
+ ): array {
+ // Sandbox mode — return canned responses, never touch the network.
+ if ( \SevenGum\Commerce\Testing\Sandbox::is_enabled() ) {
+ return \SevenGum\Commerce\Testing\Sandbox::mock( $method, $path );
+ }
+
+ $mp = $this->marketplaces->get( $market );
+ if ( null === $mp ) {
+ throw new \RuntimeException( "Unknown marketplace: {$market}" );
+ }
+
+ // Circuit breaker first — if downstream is down, don't even try.
+ $this->breaker->guard( 'sp-api.' . $mp['region'] );
+
+ // Rate limit (block up to 5s).
+ if ( ! $this->rate_limiter->acquire_blocking( $endpoint_key, 5.0 ) ) {
+ throw new \RuntimeException( "Rate limit exceeded for {$endpoint_key} after waiting." );
+ }
+
+ $url = $mp['endpoint'] . $path . ( ! empty( $query ) ? '?' . http_build_query( $query ) : '' );
+
+ $last_exception = null;
+ for ( $attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++ ) {
+ try {
+ $result = $this->fire( $method, $url, $body, $attempt );
+ $this->breaker->record_success( 'sp-api.' . $mp['region'] );
+ return $result;
+ } catch ( RetryableException $e ) {
+ $last_exception = $e;
+ $wait = (int) pow( 2, $attempt - 1 ); // 1s, 2s, 4s
+ $this->logger->warning( 'SP-API retryable error', array(
+ 'attempt' => $attempt,
+ 'wait' => $wait,
+ 'msg' => $e->getMessage(),
+ ) );
+ sleep( $wait );
+ continue;
+ } catch ( \Throwable $e ) {
+ $this->breaker->record_failure( 'sp-api.' . $mp['region'] );
+ throw $e;
+ }
+ }
+
+ $this->breaker->record_failure( 'sp-api.' . $mp['region'] );
+ throw new \RuntimeException(
+ 'SP-API failed after ' . self::MAX_RETRIES . ' attempts: ' . ( $last_exception ? $last_exception->getMessage() : 'unknown' )
+ );
+ }
+
+ /** @throws RetryableException|\RuntimeException */
+ private function fire( string $method, string $url, ?array $body, int $attempt ): array {
+ $args = array(
+ 'method' => $method,
+ 'timeout' => 30,
+ 'headers' => array(
+ 'x-amz-access-token' => $this->tokens->access_token( $attempt > 1 ),
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ 'User-Agent' => self::USER_AGENT,
+ ),
+ );
+ if ( null !== $body ) {
+ $args['body'] = wp_json_encode( $body );
+ }
+
+ $response = wp_remote_request( $url, $args );
+ if ( is_wp_error( $response ) ) {
+ throw new RetryableException( 'Network error: ' . $response->get_error_message() );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $raw_body = (string) wp_remote_retrieve_body( $response );
+ $json = json_decode( $raw_body, true );
+
+ if ( in_array( $code, array( 429, 500, 502, 503, 504 ), true ) ) {
+ throw new RetryableException( "SP-API HTTP {$code}" );
+ }
+ if ( $code >= 400 ) {
+ $msg = "HTTP {$code}";
+ if ( is_array( $json ) && isset( $json['errors'][0]['message'] ) ) {
+ $msg .= ' — ' . (string) $json['errors'][0]['message'];
+ } elseif ( '' !== $raw_body ) {
+ $msg .= ' — ' . substr( $raw_body, 0, 200 );
+ }
+ throw new \RuntimeException( $msg );
+ }
+
+ return is_array( $json ) ? $json : array();
+ }
+
+ /**
+ * Lightweight credential test using getMarketplaceParticipations.
+ */
+ public function test_connection(): array {
+ $this->tokens->clear_cache();
+ $token = $this->tokens->access_token();
+ $mp = $this->marketplaces->get( $this->marketplaces->primary() );
+
+ $response = wp_remote_get(
+ $mp['endpoint'] . '/sellers/v1/marketplaceParticipations',
+ array(
+ 'timeout' => 20,
+ 'headers' => array(
+ 'x-amz-access-token' => $token,
+ 'Accept' => 'application/json',
+ 'User-Agent' => self::USER_AGENT,
+ ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ throw new \RuntimeException( 'Network error: ' . $response->get_error_message() );
+ }
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $body = (string) wp_remote_retrieve_body( $response );
+ $json = json_decode( $body, true );
+
+ if ( 200 !== $code ) {
+ $msg = is_array( $json ) && isset( $json['errors'][0]['message'] )
+ ? $json['errors'][0]['message']
+ : "HTTP {$code}";
+ throw new \RuntimeException( "Amazon rejected us: {$msg}" );
+ }
+
+ return array(
+ 'ok' => true,
+ 'token_length' => strlen( $token ),
+ 'participations' => is_array( $json ) && isset( $json['payload'] ) ? count( $json['payload'] ) : 0,
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/AmazonModule.php b/sg-commerce-pro/src/Amazon/AmazonModule.php
new file mode 100644
index 0000000..09a0602
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/AmazonModule.php
@@ -0,0 +1,33 @@
+container->get( SyncOrchestrator::class )->cron_sync();
+ } );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/AplusContent/AplusContentClient.php b/sg-commerce-pro/src/Amazon/AplusContent/AplusContentClient.php
new file mode 100644
index 0000000..6a2f54c
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/AplusContent/AplusContentClient.php
@@ -0,0 +1,208 @@
+ $name,
+ 'contentType' => 'EBC',
+ 'contentSubType' => 'EBC',
+ 'locale' => $locale,
+ 'contentModuleList' => $modules,
+ );
+ $response = $this->amazon->request(
+ 'POST',
+ $market,
+ '/aplus/2020-11-01/contentDocuments',
+ array(),
+ $body,
+ 'aplus.create'
+ );
+ $this->logger->info( 'A+ content document created', array(
+ 'name' => $name, 'ref' => $response['contentReferenceKey'] ?? null,
+ ) );
+ return $response;
+ }
+
+ public function get( string $content_reference_key, string $market ): array {
+ return $this->amazon->request(
+ 'GET',
+ $market,
+ "/aplus/2020-11-01/contentDocuments/{$content_reference_key}",
+ array( 'includedDataSet' => 'CONTENTS' ),
+ null,
+ 'aplus.get'
+ );
+ }
+
+ public function update( string $content_reference_key, array $document, string $market ): array {
+ return $this->amazon->request(
+ 'POST',
+ $market,
+ "/aplus/2020-11-01/contentDocuments/{$content_reference_key}",
+ array(),
+ array( 'contentDocument' => $document ),
+ 'aplus.update'
+ );
+ }
+
+ /**
+ * Publish a content document against specific ASINs in a market.
+ *
+ * @param string[] $asins
+ */
+ public function publish( string $content_reference_key, array $asins, string $market ): array {
+ $records = array_map( static fn( $asin ) => array( 'asin' => (string) $asin ), $asins );
+ return $this->amazon->request(
+ 'POST',
+ $market,
+ "/aplus/2020-11-01/contentDocuments/{$content_reference_key}/asins",
+ array(),
+ array( 'asinSet' => $records ),
+ 'aplus.publish'
+ );
+ }
+
+ /* ---------------- Module builders ---------------- */
+ /*
+ * These return structured arrays matching Amazon's module schemas.
+ * Use them to compose a modules array passed to create().
+ */
+
+ public static function build_standard_headline( string $headline, string $body_text = '' ): array {
+ return array(
+ 'contentModuleType' => 'STANDARD_HEADER_IMAGE_TEXT',
+ 'standardHeaderImageTextModule' => array(
+ 'headline' => array(
+ 'value' => $headline,
+ 'decoratorSet' => array(),
+ ),
+ 'body' => array(
+ 'textList' => array(
+ array( 'text' => array( 'value' => $body_text ) ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ public static function build_product_description( string $body_text ): array {
+ return array(
+ 'contentModuleType' => 'STANDARD_PRODUCT_DESCRIPTION',
+ 'standardProductDescriptionModule' => array(
+ 'body' => array(
+ 'textList' => array( array( 'text' => array( 'value' => $body_text ) ) ),
+ ),
+ ),
+ );
+ }
+
+ public static function build_four_image_quadrant(
+ string $headline,
+ array $quadrants
+ ): array {
+ $blocks = array();
+ foreach ( array_slice( $quadrants, 0, 4 ) as $q ) {
+ $blocks[] = array(
+ 'image' => array(
+ 'uploadDestinationId' => (string) ( $q['image_id'] ?? '' ),
+ 'imageCropSpecification' => array(
+ 'size' => array( 'width' => array( 'value' => 300, 'units' => 'pixels' ),
+ 'height' => array( 'value' => 300, 'units' => 'pixels' ) ),
+ ),
+ 'altText' => (string) ( $q['alt'] ?? '' ),
+ ),
+ 'headline' => array( 'value' => (string) ( $q['headline'] ?? '' ) ),
+ 'body' => array(
+ 'textList' => array( array( 'text' => array( 'value' => (string) ( $q['body'] ?? '' ) ) ) ),
+ ),
+ );
+ }
+ return array(
+ 'contentModuleType' => 'STANDARD_FOUR_IMAGE_TEXT_QUADRANT',
+ 'standardFourImageTextQuadrantModule' => array(
+ 'block1' => $blocks[0] ?? null,
+ 'block2' => $blocks[1] ?? null,
+ 'block3' => $blocks[2] ?? null,
+ 'block4' => $blocks[3] ?? null,
+ ),
+ );
+ }
+
+ public static function build_comparison_table( array $products, array $metrics ): array {
+ $columns = array();
+ foreach ( array_slice( $products, 0, 6 ) as $p ) {
+ $metric_row = array();
+ foreach ( $metrics as $mkey ) {
+ $metric_row[] = array( 'value' => (string) ( $p['metrics'][ $mkey ] ?? '' ) );
+ }
+ $columns[] = array(
+ 'position' => count( $columns ) + 1,
+ 'image' => array(
+ 'uploadDestinationId' => (string) ( $p['image_id'] ?? '' ),
+ 'altText' => (string) ( $p['name'] ?? '' ),
+ ),
+ 'title' => (string) ( $p['name'] ?? '' ),
+ 'asin' => (string) ( $p['asin'] ?? '' ),
+ 'highlight' => (bool) ( $p['highlight'] ?? false ),
+ 'descriptionValues' => $metric_row,
+ );
+ }
+ return array(
+ 'contentModuleType' => 'STANDARD_COMPARISON_TABLE',
+ 'standardComparisonTableModule' => array(
+ 'productColumns' => $columns,
+ 'metricRowLabels' => array_map( static fn( $m ) => array( 'value' => (string) $m ), $metrics ),
+ ),
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/CatalogItems/CatalogLookup.php b/sg-commerce-pro/src/Amazon/CatalogItems/CatalogLookup.php
new file mode 100644
index 0000000..2d22710
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/CatalogItems/CatalogLookup.php
@@ -0,0 +1,173 @@
+
+ */
+ public function search_by_ean( string $ean, string $market ): array {
+ if ( ! self::validate_ean13( $ean ) ) {
+ throw new \InvalidArgumentException( "Invalid EAN-13 checksum: {$ean}" );
+ }
+
+ $cache_key = "catalog.ean.{$market}.{$ean}";
+ $cached = $this->cache->get( $cache_key );
+ if ( null !== $cached ) {
+ return $cached;
+ }
+
+ try {
+ $response = $this->amazon->request(
+ 'GET',
+ $market,
+ '/catalog/2022-04-01/items',
+ array(
+ 'identifiers' => $ean,
+ 'identifiersType' => 'EAN',
+ 'includedData' => 'attributes,dimensions,identifiers,images,productTypes,summaries',
+ ),
+ null,
+ 'catalog.items.search'
+ );
+ } catch ( \Throwable $e ) {
+ $this->logger->error( 'Catalog EAN lookup failed', array(
+ 'ean' => $ean, 'market' => $market, 'err' => $e->getMessage(),
+ ) );
+ throw $e;
+ }
+
+ $items = (array) ( $response['items'] ?? $response['payload']['items'] ?? array() );
+ $results = array();
+ foreach ( $items as $item ) {
+ $summary = (array) ( $item['summaries'][0] ?? array() );
+ $dims = (array) ( $item['dimensions'][0] ?? array() );
+ $results[] = array(
+ 'asin' => (string) ( $item['asin'] ?? '' ),
+ 'title' => (string) ( $summary['itemName'] ?? '' ),
+ 'brand' => (string) ( $summary['brandName'] ?? '' ),
+ 'marketplace' => $market,
+ 'item_dimensions' => (array) ( $dims['item'] ?? array() ),
+ 'package_dimensions' => (array) ( $dims['package'] ?? array() ),
+ 'raw' => $item,
+ );
+ }
+
+ $this->cache->set( $cache_key, $results, 12 * HOUR_IN_SECONDS );
+ return $results;
+ }
+
+ /**
+ * Preflight a prospective listing: is this EAN already on Amazon?
+ *
+ * Returns:
+ * 'match' → exact match found, list against that ASIN (best case)
+ * 'absent' → no hit, we need to create a new product via Listings API
+ * 'ambiguous' → multiple hits, operator must pick manually
+ */
+ public function preflight( string $ean, string $market ): array {
+ $results = $this->search_by_ean( $ean, $market );
+ if ( empty( $results ) ) {
+ return array( 'status' => 'absent', 'matches' => array() );
+ }
+ if ( 1 === count( $results ) ) {
+ return array( 'status' => 'match', 'matches' => $results );
+ }
+ return array( 'status' => 'ambiguous', 'matches' => $results );
+ }
+
+ /**
+ * Get catalog attributes for an ASIN — needed when composing a Listings
+ * patch (we must provide the product type + required attributes).
+ */
+ public function get_by_asin( string $asin, string $market ): ?array {
+ $cache_key = "catalog.asin.{$market}.{$asin}";
+ $cached = $this->cache->get( $cache_key );
+ if ( null !== $cached ) return $cached;
+
+ try {
+ $response = $this->amazon->request(
+ 'GET',
+ $market,
+ "/catalog/2022-04-01/items/{$asin}",
+ array(
+ 'marketplaceIds' => '', // Filled by AmazonClient based on $market param
+ 'includedData' => 'attributes,dimensions,identifiers,productTypes,relationships,summaries',
+ ),
+ null,
+ 'catalog.items.get'
+ );
+ } catch ( \Throwable $e ) {
+ $this->logger->warning( 'Catalog ASIN lookup failed', array(
+ 'asin' => $asin, 'err' => $e->getMessage(),
+ ) );
+ return null;
+ }
+
+ $this->cache->set( $cache_key, $response, 12 * HOUR_IN_SECONDS );
+ return $response;
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/CircuitBreaker.php b/sg-commerce-pro/src/Amazon/CircuitBreaker.php
new file mode 100644
index 0000000..781f1c7
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/CircuitBreaker.php
@@ -0,0 +1,89 @@
+state( $key );
+ if ( self::STATE_OPEN === $state['state'] ) {
+ $elapsed = time() - (int) $state['opened_at'];
+ if ( $elapsed >= self::COOLDOWN_SECONDS ) {
+ // Transition OPEN → HALF_OPEN.
+ $state['state'] = self::STATE_HALF_OPEN;
+ $this->save( $key, $state );
+ return;
+ }
+ $remaining = self::COOLDOWN_SECONDS - $elapsed;
+ throw new \RuntimeException( "Circuit breaker open for '{$key}' — retry in {$remaining}s." );
+ }
+ }
+
+ public function record_success( string $key ): void {
+ $state = $this->state( $key );
+ if ( self::STATE_CLOSED !== $state['state'] || $state['failures'] > 0 ) {
+ $this->save( $key, array( 'state' => self::STATE_CLOSED, 'failures' => 0, 'opened_at' => 0 ) );
+ if ( self::STATE_CLOSED !== $state['state'] ) {
+ $this->logger->info( "Circuit breaker '{$key}' recovered to CLOSED." );
+ }
+ }
+ }
+
+ public function record_failure( string $key ): void {
+ $state = $this->state( $key );
+ $state['failures'] = (int) $state['failures'] + 1;
+
+ if ( self::STATE_HALF_OPEN === $state['state'] || $state['failures'] >= self::FAILURE_THRESHOLD ) {
+ $this->logger->warning( "Circuit breaker '{$key}' tripped to OPEN after {$state['failures']} failures." );
+ $state['state'] = self::STATE_OPEN;
+ $state['opened_at'] = time();
+ }
+ $this->save( $key, $state );
+ }
+
+ private function state( string $key ): array {
+ $raw = $this->cache->get( 'circuit/' . $key, null );
+ if ( ! is_array( $raw ) ) {
+ return array( 'state' => self::STATE_CLOSED, 'failures' => 0, 'opened_at' => 0 );
+ }
+ return array(
+ 'state' => (string) ( $raw['state'] ?? self::STATE_CLOSED ),
+ 'failures' => (int) ( $raw['failures'] ?? 0 ),
+ 'opened_at' => (int) ( $raw['opened_at'] ?? 0 ),
+ );
+ }
+
+ private function save( string $key, array $state ): void {
+ $this->cache->set( 'circuit/' . $key, $state, HOUR_IN_SECONDS );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/FeedsGenerator/FeedsGenerator.php b/sg-commerce-pro/src/Amazon/FeedsGenerator/FeedsGenerator.php
new file mode 100644
index 0000000..2814982
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/FeedsGenerator/FeedsGenerator.php
@@ -0,0 +1,139 @@
+ $rows
+ */
+ public function pricing_feed( array $rows ): string {
+ $messages = '';
+ $message_id = 1;
+ foreach ( $rows as $r ) {
+ $sku = htmlspecialchars( (string) $r['sku'], ENT_XML1 | ENT_QUOTES, 'UTF-8' );
+ $price_amount = number_format( (float) $r['price'], 2, '.', '' );
+ $currency = htmlspecialchars( (string) $r['currency'], ENT_XML1 | ENT_QUOTES, 'UTF-8' );
+
+ $sale_block = '';
+ if ( isset( $r['sale_price'], $r['sale_start'], $r['sale_end'] ) && (float) $r['sale_price'] > 0 ) {
+ $sale_price = number_format( (float) $r['sale_price'], 2, '.', '' );
+ $sale_start = htmlspecialchars( (string) $r['sale_start'], ENT_XML1 | ENT_QUOTES, 'UTF-8' );
+ $sale_end = htmlspecialchars( (string) $r['sale_end'], ENT_XML1 | ENT_QUOTES, 'UTF-8' );
+ $sale_block = <<
+ {$sale_start}
+ {$sale_end}
+
+ {$sale_price}
+
+
+XML;
+ }
+
+ $messages .= <<
+ {$message_id}
+ Update
+
+ {$sku}
+ {$price_amount} {$sale_block}
+
+
+XML;
+ $message_id++;
+ }
+ return $this->envelope( 'Price', $messages );
+ }
+
+ /**
+ * Build a POST_INVENTORY_AVAILABILITY_DATA feed.
+ *
+ * @param array $rows
+ */
+ public function inventory_feed( array $rows ): string {
+ $messages = '';
+ $message_id = 1;
+ foreach ( $rows as $r ) {
+ $sku = htmlspecialchars( (string) $r['sku'], ENT_XML1 | ENT_QUOTES, 'UTF-8' );
+ $qty = max( 0, (int) $r['quantity'] );
+ $latency = isset( $r['fulfillment_latency'] ) ? max( 1, (int) $r['fulfillment_latency'] ) : 1;
+ $messages .= <<
+ {$message_id}
+ Update
+
+ {$sku}
+ {$qty}
+ {$latency}
+
+
+XML;
+ $message_id++;
+ }
+ return $this->envelope( 'Inventory', $messages );
+ }
+
+ /**
+ * Return Amazon feed type strings for a message type. Used by FeedsClient
+ * when calling /feeds/2021-06-30/feeds.
+ */
+ public static function feed_type( string $message_type ): string {
+ return match ( $message_type ) {
+ 'Price' => 'POST_PRODUCT_PRICING_DATA',
+ 'Inventory' => 'POST_INVENTORY_AVAILABILITY_DATA',
+ 'Product' => 'POST_PRODUCT_DATA',
+ default => '',
+ };
+ }
+
+ private function envelope( string $message_type, string $messages ): string {
+ $merchant = htmlspecialchars( $this->merchant_id, ENT_XML1 | ENT_QUOTES, 'UTF-8' );
+ return <<
+
+
+ {$message_type}
+{$messages}
+
+XML;
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentClient.php b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentClient.php
new file mode 100644
index 0000000..4f416d9
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentClient.php
@@ -0,0 +1,185 @@
+, 'quantity'=>, 'prep_owner'=>'SELLER'|'AMAZON']
+ * @param array $source Source address ['name','address1','city','state','postal_code','country_code']
+ * @param string $market Destination marketplace (US, DE, UK, ...)
+ * @param string $name Operator-visible plan name
+ */
+ public function create_plan( array $items, array $source, string $market, string $name ): array {
+ $body = array(
+ 'destinationMarketplaces' => array( $this->marketplace_id( $market ) ),
+ 'sourceAddress' => $this->address_block( $source ),
+ 'name' => $name,
+ 'items' => array_map( static function ( $i ) {
+ return array(
+ 'msku' => (string) ( $i['msku'] ?? '' ),
+ 'quantity' => (int) ( $i['quantity'] ?? 0 ),
+ 'prepOwner' => (string) ( $i['prep_owner'] ?? 'SELLER' ),
+ 'labelOwner' => (string) ( $i['label_owner'] ?? 'NONE' ), // NONE = use manufacturer barcode (EAN)
+ );
+ }, $items ),
+ );
+
+ $response = $this->amazon->request( 'POST', $market, self::BASE . '/inboundPlans',
+ array(), $body, 'inbound.create' );
+
+ $this->logger->info( 'Inbound plan created', array(
+ 'plan_id' => $response['inboundPlanId'] ?? null,
+ 'items' => count( $items ),
+ ) );
+ return $response;
+ }
+
+ public function get_plan( string $plan_id, string $market ): array {
+ return $this->amazon->request( 'GET', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ),
+ array(), null, 'inbound.get' );
+ }
+
+ /** Step 3 — ask Amazon how we can pack this plan. */
+ public function generate_packing_options( string $plan_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/packingOptions',
+ array(), null, 'inbound.packing.generate' );
+ }
+
+ public function list_packing_options( string $plan_id, string $market ): array {
+ return $this->amazon->request( 'GET', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/packingOptions',
+ array(), null, 'inbound.packing.list' );
+ }
+
+ public function confirm_packing_option( string $plan_id, string $packing_option_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) .
+ '/packingOptions/' . rawurlencode( $packing_option_id ) . '/confirmation',
+ array(), null, 'inbound.packing.confirm' );
+ }
+
+ /** Step 4 — update box content (dimensions, weight, items-per-box). */
+ public function set_packing_information( string $plan_id, array $packages, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/packingInformation',
+ array(), array( 'packageGroupings' => $packages ), 'inbound.packing.info' );
+ }
+
+ /** Step 5 — ask Amazon which warehouse(s) to ship to. */
+ public function generate_placement_options( string $plan_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/placementOptions',
+ array(), null, 'inbound.placement.generate' );
+ }
+
+ public function list_placement_options( string $plan_id, string $market ): array {
+ return $this->amazon->request( 'GET', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/placementOptions',
+ array(), null, 'inbound.placement.list' );
+ }
+
+ public function confirm_placement_option( string $plan_id, string $placement_option_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) .
+ '/placementOptions/' . rawurlencode( $placement_option_id ) . '/confirmation',
+ array(), null, 'inbound.placement.confirm' );
+ }
+
+ /** Step 7 — get transport options (carrier, cost, pickup date). */
+ public function generate_transport_options( string $plan_id, string $shipment_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) .
+ '/shipments/' . rawurlencode( $shipment_id ) . '/transportationOptions',
+ array(), null, 'inbound.transport.generate' );
+ }
+
+ public function confirm_transport_option( string $plan_id, string $shipment_id, string $transport_option_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) .
+ '/shipments/' . rawurlencode( $shipment_id ) .
+ '/transportationOptions/' . rawurlencode( $transport_option_id ) . '/confirmation',
+ array(), null, 'inbound.transport.confirm' );
+ }
+
+ /** Final — lock everything, generate labels. */
+ public function confirm_plan( string $plan_id, string $market ): array {
+ return $this->amazon->request( 'POST', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/confirmation',
+ array(), null, 'inbound.confirm' );
+ }
+
+ /** Retrieve confirmed box labels for printing. */
+ public function get_labels( string $plan_id, string $shipment_id, string $market, string $page_type = 'PackageLabel_Letter_6' ): array {
+ return $this->amazon->request( 'GET', $market,
+ self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) .
+ '/shipments/' . rawurlencode( $shipment_id ) . '/deliveryChallan',
+ array( 'pageType' => $page_type ), null, 'inbound.labels' );
+ }
+
+ private function marketplace_id( string $market ): string {
+ return (string) ( $this->amazon->marketplace_info( $market )['id'] ?? '' );
+ }
+
+ private function address_block( array $a ): array {
+ return array(
+ 'name' => (string) ( $a['name'] ?? '' ),
+ 'addressLine1' => (string) ( $a['address1'] ?? '' ),
+ 'addressLine2' => (string) ( $a['address2'] ?? '' ),
+ 'city' => (string) ( $a['city'] ?? '' ),
+ 'stateOrProvinceCode' => (string) ( $a['state'] ?? '' ),
+ 'postalCode' => (string) ( $a['postal_code'] ?? '' ),
+ 'countryCode' => (string) ( $a['country_code'] ?? '' ),
+ 'email' => (string) ( $a['email'] ?? '' ),
+ 'phoneNumber' => (string) ( $a['phone'] ?? '' ),
+ 'companyName' => (string) ( $a['company'] ?? '' ),
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentModule.php b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentModule.php
new file mode 100644
index 0000000..03574aa
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentModule.php
@@ -0,0 +1,90 @@
+container;
+
+ $c->singleton( CatalogLookup::class, static fn( Container $c ) =>
+ new CatalogLookup(
+ $c->get( AmazonClient::class ),
+ $c->get( CacheManager::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ $c->singleton( ListingsClient::class, static fn( Container $c ) =>
+ new ListingsClient(
+ $c->get( AmazonClient::class ),
+ $c->get( SettingsRepository::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ $c->singleton( AplusContentClient::class, static fn( Container $c ) =>
+ new AplusContentClient(
+ $c->get( AmazonClient::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ $c->singleton( InboundShipmentClient::class, static fn( Container $c ) =>
+ new InboundShipmentClient(
+ $c->get( AmazonClient::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ $c->singleton( BoxContentService::class, static fn( Container $c ) =>
+ new BoxContentService(
+ $c->get( InboundShipmentClient::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ $c->singleton( LabelGenerator::class, static fn( Container $c ) =>
+ new LabelGenerator( $c->get( Logger::class ) )
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/LWATokenManager.php b/sg-commerce-pro/src/Amazon/LWATokenManager.php
new file mode 100644
index 0000000..5ef5fcf
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/LWATokenManager.php
@@ -0,0 +1,105 @@
+settings->has( 'amazon_client_id' )
+ && $this->settings->has( 'amazon_client_secret' )
+ && $this->settings->has( 'amazon_refresh_token' );
+ }
+
+ /**
+ * Get a valid access token. Uses cache; refreshes from LWA if needed.
+ *
+ * @throws \RuntimeException
+ */
+ public function access_token( bool $force_refresh = false ): string {
+ if ( ! $force_refresh ) {
+ $cached = $this->cache->get( self::CACHE_KEY );
+ if ( is_string( $cached ) && '' !== $cached ) {
+ return $cached;
+ }
+ }
+ if ( ! $this->has_credentials() ) {
+ throw new \RuntimeException( 'Amazon credentials not configured.' );
+ }
+
+ $response = wp_remote_post(
+ self::ENDPOINT,
+ array(
+ 'timeout' => 20,
+ 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ),
+ 'body' => http_build_query(
+ array(
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => (string) $this->settings->get( 'amazon_refresh_token' ),
+ 'client_id' => (string) $this->settings->get( 'amazon_client_id' ),
+ 'client_secret' => (string) $this->settings->get( 'amazon_client_secret' ),
+ )
+ ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ $msg = $response->get_error_message();
+ $this->logger->error( 'LWA network error: ' . $msg );
+ throw new \RuntimeException( 'LWA network error: ' . $msg );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $body = (string) wp_remote_retrieve_body( $response );
+ $json = json_decode( $body, true );
+
+ if ( 200 !== $code || ! is_array( $json ) || empty( $json['access_token'] ) ) {
+ $err = is_array( $json ) ? (string) ( $json['error'] ?? '' ) : '';
+ $desc = is_array( $json ) ? (string) ( $json['error_description'] ?? '' ) : '';
+ if ( 'invalid_grant' === $err ) {
+ $this->logger->error( 'LWA refresh token invalid/expired', array( 'desc' => $desc ) );
+ throw new \RuntimeException( 'Your Amazon refresh token has expired or been revoked. Please reconnect your seller account in Amazon Connect.' );
+ }
+ $this->logger->error( "LWA HTTP {$code}: {$err} {$desc}" );
+ throw new \RuntimeException( "LWA returned HTTP {$code}: {$desc}" );
+ }
+
+ $ttl = max( 60, (int) ( $json['expires_in'] ?? 3600 ) - 300 );
+ $this->cache->set( self::CACHE_KEY, $json['access_token'], $ttl );
+
+ $this->logger->debug( 'LWA token refreshed', array( 'ttl' => $ttl ) );
+ return $json['access_token'];
+ }
+
+ public function clear_cache(): void {
+ $this->cache->delete( self::CACHE_KEY );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/Listings/ListingsClient.php b/sg-commerce-pro/src/Amazon/Listings/ListingsClient.php
new file mode 100644
index 0000000..e2a751d
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/Listings/ListingsClient.php
@@ -0,0 +1,208 @@
+settings->get( 'amazon_seller_id', '' );
+ if ( '' === $seller_id ) {
+ throw new \RuntimeException( 'amazon_seller_id must be set before creating listings.' );
+ }
+
+ // Ensure the EAN identifier is present in attributes.
+ $attributes = $this->inject_ean_identifier( $attributes, $ean, $market );
+
+ $body = array(
+ 'productType' => $product_type,
+ 'requirements' => 'LISTING',
+ 'attributes' => $attributes,
+ );
+
+ $path = sprintf(
+ '/listings/2021-08-01/items/%s/%s',
+ rawurlencode( $seller_id ),
+ rawurlencode( $sku )
+ );
+
+ $response = $this->amazon->request(
+ 'PUT',
+ $market,
+ $path,
+ array(), // marketplace ids are injected by AmazonClient
+ $body,
+ 'listings.put'
+ );
+
+ $this->logger->info( 'Listing created/replaced', array(
+ 'sku' => $sku, 'market' => $market, 'ean' => $ean,
+ 'submission_id' => $response['submissionId'] ?? null,
+ ) );
+ return $response;
+ }
+
+ /**
+ * Partial update — JSON Patch of individual attributes (e.g. just
+ * adjust price or quantity without re-sending full payload).
+ */
+ public function patch( string $sku, string $market, string $product_type, array $patches ): array {
+ $seller_id = (string) $this->settings->get( 'amazon_seller_id', '' );
+ if ( '' === $seller_id ) {
+ throw new \RuntimeException( 'amazon_seller_id must be set.' );
+ }
+
+ $body = array(
+ 'productType' => $product_type,
+ 'patches' => $patches,
+ );
+
+ $path = sprintf(
+ '/listings/2021-08-01/items/%s/%s',
+ rawurlencode( $seller_id ),
+ rawurlencode( $sku )
+ );
+
+ return $this->amazon->request( 'PATCH', $market, $path, array(), $body, 'listings.patch' );
+ }
+
+ public function delete( string $sku, string $market ): array {
+ $seller_id = (string) $this->settings->get( 'amazon_seller_id', '' );
+ $path = sprintf(
+ '/listings/2021-08-01/items/%s/%s',
+ rawurlencode( $seller_id ),
+ rawurlencode( $sku )
+ );
+ return $this->amazon->request( 'DELETE', $market, $path, array(), null, 'listings.delete' );
+ }
+
+ public function get( string $sku, string $market ): array {
+ $seller_id = (string) $this->settings->get( 'amazon_seller_id', '' );
+ $path = sprintf(
+ '/listings/2021-08-01/items/%s/%s',
+ rawurlencode( $seller_id ),
+ rawurlencode( $sku )
+ );
+ return $this->amazon->request( 'GET', $market, $path, array(
+ 'includedData' => 'summaries,attributes,issues,offers,fulfillmentAvailability',
+ ), null, 'listings.get' );
+ }
+
+ /**
+ * Ensure the `externally_assigned_product_identifier` with EAN type is
+ * present in the attributes array. This is the magic that tells Amazon
+ * we use the printed EAN-13 instead of FNSKU.
+ */
+ private function inject_ean_identifier( array $attributes, string $ean, string $market ): array {
+ $marketplace_id = $this->marketplace_id_for( $market );
+
+ $attributes['externally_assigned_product_identifier'] = array(
+ array(
+ 'type' => 'ean',
+ 'value' => $ean,
+ 'marketplace_id'=> $marketplace_id,
+ ),
+ );
+
+ // Also tell Amazon "use manufacturer barcode" (commingled inventory).
+ $attributes['merchant_suggested_asin'] = $attributes['merchant_suggested_asin'] ?? array();
+
+ return $attributes;
+ }
+
+ private function marketplace_id_for( string $market ): string {
+ $mp = $this->amazon->marketplace_info( $market );
+ return (string) ( $mp['id'] ?? '' );
+ }
+
+ /**
+ * Convenience helper — build a "minimum viable" attribute set for a new
+ * Seven Gum SKU. Caller can override any field before passing to put().
+ */
+ public static function build_attributes( array $input, string $market ): array {
+ $required_marketplace = array( 'marketplace_id' => self::marketplace_id_static( $market ) );
+
+ return array(
+ 'item_name' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['name'] ?? '' ) ) ) ),
+ 'brand' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['brand'] ?? 'Seven Gum' ) ) ) ),
+ 'manufacturer' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['manufacturer'] ?? 'Seven Gum' ) ) ) ),
+ 'product_description' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['description'] ?? '' ) ) ) ),
+ 'item_package_quantity' => array( array_merge( $required_marketplace, array( 'value' => (int) ( $input['pieces'] ?? 30 ) ) ) ),
+ 'number_of_items' => array( array_merge( $required_marketplace, array( 'value' => 1 ) ) ),
+ 'list_price' => array( array_merge( $required_marketplace, array(
+ 'value' => (float) ( $input['price'] ?? 0 ),
+ 'currency' => (string) ( $input['currency'] ?? 'USD' ),
+ ) ) ),
+ 'supplier_declared_has_product_identifier_exemption' => array( array_merge( $required_marketplace, array( 'value' => false ) ) ),
+ 'country_of_origin' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['origin'] ?? 'CN' ) ) ) ),
+ );
+ }
+
+ private static function marketplace_id_static( string $market ): string {
+ // Compact inline mapping — full list lives in Marketplaces::all().
+ $map = array(
+ 'US' => 'ATVPDKIKX0DER', 'CA' => 'A2EUQ1WTGCTBG2', 'MX' => 'A1AM78C64UM0Y8', 'BR' => 'A2Q3Y263D00KWC',
+ 'UK' => 'A1F83G8C2ARO7P', 'DE' => 'A1PA6795UKMFR9', 'FR' => 'A13V1IB3VIYZZH', 'IT' => 'APJ6JRA9NG5V4',
+ 'ES' => 'A1RKKUPIHCS9HS', 'NL' => 'A1805IZSGTT6HS', 'SE' => 'A2NODRKZP88ZB9', 'PL' => 'A1C3SOZRARQ6R3',
+ 'BE' => 'AMEN7PMS3EDWL', 'IE' => 'A28R8C7NBKEWEA', 'TR' => 'A33AVAJ2PDY3EV',
+ 'AE' => 'A2VIGQ35RCS4UG', 'SA' => 'A17E79C6D8DWNP',
+ 'AU' => 'A39IBJ37TRP1C6', 'JP' => 'A1VC38T7YXB528', 'SG' => 'A19VAU5U5O7RUS', 'IN' => 'A21TJRUUN4KGV',
+ );
+ return $map[ $market ] ?? 'ATVPDKIKX0DER';
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/Marketplaces.php b/sg-commerce-pro/src/Amazon/Marketplaces.php
new file mode 100644
index 0000000..def4d5d
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/Marketplaces.php
@@ -0,0 +1,109 @@
+|null */
+ private static ?array $cache = null;
+
+ /**
+ * @return array
+ */
+ public function all(): array {
+ if ( null !== self::$cache ) {
+ return self::$cache;
+ }
+
+ $markets = array(
+ // North America
+ 'US' => array( 'id' => 'ATVPDKIKX0DER', 'country' => 'United States', 'currency' => 'USD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'com' ),
+ 'CA' => array( 'id' => 'A2EUQ1WTGCTBG2', 'country' => 'Canada', 'currency' => 'CAD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'ca' ),
+ 'MX' => array( 'id' => 'A1AM78C64UM0Y8', 'country' => 'Mexico', 'currency' => 'MXN', 'language' => 'es', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'com.mx' ),
+ 'BR' => array( 'id' => 'A2Q3Y263D00KWC', 'country' => 'Brazil', 'currency' => 'BRL', 'language' => 'pt', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'com.br' ),
+ // Europe
+ 'UK' => array( 'id' => 'A1F83G8C2ARO7P', 'country' => 'United Kingdom', 'currency' => 'GBP', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'co.uk' ),
+ 'DE' => array( 'id' => 'A1PA6795UKMFR9', 'country' => 'Germany', 'currency' => 'EUR', 'language' => 'de', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'de' ),
+ 'FR' => array( 'id' => 'A13V1IB3VIYZZH', 'country' => 'France', 'currency' => 'EUR', 'language' => 'fr', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'fr' ),
+ 'IT' => array( 'id' => 'APJ6JRA9NG5V4', 'country' => 'Italy', 'currency' => 'EUR', 'language' => 'it', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'it' ),
+ 'ES' => array( 'id' => 'A1RKKUPIHCS9HS', 'country' => 'Spain', 'currency' => 'EUR', 'language' => 'es', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'es' ),
+ 'NL' => array( 'id' => 'A1805IZSGTT6HS', 'country' => 'Netherlands', 'currency' => 'EUR', 'language' => 'nl', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'nl' ),
+ 'SE' => array( 'id' => 'A2NODRKZP88ZB9', 'country' => 'Sweden', 'currency' => 'SEK', 'language' => 'sv', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'se' ),
+ 'PL' => array( 'id' => 'A1C3SOZRARQ6R3', 'country' => 'Poland', 'currency' => 'PLN', 'language' => 'pl', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'pl' ),
+ 'BE' => array( 'id' => 'AMEN7PMS3EDWL', 'country' => 'Belgium', 'currency' => 'EUR', 'language' => 'nl', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'com.be' ),
+ 'IE' => array( 'id' => 'A28R8C7NBKEWEA', 'country' => 'Ireland', 'currency' => 'EUR', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'ie' ),
+ 'TR' => array( 'id' => 'A33AVAJ2PDY3EV', 'country' => 'Turkey', 'currency' => 'TRY', 'language' => 'tr', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'com.tr' ),
+ // Middle East
+ 'AE' => array( 'id' => 'A2VIGQ35RCS4UG', 'country' => 'United Arab Emirates', 'currency' => 'AED', 'language' => 'ar', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'ae' ),
+ 'SA' => array( 'id' => 'A17E79C6D8DWNP', 'country' => 'Saudi Arabia', 'currency' => 'SAR', 'language' => 'ar', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'sa' ),
+ // Far East
+ 'AU' => array( 'id' => 'A39IBJ37TRP1C6', 'country' => 'Australia', 'currency' => 'AUD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'com.au' ),
+ 'IN' => array( 'id' => 'A21TJRUUN4KGV', 'country' => 'India', 'currency' => 'INR', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'in' ),
+ 'SG' => array( 'id' => 'A19VAU5U5O7RUS', 'country' => 'Singapore', 'currency' => 'SGD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'sg' ),
+ 'JP' => array( 'id' => 'A1VC38T7YXB528', 'country' => 'Japan', 'currency' => 'JPY', 'language' => 'ja', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'co.jp' ),
+ );
+
+ self::$cache = apply_filters( 'sg_commerce_marketplaces', $markets );
+ return self::$cache;
+ }
+
+ public function get( string $code ): ?array {
+ return $this->all()[ strtoupper( $code ) ] ?? null;
+ }
+
+ public function exists( string $code ): bool {
+ return null !== $this->get( $code );
+ }
+
+ /** @return string[] */
+ public function enabled_codes(): array {
+ $settings = get_option( 'sg_commerce_settings', array() );
+ $codes = (array) ( is_array( $settings ) ? ( $settings['amazon_enabled_markets'] ?? array( 'US' ) ) : array( 'US' ) );
+ $all = $this->all();
+ return array_values( array_filter( $codes, static fn( $c ) => isset( $all[ $c ] ) ) );
+ }
+
+ public function primary(): string {
+ $settings = get_option( 'sg_commerce_settings', array() );
+ $p = is_array( $settings ) ? (string) ( $settings['amazon_primary_market'] ?? 'US' ) : 'US';
+ return $this->exists( $p ) ? $p : 'US';
+ }
+
+ /** Build the public Amazon product URL for an ASIN in a market. */
+ public function product_url( string $asin, string $market ): string {
+ $mp = $this->get( $market );
+ $tld = $mp['tld'] ?? 'com';
+ return "https://www.amazon.{$tld}/dp/{$asin}";
+ }
+
+ /** Reverse lookup: marketplace_id (e.g. ATVPDKIKX0DER) → code (US). */
+ public function code_by_id( string $marketplace_id ): string {
+ if ( '' === $marketplace_id ) return '';
+ foreach ( $this->all() as $code => $m ) {
+ if ( ( $m['id'] ?? '' ) === $marketplace_id ) {
+ return (string) $code;
+ }
+ }
+ return '';
+ }
+
+ public static function flush_cache(): void {
+ self::$cache = null;
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/RateLimiter.php b/sg-commerce-pro/src/Amazon/RateLimiter.php
new file mode 100644
index 0000000..a5693ac
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/RateLimiter.php
@@ -0,0 +1,87 @@
+try_acquire('fbaInventory.summaries')) { ... }
+ *
+ * @package SevenGum\Commerce\Amazon
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Amazon;
+
+use SevenGum\Commerce\Cache\CacheManager;
+
+defined( 'ABSPATH' ) || exit;
+
+final class RateLimiter {
+
+ /** Per-endpoint config: [refill_per_sec, burst_capacity]. */
+ private const LIMITS = array(
+ 'fbaInventory.summaries' => array( 2.0, 2 ),
+ 'sellers.marketplaceParticipations' => array( 0.5, 1 ),
+ 'pricing.itemOffers' => array( 0.5, 1 ),
+ 'pricing.competitivePricing' => array( 0.5, 1 ),
+ 'reports.create' => array( 0.0167, 15 ), // ~1/min
+ 'reports.get' => array( 2.0, 15 ),
+ 'orders.list' => array( 0.0167, 20 ),
+ 'listings.patch' => array( 5.0, 5 ),
+ // v4.0 — Seller Suite (rates from the SP-API usage-plan docs).
+ 'reports.document' => array( 0.0167, 15 ),
+ 'orders.items' => array( 0.5, 30 ),
+ 'finances.events' => array( 0.5, 30 ),
+ 'listings.get' => array( 5.0, 10 ),
+ 'catalog.search' => array( 2.0, 2 ),
+ 'fees.estimate' => array( 1.0, 2 ),
+ 'solicitations' => array( 1.0, 5 ),
+ );
+
+ public function __construct( private CacheManager $cache ) {}
+
+ /**
+ * Try to acquire one token. Returns true on success, false if depleted.
+ */
+ public function try_acquire( string $endpoint ): bool {
+ [ $refill, $capacity ] = self::LIMITS[ $endpoint ] ?? array( 1.0, 5 );
+ $now = microtime( true );
+
+ $key = 'ratelimit/' . $endpoint;
+ $bucket = $this->cache->get( $key, array( 'tokens' => $capacity, 'last' => $now ) );
+
+ // Refill since last check.
+ $elapsed = max( 0.0, $now - (float) $bucket['last'] );
+ $tokens = min( $capacity, (float) $bucket['tokens'] + $elapsed * $refill );
+
+ if ( $tokens >= 1.0 ) {
+ $tokens -= 1.0;
+ $this->cache->set( $key, array( 'tokens' => $tokens, 'last' => $now ), 300 );
+ return true;
+ }
+ // Persist the (still-low) state.
+ $this->cache->set( $key, array( 'tokens' => $tokens, 'last' => $now ), 300 );
+ return false;
+ }
+
+ /**
+ * Block until a token is available, with a max wait. Returns true if
+ * acquired, false if max wait exceeded.
+ */
+ public function acquire_blocking( string $endpoint, float $max_wait_sec = 5.0 ): bool {
+ $deadline = microtime( true ) + $max_wait_sec;
+ while ( microtime( true ) < $deadline ) {
+ if ( $this->try_acquire( $endpoint ) ) {
+ return true;
+ }
+ usleep( 200_000 ); // 200ms
+ }
+ return false;
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/ReportsParser/SettlementsParser.php b/sg-commerce-pro/src/Amazon/ReportsParser/SettlementsParser.php
new file mode 100644
index 0000000..76907c3
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/ReportsParser/SettlementsParser.php
@@ -0,0 +1,125 @@
+request_settlement($start, $end)
+ * → creates a report, polls until done, downloads TSV
+ * 2. SettlementsParser->parse($file_content) → array of events
+ * 3. Events written to wp_sg_settlements table
+ * 4. Analytics dashboard uses aggregated fees for accurate margin
+ *
+ * For v3.2: we only implement the parser — the Reports API client is
+ * deferred because it requires AWS Signature V4 signing for some endpoints.
+ * Operators can manually upload a settlement TSV via admin for now.
+ *
+ * @package SevenGum\Commerce\Amazon\ReportsParser
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Amazon\ReportsParser;
+
+defined( 'ABSPATH' ) || exit;
+
+final class SettlementsParser {
+
+ /**
+ * Parse a settlement TSV file's raw content.
+ *
+ * Returns an array of normalized financial events:
+ * [
+ * 'date', 'type', 'sku', 'order_id',
+ * 'amount', 'currency', 'description'
+ * ]
+ *
+ * @param string $tsv_content Contents of the GET_V2_SETTLEMENT_REPORT file.
+ * @return array
+ */
+ public function parse( string $tsv_content ): array {
+ $lines = preg_split( '/\r?\n/', trim( $tsv_content ) );
+ if ( count( $lines ) < 2 ) {
+ return array();
+ }
+ $headers = array_map( 'trim', explode( "\t", (string) array_shift( $lines ) ) );
+ // Normalize: lowercase, no spaces, no dashes.
+ $headers = array_map( static fn( $h ) => strtolower( preg_replace( '/[^a-z0-9_]/i', '_', (string) $h ) ), $headers );
+
+ $events = array();
+ foreach ( $lines as $line ) {
+ if ( '' === trim( $line ) ) continue;
+ $cols = explode( "\t", $line );
+ if ( count( $cols ) !== count( $headers ) ) continue;
+ $row = array_combine( $headers, array_map( 'trim', $cols ) );
+ $normalized = $this->normalize_row( $row );
+ if ( null !== $normalized ) {
+ $events[] = $normalized;
+ }
+ }
+ return $events;
+ }
+
+ private function normalize_row( array $row ): ?array {
+ $type = strtolower( (string) ( $row['transaction_type'] ?? $row['type'] ?? '' ) );
+ $amount_str = (string) ( $row['amount'] ?? $row['amount_'] ?? '0' );
+ $amount = (float) str_replace( array( ',', ' ' ), '', $amount_str );
+ if ( '' === $type ) return null;
+
+ return array(
+ 'date' => (string) ( $row['posted_date'] ?? $row['posted_date_time'] ?? '' ),
+ 'type' => $this->canonical_type( $type ),
+ 'sku' => (string) ( $row['sku'] ?? '' ),
+ 'order_id' => (string) ( $row['order_id'] ?? $row['adjustment_id'] ?? '' ),
+ 'amount' => round( $amount, 2 ),
+ 'currency' => (string) ( $row['currency'] ?? '' ),
+ 'description' => (string) ( $row['transaction_type'] ?? $row['description'] ?? $type ),
+ 'marketplace' => (string) ( $row['marketplace_name'] ?? '' ),
+ );
+ }
+
+ private function canonical_type( string $type ): string {
+ $type = strtolower( trim( $type ) );
+ if ( str_contains( $type, 'order' ) && ! str_contains( $type, 'refund' ) ) return 'sale';
+ if ( str_contains( $type, 'refund' ) ) return 'refund';
+ if ( str_contains( $type, 'storage' ) ) return 'fba_storage';
+ if ( str_contains( $type, 'fulfillment' ) || str_contains( $type, 'fba' ) ) return 'fba_fee';
+ if ( str_contains( $type, 'referral' ) ) return 'referral_fee';
+ if ( str_contains( $type, 'advertising' ) ) return 'advertising';
+ if ( str_contains( $type, 'adjustment' ) ) return 'adjustment';
+ if ( str_contains( $type, 'reserve' ) ) return 'reserve';
+ if ( str_contains( $type, 'service' ) ) return 'service_fee';
+ return 'other';
+ }
+
+ /**
+ * Aggregate parsed events into totals per type.
+ *
+ * @param array $events
+ */
+ public function summary( array $events ): array {
+ $totals = array();
+ $by_sku = array();
+ foreach ( $events as $e ) {
+ $type = (string) $e['type'];
+ $totals[ $type ] = ( $totals[ $type ] ?? 0.0 ) + (float) $e['amount'];
+ if ( '' !== (string) $e['sku'] ) {
+ $by_sku[ $e['sku'] ][ $type ] = ( $by_sku[ $e['sku'] ][ $type ] ?? 0.0 ) + (float) $e['amount'];
+ }
+ }
+ ksort( $totals );
+ $net = array_sum( $totals );
+ return array(
+ 'event_count' => count( $events ),
+ 'totals' => $totals,
+ 'net' => round( $net, 2 ),
+ 'by_sku' => $by_sku,
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/RetryableException.php b/sg-commerce-pro/src/Amazon/RetryableException.php
new file mode 100644
index 0000000..5d16eeb
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/RetryableException.php
@@ -0,0 +1,14 @@
+container->get( SettingsRepository::class );
+ if ( $settings->get( 'sqs_enabled', false ) ) {
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_event( time() + 60, 'sg_five_minutes', self::CRON_HOOK );
+ }
+ }
+ }
+
+ /**
+ * Poll SQS. Runs until queue is empty OR 25 messages processed (whichever
+ * comes first) to stay under the default PHP max_execution_time.
+ */
+ public function poll(): int {
+ $settings = $this->container->get( SettingsRepository::class );
+ $logger = $this->container->get( Logger::class );
+ $enc = $this->container->get( Encryption::class );
+
+ $queue_url = (string) $settings->get( 'sqs_queue_url', '' );
+ $region = (string) $settings->get( 'sqs_region', 'us-east-1' );
+ $access_key = (string) $settings->get( 'sqs_access_key', '' );
+
+ if ( '' === $queue_url || '' === $access_key ) {
+ return 0;
+ }
+ $secret_key_enc = (string) $settings->get( 'sqs_secret_key_enc', '' );
+ try {
+ $secret_key = '' !== $secret_key_enc ? $enc->decrypt( $secret_key_enc ) : '';
+ } catch ( \Throwable $e ) {
+ $logger->error( 'SQS: secret key decrypt failed' );
+ return 0;
+ }
+ if ( '' === $secret_key ) return 0;
+
+ $signer = new SignatureV4( $access_key, $secret_key, $region );
+ $events = $this->container->get( EventDispatcher::class );
+
+ $processed = 0;
+ $loop_cap = 3; // Up to 3 receive batches × 10 messages = 30 per cron tick.
+
+ for ( $i = 0; $i < $loop_cap; $i++ ) {
+ $messages = $this->receive_messages( $signer, $queue_url );
+ if ( empty( $messages ) ) break;
+
+ foreach ( $messages as $msg ) {
+ try {
+ $body = json_decode( (string) $msg['body'], true );
+ if ( ! is_array( $body ) ) {
+ $logger->warning( 'SQS: unparseable message body' );
+ } else {
+ $this->handle_notification( $body, $events );
+ }
+ $this->delete_message( $signer, $queue_url, (string) $msg['receipt_handle'] );
+ $processed++;
+ } catch ( \Throwable $e ) {
+ $logger->error( 'SQS message processing failed', array( 'err' => $e->getMessage() ) );
+ // Leave the message; SQS visibility timeout will re-deliver it.
+ }
+ }
+ if ( count( $messages ) < 10 ) break; // Queue drained.
+ }
+ if ( $processed > 0 ) {
+ $logger->info( 'SQS poll complete', array( 'processed' => $processed ) );
+ }
+ return $processed;
+ }
+
+ /**
+ * Dispatch a single notification envelope to appropriate domain events
+ * and side effects.
+ */
+ private function handle_notification( array $body, EventDispatcher $events ): void {
+ $type = strtoupper( (string) ( $body['NotificationType'] ?? $body['notificationType'] ?? '' ) );
+ $payload = (array) ( $body['Payload'] ?? $body['payload'] ?? array() );
+
+ // Emit as a generic event first.
+ $events->dispatch( 'sqs.' . strtolower( $type ), $payload );
+
+ switch ( $type ) {
+ case 'ANY_OFFER_CHANGED':
+ $this->handle_offer_change( $payload );
+ break;
+ case 'ORDER_CHANGE':
+ $events->dispatch( 'order.changed', $payload );
+ break;
+ case 'FBA_INVENTORY_AVAILABILITY_CHANGES':
+ $this->handle_inventory_change( $payload );
+ break;
+ case 'FEED_PROCESSING_FINISHED':
+ $events->dispatch( 'feed.finished', $payload );
+ break;
+ case 'REPORT_PROCESSING_FINISHED':
+ $events->dispatch( 'report.finished', $payload );
+ break;
+ }
+ }
+
+ /**
+ * Incremental Buy Box update — patches one SKU without re-syncing all.
+ */
+ private function handle_offer_change( array $payload ): void {
+ $offer = $payload['AnyOfferChangedNotification']['OfferChangeTrigger'] ?? null;
+ if ( ! is_array( $offer ) ) return;
+ $asin = (string) ( $offer['ASIN'] ?? '' );
+ $marketplace_id = (string) ( $offer['MarketplaceId'] ?? '' );
+ if ( '' === $asin || '' === $marketplace_id ) return;
+
+ // Map marketplace ID → market code.
+ $mp = $this->container->get( \SevenGum\Commerce\Amazon\Marketplaces::class );
+ $market = $mp->code_by_id( $marketplace_id );
+ if ( '' === $market ) return;
+
+ // Extract new Buy Box from the summary.
+ $summary = (array) ( $payload['AnyOfferChangedNotification']['Summary'] ?? array() );
+ $buybox = (array) ( $summary['BuyBoxPrices'][0] ?? array() );
+ if ( empty( $buybox ) ) return;
+
+ $price = (float) ( $buybox['ListingPrice']['Amount'] ?? 0 );
+ $currency = (string) ( $buybox['ListingPrice']['CurrencyCode'] ?? '' );
+
+ // Patch DB by ASIN+market.
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}sg_products
+ SET buybox_price = %f, buybox_currency = %s, updated_at = UTC_TIMESTAMP()
+ WHERE asin = %s AND market = %s",
+ $price, $currency, $asin, $market
+ ) );
+
+ $this->container->get( Logger::class )->info( 'BuyBox patched from SQS', array(
+ 'asin' => $asin, 'market' => $market, 'price' => $price,
+ ) );
+ }
+
+ private function handle_inventory_change( array $payload ): void {
+ $sku = (string) ( $payload['FBAInventoryAvailabilityNotification']['SellerSku'] ?? '' );
+ if ( '' === $sku ) return;
+ $qty = (int) ( $payload['FBAInventoryAvailabilityNotification']['FulfillableQuantity'] ?? 0 );
+ $marketplace_id = (string) ( $payload['FBAInventoryAvailabilityNotification']['MarketplaceId'] ?? '' );
+ $mp = $this->container->get( \SevenGum\Commerce\Amazon\Marketplaces::class );
+ $market = $mp->code_by_id( $marketplace_id );
+ if ( '' === $market ) return;
+
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}sg_products
+ SET fulfillable_qty = %d, updated_at = UTC_TIMESTAMP()
+ WHERE sku = %s AND market = %s",
+ $qty, $sku, $market
+ ) );
+ }
+
+ private function receive_messages( SignatureV4 $signer, string $queue_url ): array {
+ $signed = $signer->sign_get( $queue_url, array(
+ 'Action' => 'ReceiveMessage',
+ 'Version' => '2012-11-05',
+ 'MaxNumberOfMessages' => 10,
+ 'WaitTimeSeconds' => 5, // Long-poll briefly to avoid empty responses.
+ ) );
+
+ $response = wp_remote_get( $signed['url'], array(
+ 'headers' => $signed['headers'],
+ 'timeout' => 15,
+ ) );
+ if ( is_wp_error( $response ) ) {
+ throw new \RuntimeException( 'SQS receive failed: ' . $response->get_error_message() );
+ }
+ $code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== (int) $code ) {
+ throw new \RuntimeException( 'SQS receive HTTP ' . $code );
+ }
+
+ $xml = simplexml_load_string( (string) wp_remote_retrieve_body( $response ) );
+ if ( false === $xml ) return array();
+
+ $out = array();
+ foreach ( $xml->ReceiveMessageResult->Message ?? array() as $m ) {
+ $out[] = array(
+ 'message_id' => (string) $m->MessageId,
+ 'receipt_handle' => (string) $m->ReceiptHandle,
+ 'body' => (string) $m->Body,
+ );
+ }
+ return $out;
+ }
+
+ private function delete_message( SignatureV4 $signer, string $queue_url, string $receipt_handle ): void {
+ $signed = $signer->sign_get( $queue_url, array(
+ 'Action' => 'DeleteMessage',
+ 'Version' => '2012-11-05',
+ 'ReceiptHandle' => $receipt_handle,
+ ) );
+ wp_remote_get( $signed['url'], array(
+ 'headers' => $signed['headers'],
+ 'timeout' => 10,
+ ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/SQSConsumer/SignatureV4.php b/sg-commerce-pro/src/Amazon/SQSConsumer/SignatureV4.php
new file mode 100644
index 0000000..aaecdae
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/SQSConsumer/SignatureV4.php
@@ -0,0 +1,90 @@
+region}/{$this->service}/aws4_request";
+ $string_to_sign = implode( "\n", array(
+ $algorithm,
+ $amz_date,
+ $credential_scope,
+ hash( 'sha256', $canonical_request ),
+ ) );
+
+ $k_date = hash_hmac( 'sha256', $date_stamp, 'AWS4' . $this->secret_key, true );
+ $k_region = hash_hmac( 'sha256', $this->region, $k_date, true );
+ $k_service = hash_hmac( 'sha256', $this->service, $k_region, true );
+ $k_signing = hash_hmac( 'sha256', 'aws4_request', $k_service, true );
+ $signature = hash_hmac( 'sha256', $string_to_sign, $k_signing );
+
+ $authorization = "{$algorithm} Credential={$this->access_key}/{$credential_scope}, "
+ . "SignedHeaders={$signed_headers}, Signature={$signature}";
+
+ return array(
+ 'url' => "{$queue_url}?{$canonical_query}",
+ 'headers' => array(
+ 'Host' => $host,
+ 'X-Amz-Date' => $amz_date,
+ 'Authorization' => $authorization,
+ ),
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Amazon/SyncOrchestrator.php b/sg-commerce-pro/src/Amazon/SyncOrchestrator.php
new file mode 100644
index 0000000..bfbb437
--- /dev/null
+++ b/sg-commerce-pro/src/Amazon/SyncOrchestrator.php
@@ -0,0 +1,269 @@
+marketplaces->get( $market );
+ if ( null === $mp ) {
+ throw new \RuntimeException( "Unknown marketplace: {$market}" );
+ }
+
+ $lock_key = 'sg_sync_lock_' . $market;
+ if ( get_transient( $lock_key ) ) {
+ throw new \RuntimeException( "Sync already in progress for {$market}." );
+ }
+ set_transient( $lock_key, time(), 10 * MINUTE_IN_SECONDS );
+
+ try {
+ $summary = $this->do_sync_market( $mp, $market, $max_pages );
+ $this->events->emit( 'sync.market.completed', array( 'market' => $market, 'summary' => $summary ) );
+ return $summary;
+ } finally {
+ delete_transient( $lock_key );
+ }
+ }
+
+ private function do_sync_market( array $mp, string $market, int $max_pages ): array {
+ $next_token = null;
+ $total = 0;
+ $page = 0;
+ $asins = array();
+
+ do {
+ $query = array(
+ 'granularityType' => 'Marketplace',
+ 'granularityId' => $mp['id'],
+ 'marketplaceIds' => $mp['id'],
+ 'details' => 'true',
+ );
+ if ( null !== $next_token ) {
+ $query['nextToken'] = $next_token;
+ }
+
+ $json = $this->client->request(
+ 'GET',
+ $market,
+ '/fba/inventory/v1/summaries',
+ $query,
+ null,
+ 'fbaInventory.summaries'
+ );
+
+ $summaries = $json['payload']['inventorySummaries'] ?? array();
+ if ( ! is_array( $summaries ) ) {
+ break;
+ }
+
+ foreach ( $summaries as $item ) {
+ $sku = (string) ( $item['sellerSku'] ?? '' );
+ $asin = (string) ( $item['asin'] ?? '' );
+ $name = (string) ( $item['productName'] ?? '' );
+ $details = $item['inventoryDetails'] ?? array();
+ $qty = (int) ( $details['fulfillableQuantity'] ?? 0 );
+ $rsv = (int) ( $details['reservedQuantity']['totalReservedQuantity'] ?? 0 );
+ $inb = (int) ( $details['inboundWorkingQuantity'] ?? 0 )
+ + (int) ( $details['inboundShippedQuantity'] ?? 0 )
+ + (int) ( $details['inboundReceivingQuantity'] ?? 0 );
+
+ if ( '' === $sku ) {
+ continue;
+ }
+
+ try {
+ $id = $this->products->upsert_from_sync( array(
+ 'market' => $market,
+ 'sku' => $sku,
+ 'asin' => $asin,
+ 'product_name' => $name,
+ 'fulfillable_qty' => $qty,
+ 'reserved_qty' => $rsv,
+ 'inbound_qty' => $inb,
+ ) );
+ if ( $id > 0 ) {
+ ++$total;
+ if ( '' !== $asin ) {
+ $asins[ $id ] = $asin;
+ }
+ $this->events->emit( 'product.synced', array( 'product_id' => $id, 'market' => $market ) );
+ }
+ } catch ( \Throwable $e ) {
+ $this->logger->error( 'product upsert failed', array( 'sku' => $sku, 'err' => $e->getMessage() ) );
+ }
+ }
+
+ $next_token = (string) ( $json['pagination']['nextToken'] ?? '' );
+ ++$page;
+ } while ( '' !== $next_token && $page < $max_pages );
+
+ // Pricing pass — fetch competitive pricing for ASINs we just synced.
+ if ( ! empty( $asins ) ) {
+ $this->sync_pricing_for( $market, $mp['id'], $asins );
+ }
+
+ $this->logger->info( 'sync market complete', array( 'market' => $market, 'rows' => $total, 'pages' => $page ) );
+ return array( 'rows' => $total, 'pages' => $page );
+ }
+
+ /**
+ * Fetch competitive pricing for up to 20 ASINs at a time.
+ *
+ * @param array $product_id_to_asin
+ */
+ private function sync_pricing_for( string $market, string $market_id, array $product_id_to_asin ): void {
+ $chunks = array_chunk( $product_id_to_asin, 20, true );
+
+ foreach ( $chunks as $chunk ) {
+ try {
+ $json = $this->client->request(
+ 'GET',
+ $market,
+ '/products/pricing/v0/competitivePrice',
+ array(
+ 'MarketplaceId' => $market_id,
+ 'ItemType' => 'Asin',
+ 'Asins' => implode( ',', array_values( $chunk ) ),
+ ),
+ null,
+ 'pricing.competitivePricing'
+ );
+ } catch ( \Throwable $e ) {
+ $this->logger->warning( 'pricing fetch failed', array( 'market' => $market, 'err' => $e->getMessage() ) );
+ continue;
+ }
+
+ $payload = $json['payload'] ?? array();
+ if ( ! is_array( $payload ) ) {
+ continue;
+ }
+ $asin_to_id = array_flip( $chunk );
+
+ foreach ( $payload as $entry ) {
+ $asin = (string) ( $entry['ASIN'] ?? '' );
+ if ( '' === $asin || ! isset( $asin_to_id[ $asin ] ) ) {
+ continue;
+ }
+ $id = (int) $asin_to_id[ $asin ];
+
+ $competitive = $entry['Product']['CompetitivePricing']['CompetitivePrices'] ?? array();
+ $best = $this->extract_buybox( $competitive );
+
+ $row = $this->products->find( $id );
+ if ( ! $row ) continue;
+
+ try {
+ $this->products->upsert_from_sync( array(
+ 'market' => $row['market'],
+ 'sku' => $row['sku'],
+ 'asin' => $row['asin'],
+ 'product_name' => $row['product_name'],
+ 'fulfillable_qty' => (int) $row['fulfillable_qty'],
+ 'reserved_qty' => (int) $row['reserved_qty'],
+ 'inbound_qty' => (int) $row['inbound_qty'],
+ 'buybox_price' => $best['price'],
+ 'buybox_currency' => $best['currency'],
+ 'buybox_is_mine' => 0, // determined by /listings — out of scope for v3.0
+ 'lowest_price' => $best['price'],
+ ) );
+
+ $this->price_history->record( $id, array(
+ 'my_price' => isset( $row['my_price'] ) ? (float) $row['my_price'] : null,
+ 'buybox_price' => $best['price'],
+ 'lowest_price' => $best['price'],
+ 'currency' => $best['currency'],
+ 'buybox_is_mine' => 0,
+ 'fulfillable_qty' => (int) $row['fulfillable_qty'],
+ ) );
+ } catch ( \Throwable $e ) {
+ $this->logger->error( 'pricing upsert failed', array( 'id' => $id, 'err' => $e->getMessage() ) );
+ }
+ }
+ }
+ }
+
+ /** Extract the New-condition Buy Box price from a competitive-prices payload. */
+ private function extract_buybox( array $prices ): array {
+ foreach ( $prices as $cp ) {
+ if ( ( $cp['CompetitivePriceId'] ?? '' ) === '1' && ( $cp['condition'] ?? 'New' ) === 'New' ) {
+ return array(
+ 'price' => isset( $cp['Price']['LandedPrice']['Amount'] )
+ ? (float) $cp['Price']['LandedPrice']['Amount']
+ : null,
+ 'currency' => (string) ( $cp['Price']['LandedPrice']['CurrencyCode'] ?? '' ),
+ );
+ }
+ }
+ return array( 'price' => null, 'currency' => '' );
+ }
+
+ public function sync_all( int $max_pages = 5 ): array {
+ $results = array( 'total' => 0, 'by_market' => array(), 'errors' => array() );
+ foreach ( $this->marketplaces->enabled_codes() as $market ) {
+ try {
+ $summary = $this->sync_market( $market, $max_pages );
+ $results['by_market'][ $market ] = $summary['rows'];
+ $results['total'] += $summary['rows'];
+ } catch ( \Throwable $e ) {
+ $results['errors'][ $market ] = $e->getMessage();
+ $this->logger->error( 'market sync failed', array( 'market' => $market, 'err' => $e->getMessage() ) );
+ }
+ }
+ $this->events->emit( 'sync.completed', $results );
+ return $results;
+ }
+
+ /** Cron entrypoint. */
+ public function cron_sync(): void {
+ if ( ! $this->client->has_credentials() ) {
+ return;
+ }
+ try {
+ $result = $this->sync_all();
+ update_option( 'sg_commerce_last_cron_sync', array(
+ 'at' => time(),
+ 'total' => $result['total'],
+ 'by_market' => $result['by_market'],
+ 'errors' => $result['errors'],
+ ), false );
+ } catch ( \Throwable $e ) {
+ $this->logger->error( 'cron sync failed: ' . $e->getMessage() );
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Analytics/AnalyticsModule.php b/sg-commerce-pro/src/Analytics/AnalyticsModule.php
new file mode 100644
index 0000000..cd22e4b
--- /dev/null
+++ b/sg-commerce-pro/src/Analytics/AnalyticsModule.php
@@ -0,0 +1,36 @@
+container->get( SettingsRepository::class )->get( 'log_retention_days', 90 );
+ if ( $retain > 0 ) {
+ $this->container->get( PriceHistoryRepository::class )->prune_older_than( $retain );
+ }
+ } );
+ }
+}
diff --git a/sg-commerce-pro/src/Analytics/ProfitCalculator.php b/sg-commerce-pro/src/Analytics/ProfitCalculator.php
new file mode 100644
index 0000000..772600c
--- /dev/null
+++ b/sg-commerce-pro/src/Analytics/ProfitCalculator.php
@@ -0,0 +1,66 @@
+settings->get( 'referral_fee_pct', 15.0 );
+ $fba_fee = (float) $this->settings->get( 'fba_pick_pack_fee', 3.00 );
+
+ $fee = $sale_price * ( $referral_pct / 100.0 ) + $fba_fee;
+
+ $gross = ( null !== $cost ) ? ( $sale_price - $cost ) : 0.0;
+ $net = ( null !== $cost ) ? ( $sale_price - $cost - $fee ) : -1.0 * $fee;
+ $margin = ( null !== $cost && $sale_price > 0 ) ? ( $net / $sale_price * 100.0 ) : 0.0;
+
+ return array(
+ 'gross_profit' => round( $gross, 2 ),
+ 'net_profit' => round( $net, 2 ),
+ 'margin_pct' => round( $margin, 2 ),
+ 'fee_estimate' => round( $fee, 2 ),
+ );
+ }
+
+ /**
+ * Minimum sale price that satisfies the configured min margin %.
+ * Useful for the repricer floor.
+ */
+ public function min_price_for( float $cost ): float {
+ $min_margin = (float) $this->settings->get( 'repricer_min_margin_pct', 15.0 );
+ $referral_pct = (float) $this->settings->get( 'referral_fee_pct', 15.0 );
+ $fba_fee = (float) $this->settings->get( 'fba_pick_pack_fee', 3.00 );
+
+ // price - cost - (price * referral) - fba >= price * (margin/100)
+ // price * (1 - referral/100 - margin/100) >= cost + fba
+ $denom = 1.0 - ( $referral_pct / 100.0 ) - ( $min_margin / 100.0 );
+ if ( $denom <= 0 ) {
+ return 0.0; // misconfigured — refuse to compute
+ }
+ return round( ( $cost + $fba_fee ) / $denom, 2 );
+ }
+}
diff --git a/sg-commerce-pro/src/Analytics/SalesAggregator.php b/sg-commerce-pro/src/Analytics/SalesAggregator.php
new file mode 100644
index 0000000..8a648bd
--- /dev/null
+++ b/sg-commerce-pro/src/Analytics/SalesAggregator.php
@@ -0,0 +1,80 @@
+products->count_total();
+ $in_stock = $this->products->count_in_stock();
+ $winning = $this->products->count_winning_buybox();
+ $markets = count( $this->products->counts_by_market() );
+
+ return array(
+ 'products_total' => $total,
+ 'products_in_stock' => $in_stock,
+ 'products_winning_buybox' => $winning,
+ 'buybox_win_rate' => $total > 0 ? round( $winning / $total * 100, 1 ) : 0.0,
+ 'stockout_rate' => $total > 0 ? round( ( $total - $in_stock ) / $total * 100, 1 ) : 0.0,
+ 'markets' => $markets,
+ );
+ }
+
+ /**
+ * Daily Buy Box win/loss counts for the last 14 days.
+ * Returns rows keyed by date with 'won' and 'lost' integers.
+ */
+ public function buybox_trend( int $days = 14 ): array {
+ return $this->price_history->buybox_loss_by_day( $days );
+ }
+
+ /** Top-N products at risk: low stock or losing buybox. */
+ public function at_risk( int $limit = 10 ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . 'sg_products';
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT id, market, sku, product_name, fulfillable_qty, buybox_price, buybox_is_mine
+ FROM {$table}
+ WHERE fulfillable_qty <= 10 OR (buybox_is_mine = 0 AND buybox_price IS NOT NULL)
+ ORDER BY fulfillable_qty ASC, buybox_is_mine ASC
+ LIMIT %d",
+ $limit
+ ), ARRAY_A );
+ return is_array( $rows ) ? $rows : array();
+ }
+}
diff --git a/sg-commerce-pro/src/Automation/AlertDispatcher.php b/sg-commerce-pro/src/Automation/AlertDispatcher.php
new file mode 100644
index 0000000..e0cb2f4
--- /dev/null
+++ b/sg-commerce-pro/src/Automation/AlertDispatcher.php
@@ -0,0 +1,76 @@
+insert(
+ $wpdb->prefix . 'sg_alerts',
+ array(
+ 'level' => $level,
+ 'category' => $category,
+ 'title' => $title,
+ 'message' => $message,
+ 'context' => wp_json_encode( $context ),
+ 'product_id' => $product_id,
+ 'is_read' => 0,
+ 'created_at' => current_time( 'mysql', true ),
+ ),
+ array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s' )
+ );
+ return false !== $result;
+ }
+
+ public static function unread_count(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_alerts WHERE is_read = 0" );
+ }
+
+ public static function recent( int $limit = 50 ): array {
+ global $wpdb;
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}sg_alerts ORDER BY created_at DESC LIMIT %d",
+ $limit
+ ), ARRAY_A );
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ public static function mark_read( int $id ): bool {
+ global $wpdb;
+ $result = $wpdb->update(
+ $wpdb->prefix . 'sg_alerts',
+ array( 'is_read' => 1 ),
+ array( 'id' => $id ),
+ array( '%d' ),
+ array( '%d' )
+ );
+ return false !== $result;
+ }
+
+ public static function mark_all_read(): int {
+ global $wpdb;
+ $rows = $wpdb->query( "UPDATE {$wpdb->prefix}sg_alerts SET is_read = 1 WHERE is_read = 0" );
+ return is_numeric( $rows ) ? (int) $rows : 0;
+ }
+}
diff --git a/sg-commerce-pro/src/Automation/AutomationModule.php b/sg-commerce-pro/src/Automation/AutomationModule.php
new file mode 100644
index 0000000..a6f7050
--- /dev/null
+++ b/sg-commerce-pro/src/Automation/AutomationModule.php
@@ -0,0 +1,77 @@
+container->get( RulesEngine::class )->attach_listeners();
+ add_action( 'init', array( $this, 'seed_default_rules' ), 20 );
+ }
+
+ public function seed_default_rules(): void {
+ if ( get_option( 'sg_commerce_default_rules_seeded' ) ) {
+ return;
+ }
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+
+ $wpdb->insert( $wpdb->prefix . 'sg_rules', array(
+ 'name' => 'Low stock alert',
+ 'trigger_event' => 'product.synced',
+ 'conditions' => wp_json_encode( array(
+ array( 'field' => 'fulfillable_qty', 'op' => '<=', 'value' => 5 ),
+ ) ),
+ 'actions' => wp_json_encode( array(
+ array( 'type' => 'create_alert', 'params' => array(
+ 'level' => 'warning',
+ 'category' => 'stock',
+ 'title' => 'Low stock',
+ 'message' => 'A product has 5 or fewer units left.',
+ ) ),
+ ) ),
+ 'is_active' => 1,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ) );
+
+ $wpdb->insert( $wpdb->prefix . 'sg_rules', array(
+ 'name' => 'Lost Buy Box',
+ 'trigger_event' => 'product.synced',
+ 'conditions' => wp_json_encode( array(
+ array( 'field' => 'buybox_is_mine', 'op' => '=', 'value' => 0 ),
+ ) ),
+ 'actions' => wp_json_encode( array(
+ array( 'type' => 'create_alert', 'params' => array(
+ 'level' => 'warning',
+ 'category' => 'buybox',
+ 'title' => 'Lost Buy Box',
+ 'message' => 'A competitor took the Buy Box.',
+ ) ),
+ ) ),
+ 'is_active' => 0, // off by default — too noisy to enable for everyone
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ) );
+
+ update_option( 'sg_commerce_default_rules_seeded', time(), false );
+ }
+}
diff --git a/sg-commerce-pro/src/Automation/RulesEngine.php b/sg-commerce-pro/src/Automation/RulesEngine.php
new file mode 100644
index 0000000..dca2dd2
--- /dev/null
+++ b/sg-commerce-pro/src/Automation/RulesEngine.php
@@ -0,0 +1,156 @@
+events->after( 'product.synced', function ( array $payload ): void {
+ $this->run_for_event( 'product.synced', $payload );
+ } );
+ }
+
+ private function run_for_event( string $event, array $payload ): void {
+ global $wpdb;
+ $rules = $wpdb->get_results( $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}sg_rules WHERE trigger_event = %s AND is_active = 1",
+ $event
+ ), ARRAY_A );
+ if ( empty( $rules ) ) {
+ return;
+ }
+
+ // Hydrate the product if the event references an id.
+ $product = null;
+ if ( ! empty( $payload['product_id'] ) ) {
+ $product = $wpdb->get_row( $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}sg_products WHERE id = %d",
+ (int) $payload['product_id']
+ ), ARRAY_A );
+ }
+
+ foreach ( $rules as $rule ) {
+ $conditions = json_decode( (string) $rule['conditions'], true ) ?: array();
+ if ( ! $this->conditions_match( $conditions, $product ?? array() ) ) {
+ continue;
+ }
+ $actions = json_decode( (string) $rule['actions'], true ) ?: array();
+ foreach ( $actions as $action ) {
+ $this->execute_action( $action, $product ?? array(), (int) $rule['id'] );
+ }
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}sg_rules SET run_count = run_count + 1, last_run_at = %s WHERE id = %d",
+ current_time( 'mysql', true ),
+ (int) $rule['id']
+ ) );
+ }
+ }
+
+ private function conditions_match( array $conditions, array $product ): bool {
+ foreach ( $conditions as $c ) {
+ $field = (string) ( $c['field'] ?? '' );
+ $op = (string) ( $c['op'] ?? '=' );
+ $value = $c['value'] ?? null;
+ $actual = $product[ $field ] ?? null;
+ $pass = match ( $op ) {
+ '=' => (string) $actual === (string) $value,
+ '!=' => (string) $actual !== (string) $value,
+ '<' => is_numeric( $actual ) && $actual < $value,
+ '<=' => is_numeric( $actual ) && $actual <= $value,
+ '>' => is_numeric( $actual ) && $actual > $value,
+ '>=' => is_numeric( $actual ) && $actual >= $value,
+ default => false,
+ };
+ if ( ! $pass ) return false;
+ }
+ return true;
+ }
+
+ private function execute_action( array $action, array $product, int $rule_id ): void {
+ $type = (string) ( $action['type'] ?? '' );
+ $params = (array) ( $action['params'] ?? array() );
+ try {
+ match ( $type ) {
+ 'create_alert' => $this->action_create_alert( $params, $product ),
+ 'email_admin' => $this->action_email( $params, $product ),
+ 'queue_describe' => $this->action_queue_describe( $product ),
+ 'queue_translate' => $this->action_queue_translate( $params, $product ),
+ default => $this->logger->warning( "unknown rule action: {$type}" ),
+ };
+ } catch ( \Throwable $e ) {
+ $this->logger->error( "rule action {$type} failed: " . $e->getMessage(), array( 'rule_id' => $rule_id ) );
+ }
+ }
+
+ private function action_create_alert( array $params, array $product ): void {
+ AlertDispatcher::create(
+ (string) ( $params['level'] ?? 'info' ),
+ (string) ( $params['category'] ?? 'rule' ),
+ (string) ( $params['title'] ?? 'Rule fired' ),
+ (string) ( $params['message'] ?? '' ),
+ $product,
+ isset( $product['id'] ) ? (int) $product['id'] : null
+ );
+ }
+
+ private function action_email( array $params, array $product ): void {
+ $to = (string) ( $params['to'] ?? get_option( 'admin_email' ) );
+ $subject = (string) ( $params['subject'] ?? 'Seven Gum Commerce alert' );
+ $body = (string) ( $params['body'] ?? '' );
+ // Simple {{field}} placeholder substitution.
+ foreach ( $product as $k => $v ) {
+ if ( is_scalar( $v ) ) {
+ $body = str_replace( '{{' . $k . '}}', (string) $v, $body );
+ $subject = str_replace( '{{' . $k . '}}', (string) $v, $subject );
+ }
+ }
+ wp_mail( $to, $subject, $body );
+ }
+
+ private function action_queue_describe( array $product ): void {
+ if ( empty( $product['id'] ) ) return;
+ wp_schedule_single_event( time() + 5, 'sg_commerce_ai_describe', array( array( 'product_id' => (int) $product['id'] ) ) );
+ }
+
+ private function action_queue_translate( array $params, array $product ): void {
+ if ( empty( $product['id'] ) || empty( $params['market'] ) ) return;
+ wp_schedule_single_event( time() + 10, 'sg_commerce_ai_translate', array( array(
+ 'product_id' => (int) $product['id'],
+ 'market' => (string) $params['market'],
+ ) ) );
+ }
+}
diff --git a/sg-commerce-pro/src/CLI/CommandRegistry.php b/sg-commerce-pro/src/CLI/CommandRegistry.php
new file mode 100644
index 0000000..71491d9
--- /dev/null
+++ b/sg-commerce-pro/src/CLI/CommandRegistry.php
@@ -0,0 +1,461 @@
+`. Each subcommand resolves
+ * services from the container so it shares state with the rest of the
+ * plugin (caches, settings, etc.).
+ *
+ * Examples:
+ * wp sg-commerce sync-all
+ * wp sg-commerce sync US
+ * wp sg-commerce describe 42
+ * wp sg-commerce describe-all --market=US --limit=20
+ * wp sg-commerce translate 42 DE
+ * wp sg-commerce repricer-tick
+ * wp sg-commerce status
+ *
+ * @package SevenGum\Commerce\CLI
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\CLI;
+
+use SevenGum\Commerce\AI\Copywriter;
+use SevenGum\Commerce\Amazon\AmazonClient;
+use SevenGum\Commerce\Amazon\Marketplaces;
+use SevenGum\Commerce\Amazon\SyncOrchestrator;
+use SevenGum\Commerce\Core\Plugin;
+use SevenGum\Commerce\Database\Repositories\ProductRepository;
+use SevenGum\Commerce\Repricer\RepricerEngine;
+
+defined( 'ABSPATH' ) || exit;
+
+final class CommandRegistry {
+
+ /**
+ * Sync FBA inventory from one Amazon marketplace.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Marketplace code (US, DE, UK, ...).
+ *
+ * [--max-pages=]
+ * : Maximum pages of inventory to fetch (default 5).
+ *
+ * ## EXAMPLES
+ *
+ * wp sg-commerce sync US
+ * wp sg-commerce sync DE --max-pages=10
+ */
+ public function sync( array $args, array $assoc ): void {
+ $market = strtoupper( (string) ( $args[0] ?? '' ) );
+ if ( '' === $market ) {
+ \WP_CLI::error( 'market argument required' );
+ }
+ $max_pages = isset( $assoc['max-pages'] ) ? max( 1, (int) $assoc['max-pages'] ) : 5;
+ try {
+ $result = $this->container()->get( SyncOrchestrator::class )->sync_market( $market, $max_pages );
+ \WP_CLI::success( sprintf( 'Synced %d products from %s in %d pages.', $result['rows'], $market, $result['pages'] ) );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Sync every enabled marketplace.
+ */
+ public function sync_all( array $args, array $assoc ): void {
+ $result = $this->container()->get( SyncOrchestrator::class )->sync_all();
+ \WP_CLI::log( sprintf( 'Total: %d products', $result['total'] ) );
+ foreach ( $result['by_market'] as $m => $n ) {
+ \WP_CLI::log( " {$m}: {$n}" );
+ }
+ foreach ( $result['errors'] as $m => $err ) {
+ \WP_CLI::warning( " {$m}: {$err}" );
+ }
+ \WP_CLI::success( 'sync-all complete' );
+ }
+
+ /**
+ * Generate AI description for one product.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Product id.
+ */
+ public function describe( array $args, array $assoc ): void {
+ $id = (int) ( $args[0] ?? 0 );
+ if ( $id <= 0 ) {
+ \WP_CLI::error( 'product id required' );
+ }
+ try {
+ $text = $this->container()->get( Copywriter::class )->describe( $id );
+ \WP_CLI::log( $text );
+ \WP_CLI::success( 'description saved' );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Bulk-describe products without an English description yet.
+ *
+ * [--market=]
+ * : Restrict to one marketplace.
+ *
+ * [--limit=]
+ * : Maximum products to process (default 20).
+ */
+ public function describe_all( array $args, array $assoc ): void {
+ global $wpdb;
+ $market = isset( $assoc['market'] ) ? strtoupper( (string) $assoc['market'] ) : '';
+ $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
+
+ $where = "WHERE NOT EXISTS (SELECT 1 FROM {$wpdb->prefix}sg_descriptions d WHERE d.product_id = p.id AND d.language='en')";
+ $params = array();
+ if ( '' !== $market ) {
+ $where .= ' AND market = %s';
+ $params[] = $market;
+ }
+ $sql = "SELECT id FROM {$wpdb->prefix}sg_products p {$where} LIMIT %d";
+ $params[] = $limit;
+ $ids = $wpdb->get_col( $wpdb->prepare( $sql, ...$params ) );
+
+ if ( empty( $ids ) ) {
+ \WP_CLI::success( 'nothing to do' );
+ return;
+ }
+ $copywriter = $this->container()->get( Copywriter::class );
+ $progress = \WP_CLI\Utils\make_progress_bar( 'Describing', count( $ids ) );
+ $ok = 0; $fail = 0;
+ foreach ( $ids as $id ) {
+ try { $copywriter->describe( (int) $id ); $ok++; }
+ catch ( \Throwable $e ) { $fail++; \WP_CLI::warning( "{$id}: " . $e->getMessage() ); }
+ $progress->tick();
+ }
+ $progress->finish();
+ \WP_CLI::success( "described {$ok}, failed {$fail}" );
+ }
+
+ /**
+ * Translate one product's description into a market language.
+ *
+ *
+ * : Product id.
+ *
+ *
+ * : Target marketplace code.
+ */
+ public function translate( array $args, array $assoc ): void {
+ $id = (int) ( $args[0] ?? 0 );
+ $market = strtoupper( (string) ( $args[1] ?? '' ) );
+ if ( $id <= 0 || '' === $market ) {
+ \WP_CLI::error( 'usage: wp sg-commerce translate ' );
+ }
+ try {
+ $text = $this->container()->get( Copywriter::class )->translate( $id, $market );
+ \WP_CLI::log( $text );
+ \WP_CLI::success( 'translation saved' );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /** Run the repricer engine once. */
+ public function repricer_tick( array $args, array $assoc ): void {
+ try {
+ $result = $this->container()->get( RepricerEngine::class )->tick();
+ \WP_CLI::log( wp_json_encode( $result, JSON_PRETTY_PRINT ) );
+ \WP_CLI::success( 'repricer tick complete' );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /** Print high-level status. */
+ public function status( array $args, array $assoc ): void {
+ $c = $this->container();
+ $products = $c->get( ProductRepository::class );
+ $client = $c->get( AmazonClient::class );
+ $mp = $c->get( Marketplaces::class );
+
+ \WP_CLI::log( '── Seven Gum Commerce v' . SG_COMMERCE_VERSION . ' ──' );
+ \WP_CLI::log( 'Amazon credentials: ' . ( $client->has_credentials() ? 'configured' : 'NOT configured' ) );
+ \WP_CLI::log( 'Primary market: ' . $mp->primary() );
+ \WP_CLI::log( 'Enabled markets: ' . implode( ', ', $mp->enabled_codes() ) );
+ \WP_CLI::log( 'Products tracked: ' . $products->count_total() );
+ \WP_CLI::log( 'In stock: ' . $products->count_in_stock() );
+ \WP_CLI::log( 'Winning Buy Box: ' . $products->count_winning_buybox() );
+ \WP_CLI::log( 'Last sync: ' . ( $products->last_sync_at() ?? 'never' ) );
+ }
+
+ /**
+ * Refresh tracking for all pending MCF orders.
+ */
+ public function mcf_refresh( array $args, array $assoc ): void {
+ try {
+ $module = $this->container()->get( \SevenGum\Commerce\Fulfillment\FulfillmentModule::class );
+ $module->refresh_pending_tracking();
+ \WP_CLI::success( 'MCF tracking refreshed' );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * List recent MCF orders.
+ *
+ * [--limit=]
+ * : Max rows (default 20).
+ */
+ public function mcf_list( array $args, array $assoc ): void {
+ $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20;
+ $repo = $this->container()->get( \SevenGum\Commerce\Fulfillment\MCFOrderRepository::class );
+ $rows = $repo->recent( $limit );
+ if ( empty( $rows ) ) { \WP_CLI::log( 'No MCF orders.' ); return; }
+ \WP_CLI\Utils\format_items(
+ 'table',
+ array_map( static fn( $r ) => array(
+ 'id' => $r['id'],
+ 'order' => $r['displayable_order_id'],
+ 'market' => $r['market'],
+ 'status' => $r['status'],
+ 'customer' => $r['customer_name'],
+ 'created' => $r['created_at'],
+ ), $rows ),
+ array( 'id', 'order', 'market', 'status', 'customer', 'created' )
+ );
+ }
+
+ /**
+ * Probe the geo-resolver from CLI (useful for debugging headers).
+ */
+ public function geo( array $args, array $assoc ): void {
+ $resolver = $this->container()->get( \SevenGum\Commerce\Geotargeting\GeoResolver::class );
+ \WP_CLI::log( 'country: ' . $resolver->country() );
+ \WP_CLI::log( 'market: ' . $resolver->market() );
+ }
+
+ /**
+ * Run PII anonymization pass immediately.
+ */
+ public function pii_purge( array $args, array $assoc ): void {
+ try {
+ $count = $this->container()->get( \SevenGum\Commerce\Security\PIIManager\PIIManager::class )->purge_expired();
+ \WP_CLI::success( "Anonymized {$count} expired MCF orders." );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Resync all theme posts' prices + URLs from Amazon data.
+ */
+ public function theme_sync( array $args, array $assoc ): void {
+ try {
+ $updated = $this->container()->get( \SevenGum\Commerce\Integration\ThemeBridge::class )
+ ->on_sync_completed( array() );
+ \WP_CLI::success( 'Theme bridge resync complete.' );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Parse + import a local settlement report TSV file.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Path to the GET_V2_SETTLEMENT_REPORT TSV file.
+ *
+ * [--settlement-id=]
+ * : Settlement ID to tag these rows with (optional).
+ */
+ public function settlement_import( array $args, array $assoc ): void {
+ $path = (string) ( $args[0] ?? '' );
+ if ( '' === $path || ! file_exists( $path ) ) {
+ \WP_CLI::error( "File not found: {$path}" );
+ }
+ $content = (string) file_get_contents( $path );
+ $parser = new \SevenGum\Commerce\Amazon\ReportsParser\SettlementsParser();
+ $events = $parser->parse( $content );
+
+ $settlement_id = isset( $assoc['settlement-id'] ) ? sanitize_text_field( (string) $assoc['settlement-id'] ) : '';
+ global $wpdb;
+ $table = $wpdb->prefix . 'sg_settlements';
+ $imported = 0;
+ foreach ( $events as $e ) {
+ $result = $wpdb->insert( $table, array(
+ 'event_date' => $e['date'] ? gmdate( 'Y-m-d', strtotime( $e['date'] ) ) : null,
+ 'event_type' => $e['type'],
+ 'sku' => $e['sku'],
+ 'order_id' => $e['order_id'],
+ 'marketplace' => $e['marketplace'],
+ 'amount' => $e['amount'],
+ 'currency' => $e['currency'],
+ 'description' => substr( $e['description'], 0, 190 ),
+ 'settlement_id' => $settlement_id,
+ 'imported_at' => current_time( 'mysql', true ),
+ ), array( '%s', '%s', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%s' ) );
+ if ( false !== $result ) $imported++;
+ }
+ $summary = $parser->summary( $events );
+ \WP_CLI::log( "Parsed: {$summary['event_count']} events" );
+ foreach ( $summary['totals'] as $t => $amt ) {
+ \WP_CLI::log( sprintf( ' %-16s %10.2f', $t, $amt ) );
+ }
+ \WP_CLI::log( sprintf( " %-16s %10.2f", 'NET', $summary['net'] ) );
+ \WP_CLI::success( "Imported {$imported} rows." );
+ }
+
+ /**
+ * Poll SQS once (outside the normal cron).
+ */
+ public function sqs_poll( array $args, array $assoc ): void {
+ try {
+ $processed = $this->container()->get( \SevenGum\Commerce\Amazon\SQSConsumer\SQSConsumer::class )->poll();
+ \WP_CLI::success( "Processed {$processed} SQS messages." );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Mint a new API key for the Headless API. Prints the plaintext key ONCE.
+ *
+ *
+ * : Human-readable label for the key.
+ */
+ public function api_key_mint( array $args, array $assoc ): void {
+ $label = sanitize_text_field( (string) ( $args[0] ?? '' ) );
+ if ( '' === $label ) \WP_CLI::error( 'label required' );
+ $key = \SevenGum\Commerce\API\HeadlessAPI::mint_key( $this->container(), $label );
+ \WP_CLI::log( '' );
+ \WP_CLI::log( ' API key (copy now — will not be shown again):' );
+ \WP_CLI::log( '' );
+ \WP_CLI::log( ' ' . $key );
+ \WP_CLI::log( '' );
+ \WP_CLI::success( "Minted API key labeled: {$label}" );
+ }
+
+ /**
+ * Enable or disable sandbox mode (mocked SP-API responses).
+ *
+ *
+ * : on | off
+ */
+ public function sandbox( array $args, array $assoc ): void {
+ $state = strtolower( (string) ( $args[0] ?? '' ) );
+ if ( 'on' === $state ) {
+ \SevenGum\Commerce\Testing\Sandbox::enable();
+ \WP_CLI::success( 'Sandbox enabled. All SP-API calls now return mock data.' );
+ } elseif ( 'off' === $state ) {
+ \SevenGum\Commerce\Testing\Sandbox::disable();
+ \WP_CLI::success( 'Sandbox disabled.' );
+ } else {
+ \WP_CLI::log( 'Sandbox is currently: ' . ( \SevenGum\Commerce\Testing\Sandbox::is_enabled() ? 'ON' : 'OFF' ) );
+ }
+ }
+
+ /**
+ * Look up an EAN-13 in Amazon catalog for a given market.
+ *
+ *
+ * : The 13-digit EAN.
+ *
+ *
+ * : Marketplace code (US, DE, UK, ...).
+ */
+ public function ean_lookup( array $args, array $assoc ): void {
+ $ean = (string) ( $args[0] ?? '' );
+ $market = strtoupper( (string) ( $args[1] ?? 'US' ) );
+
+ if ( ! \SevenGum\Commerce\Amazon\CatalogItems\CatalogLookup::validate_ean13( $ean ) ) {
+ \WP_CLI::error( "Invalid EAN-13 checksum: {$ean}" );
+ }
+
+ try {
+ $result = $this->container()->get( \SevenGum\Commerce\Amazon\CatalogItems\CatalogLookup::class )
+ ->preflight( $ean, $market );
+ \WP_CLI::log( 'Status: ' . $result['status'] );
+ foreach ( $result['matches'] as $m ) {
+ \WP_CLI::log( sprintf( ' %s %s (%s)', $m['asin'], $m['title'], $m['brand'] ) );
+ }
+ \WP_CLI::success( 'Lookup complete.' );
+ } catch ( \Throwable $e ) {
+ \WP_CLI::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Generate box labels for an inbound plan and save a PDF to disk.
+ *
+ *
+ * : Internal plan ID (from wp_sg_inbound_plans).
+ *
+ *
+ * : File path to write the PDF.
+ */
+ public function labels_generate( array $args, array $assoc ): void {
+ $plan_id = (int) ( $args[0] ?? 0 );
+ $output_path = (string) ( $args[1] ?? '' );
+ if ( $plan_id <= 0 || '' === $output_path ) {
+ \WP_CLI::error( 'usage: wp sg-commerce labels-generate ' );
+ }
+
+ global $wpdb;
+ $plan = $wpdb->get_row( $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}sg_inbound_plans WHERE id = %d",
+ $plan_id
+ ), ARRAY_A );
+ if ( ! $plan ) \WP_CLI::error( 'plan not found' );
+
+ $cartons = json_decode( (string) $plan['cartons'], true ) ?: array();
+ $address = json_decode( (string) $plan['source_address'], true ) ?: array();
+
+ $templates = $this->container()->get( \SevenGum\Commerce\Fulfillment\BoxContent\BoxContentService::class )->get_templates();
+ $template = $templates['sg-master-24pack'] ?? array();
+
+ $boxes = array();
+ foreach ( $cartons as $c ) {
+ $qty = (int) ( $c['quantity'] ?? 1 );
+ for ( $i = 1; $i <= $qty; $i++ ) {
+ $boxes[] = array(
+ 'box_id' => sprintf( '%s-U%05d', $plan['name'], $i ),
+ 'plan_name' => $plan['name'],
+ 'ship_to' => array(
+ 'name' => 'Amazon FBA Warehouse',
+ 'address1' => '(to be assigned after placement confirm)',
+ 'city' => '',
+ 'state' => '',
+ 'postal' => '',
+ 'country' => $plan['market'],
+ ),
+ 'sku_contents' => $c['contents'] ?? array(),
+ 'weight_kg' => $template['weight_kg'] ?? 2.1,
+ 'dimensions' => array(
+ 'length_cm' => $template['length_cm'] ?? 33,
+ 'width_cm' => $template['width_cm'] ?? 9.5,
+ 'height_cm' => $template['height_cm'] ?? 22.5,
+ ),
+ );
+ }
+ }
+ if ( empty( $boxes ) ) \WP_CLI::error( 'no cartons in plan' );
+
+ $pdf_bytes = $this->container()->get( \SevenGum\Commerce\Fulfillment\LabelGenerator\LabelGenerator::class )
+ ->generate_box_labels( $boxes );
+ file_put_contents( $output_path, $pdf_bytes );
+ \WP_CLI::success( sprintf( 'Wrote %d label pages (%s bytes) to %s',
+ count( $boxes ), number_format( strlen( $pdf_bytes ) ), $output_path ) );
+ }
+
+ private function container() {
+ return Plugin::instance()->container();
+ }
+}
diff --git a/sg-commerce-pro/src/Cache/CacheManager.php b/sg-commerce-pro/src/Cache/CacheManager.php
new file mode 100644
index 0000000..bc666d0
--- /dev/null
+++ b/sg-commerce-pro/src/Cache/CacheManager.php
@@ -0,0 +1,107 @@
+ */
+ private array $memory = array();
+
+ /**
+ * Read a key. Returns $default if not found.
+ */
+ public function get( string $key, mixed $default = null ): mixed {
+ $full = $this->key( $key );
+
+ // Tier 1: in-request.
+ if ( array_key_exists( $full, $this->memory ) ) {
+ return $this->memory[ $full ];
+ }
+
+ // Tier 2: object cache.
+ $found = false;
+ $value = wp_cache_get( $full, self::GROUP, false, $found );
+ if ( $found ) {
+ $this->memory[ $full ] = $value;
+ return $value;
+ }
+
+ // Tier 3: transient.
+ $value = get_transient( $full );
+ if ( false !== $value ) {
+ $this->memory[ $full ] = $value;
+ wp_cache_set( $full, $value, self::GROUP, 0 );
+ return $value;
+ }
+
+ return $default;
+ }
+
+ /**
+ * Write a key with optional TTL (seconds). 0 = no expiration.
+ */
+ public function set( string $key, mixed $value, int $ttl = 0 ): void {
+ $full = $this->key( $key );
+ $this->memory[ $full ] = $value;
+ wp_cache_set( $full, $value, self::GROUP, $ttl );
+ set_transient( $full, $value, $ttl );
+ }
+
+ public function delete( string $key ): void {
+ $full = $this->key( $key );
+ unset( $this->memory[ $full ] );
+ wp_cache_delete( $full, self::GROUP );
+ delete_transient( $full );
+ }
+
+ /**
+ * Memoize a callable: call it on first access, return cached result thereafter.
+ *
+ * @template T
+ * @param string $key
+ * @param callable(): T $producer
+ * @param int $ttl
+ * @return T
+ */
+ public function remember( string $key, callable $producer, int $ttl = 300 ): mixed {
+ $value = $this->get( $key, null );
+ if ( null !== $value ) {
+ return $value;
+ }
+ $value = $producer();
+ if ( null !== $value ) {
+ $this->set( $key, $value, $ttl );
+ }
+ return $value;
+ }
+
+ /**
+ * Clear in-request memory tier only (useful in long-running CLI processes).
+ */
+ public function flush_memory(): void {
+ $this->memory = array();
+ }
+
+ private function key( string $key ): string {
+ return self::GROUP . '/' . $key;
+ }
+}
diff --git a/sg-commerce-pro/src/Comparison/ComparisonModule.php b/sg-commerce-pro/src/Comparison/ComparisonModule.php
new file mode 100644
index 0000000..f405634
--- /dev/null
+++ b/sg-commerce-pro/src/Comparison/ComparisonModule.php
@@ -0,0 +1,60 @@
+container;
+
+ $c->singleton( ComparisonTable::class, static fn( Container $c ) =>
+ new ComparisonTable(
+ $c->get( ProductRepository::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( Copywriter::class )
+ )
+ );
+
+ add_shortcode( 'sg_compare', function ( $atts ): string {
+ return $this->container->get( ComparisonTable::class )->render( $atts );
+ } );
+
+ add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) );
+ }
+
+ public function maybe_enqueue_assets(): void {
+ global $post;
+ if ( ! $post || ! has_shortcode( (string) $post->post_content, 'sg_compare' ) ) {
+ return;
+ }
+ // Depends on widget.css for the shared chip/price styles.
+ wp_enqueue_style( 'sg-commerce-widget', SG_COMMERCE_URL . 'assets/css/widget.css', array(), SG_COMMERCE_VERSION );
+ wp_enqueue_style( 'sg-commerce-compare', SG_COMMERCE_URL . 'assets/css/compare.css', array( 'sg-commerce-widget' ), SG_COMMERCE_VERSION );
+ wp_enqueue_script( 'sg-commerce-widget', SG_COMMERCE_URL . 'assets/js/widget.js', array(), SG_COMMERCE_VERSION, true );
+ wp_localize_script( 'sg-commerce-widget', 'SG_COMMERCE', array( 'rest' => esc_url_raw( rest_url( 'sg-commerce/v1/' ) ) ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Comparison/ComparisonTable.php b/sg-commerce-pro/src/Comparison/ComparisonTable.php
new file mode 100644
index 0000000..e5a68ae
--- /dev/null
+++ b/sg-commerce-pro/src/Comparison/ComparisonTable.php
@@ -0,0 +1,253 @@
+= 768px): horizontal table, feature rows × product columns
+ * Mobile (< 768px): stacked cards, one per product, features as dt/dd
+ *
+ * The breakpoint is handled in CSS — server always emits the same HTML.
+ * Both layouts are included in the markup so it's a pure CSS toggle.
+ *
+ * Each product block has a `data-sku` / `data-market` so the widget's
+ * auto-refresh JS picks it up and keeps price + stock live.
+ *
+ * @package SevenGum\Commerce\Comparison
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Comparison;
+
+use SevenGum\Commerce\AI\Copywriter;
+use SevenGum\Commerce\Amazon\Marketplaces;
+use SevenGum\Commerce\Database\Repositories\ProductRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+final class ComparisonTable {
+
+ /**
+ * Default feature rows if the `features` attribute isn't set. These map
+ * to keys we pull from the product row or compute.
+ */
+ private const DEFAULT_FEATURES = array( 'image', 'name', 'price', 'stock', 'cta' );
+
+ public function __construct(
+ private ProductRepository $products,
+ private Marketplaces $marketplaces,
+ private Copywriter $copywriter,
+ ) {}
+
+ public function render( $atts ): string {
+ $atts = shortcode_atts(
+ array(
+ 'skus' => '',
+ 'slug' => '',
+ 'market' => 'auto', // Geotargeting filter turns 'auto' into visitor's market.
+ 'features' => implode( ',', self::DEFAULT_FEATURES ),
+ 'title' => '',
+ ),
+ is_array( $atts ) ? $atts : array(),
+ 'sg_compare'
+ );
+
+ $skus = array();
+ $market = strtoupper( (string) $atts['market'] );
+ $title = sanitize_text_field( (string) $atts['title'] );
+
+ // Load from stored comparison set by slug.
+ if ( '' !== (string) $atts['slug'] ) {
+ global $wpdb;
+ $set = $wpdb->get_row( $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}sg_comparisons WHERE slug = %s",
+ sanitize_key( (string) $atts['slug'] )
+ ), ARRAY_A );
+ if ( $set ) {
+ $decoded = json_decode( (string) $set['skus'], true );
+ if ( is_array( $decoded ) ) $skus = $decoded;
+ if ( '' === $title ) $title = (string) $set['title'];
+ if ( '' !== (string) $set['market'] && 'AUTO' === $market ) {
+ $market = (string) $set['market'];
+ }
+ }
+ } else {
+ $skus = array_filter( array_map( 'trim', explode( ',', (string) $atts['skus'] ) ) );
+ }
+
+ if ( empty( $skus ) ) {
+ return '[sg_compare] requires either skus or a slug.
';
+ }
+ if ( ! $this->marketplaces->exists( $market ) ) {
+ return sprintf( 'Invalid market: %s
', esc_html( $market ) );
+ }
+
+ $feature_keys = array_filter( array_map( 'trim', explode( ',', (string) $atts['features'] ) ) );
+ if ( empty( $feature_keys ) ) $feature_keys = self::DEFAULT_FEATURES;
+
+ // Resolve products.
+ $resolved = array();
+ foreach ( $skus as $sku ) {
+ $row = $this->products->find_by_sku( $market, (string) $sku );
+ if ( $row ) {
+ $resolved[] = $this->build_vm( $row, $market );
+ }
+ }
+
+ if ( empty( $resolved ) ) {
+ return 'No matching products found in ' . esc_html( $market ) . '.
';
+ }
+
+ return $this->render_html( $resolved, $feature_keys, $title, $market );
+ }
+
+ private function build_vm( array $row, string $market ): array {
+ $mp = $this->marketplaces->get( $market );
+ $currency = (string) $row['buybox_currency'] ?: ( $mp['currency'] ?? '' );
+ $lang = (string) ( $mp['language'] ?? 'en' );
+
+ $description = $this->copywriter->find_description( (int) $row['id'], $lang )
+ ?? $this->copywriter->find_description( (int) $row['id'], 'en' )
+ ?? '';
+
+ return array(
+ 'id' => (int) $row['id'],
+ 'sku' => (string) $row['sku'],
+ 'asin' => (string) $row['asin'],
+ 'name' => (string) $row['product_name'],
+ 'image_url' => (string) $row['image_url'],
+ 'stock' => (int) $row['fulfillable_qty'],
+ 'in_stock' => (int) $row['fulfillable_qty'] > 0,
+ 'price' => null !== $row['buybox_price'] ? (float) $row['buybox_price'] : null,
+ 'currency' => $currency,
+ 'description' => $description,
+ 'amazon_url' => '' !== $row['asin'] ? $this->marketplaces->product_url( (string) $row['asin'], $market ) : '',
+ );
+ }
+
+ /**
+ * Emits both desktop (table) and mobile (cards) markup in one HTML block.
+ * CSS toggles visibility at 768px breakpoint.
+ */
+ private function render_html( array $items, array $features, string $title, string $market ): string {
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ feature_label( $feature ) ); ?>
+
+
+ feature_cell( $feature, $p ); // phpcs:ignore ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ feature_label( $feature ) ); ?>
+ feature_cell( $feature, $p ); // phpcs:ignore ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ __( 'Price', 'sg-commerce' ),
+ 'stock' => __( 'Availability', 'sg-commerce' ),
+ 'description' => __( 'Description', 'sg-commerce' ),
+ 'cta' => __( 'Buy', 'sg-commerce' ),
+ 'sku' => 'SKU',
+ 'asin' => 'ASIN',
+ default => ucfirst( str_replace( '_', ' ', $key ) ),
+ };
+ }
+
+ private function feature_cell( string $feature, array $p ): string {
+ switch ( $feature ) {
+ case 'price':
+ if ( null === $p['price'] ) return '— ';
+ return ''
+ . esc_html( number_format( $p['price'], 2 ) )
+ . ' ' . esc_html( $p['currency'] ) . ' ';
+ case 'stock':
+ return $p['in_stock']
+ ? '' . esc_html__( 'In stock', 'sg-commerce' ) . ' '
+ : '' . esc_html__( 'Out of stock', 'sg-commerce' ) . ' ';
+ case 'description':
+ return $p['description'] !== ''
+ ? esc_html( $p['description'] )
+ : '— ';
+ case 'cta':
+ return '' !== $p['amazon_url']
+ ? '' . esc_html__( 'Buy →', 'sg-commerce' ) . ' '
+ : '— ';
+ case 'sku':
+ return '' . esc_html( $p['sku'] ) . '';
+ case 'asin':
+ return $p['asin'] ? '' . esc_html( $p['asin'] ) . '' : '— ';
+ default:
+ // Allow arbitrary keys from the row.
+ return isset( $p[ $feature ] ) ? esc_html( (string) $p[ $feature ] ) : '— ';
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Core/Activator.php b/sg-commerce-pro/src/Core/Activator.php
new file mode 100644
index 0000000..fd53437
--- /dev/null
+++ b/sg-commerce-pro/src/Core/Activator.php
@@ -0,0 +1,182 @@
+Seven Gum Commerce: activation blocked' . esc_html( implode( ' ', $errors ) ) . ' ',
+ 'Plugin activation failed',
+ array( 'back_link' => true )
+ );
+ }
+ }
+
+ private static function seed_defaults(): void {
+ $defaults = array(
+ 'sg_commerce_settings' => array(
+ 'amazon_primary_market' => 'US',
+ 'amazon_enabled_markets' => array( 'US' ),
+ 'ai_provider' => 'ollama',
+ 'ai_default_locale' => 'en',
+ 'ai_temperature' => 0.7,
+ 'ai_brand_voice' => 'modern, clean, playful but confident',
+ 'ollama_url' => 'http://127.0.0.1:11434',
+ 'ollama_model' => 'llama3.1:8b',
+ 'sync_frequency' => 'hourly',
+ 'sync_pages_per_run' => 5,
+ 'analytics_enabled' => true,
+ 'repricer_enabled' => false,
+ 'repricer_strategy' => 'match_buybox',
+ 'repricer_min_margin_pct' => 15.0,
+ 'log_level' => 'info',
+ 'log_retention_days' => 30,
+ // Fulfillment (MCF)
+ 'mcf_auto_wc' => false,
+ 'mcf_default_market' => 'US',
+ 'mcf_default_speed' => 'Standard',
+ // Injection
+ 'injection_enabled' => true,
+ // Geotargeting
+ 'geotargeting_enabled' => true,
+ // Theme bridge (v3.2)
+ 'theme_bridge_enabled' => true,
+ // PII compliance (v3.2)
+ 'pii_retention_days' => 30,
+ // SQS real-time consumer (v3.2)
+ 'sqs_enabled' => false,
+ 'sqs_region' => 'us-east-1',
+ 'sqs_queue_url' => '',
+ 'sqs_access_key' => '',
+ 'sqs_secret_key_enc' => '',
+ // Headless API keys (v3.2)
+ 'api_keys' => array(),
+ // Sandbox (v3.2)
+ 'sandbox_enabled' => false,
+ ),
+ );
+ foreach ( $defaults as $key => $value ) {
+ if ( false === get_option( $key, false ) ) {
+ add_option( $key, $value, '', false );
+ }
+ }
+ }
+
+ /**
+ * Seed Seven Gum's master-carton template so operators don't have to
+ * re-enter physical measurements. Called once at activation.
+ */
+ private static function seed_carton_templates(): void {
+ if ( class_exists( '\\SevenGum\\Commerce\\Fulfillment\\BoxContent\\BoxContentService' ) ) {
+ \SevenGum\Commerce\Fulfillment\BoxContent\BoxContentService::seed_default_template();
+ }
+ }
+
+ private static function schedule_cron(): void {
+ $jobs = array(
+ 'sg_commerce_hourly_sync' => 'hourly',
+ 'sg_commerce_daily_analytics' => 'daily',
+ 'sg_commerce_repricer_tick' => 'sg_fifteen_minutes',
+ 'sg_commerce_competitor_scan' => 'sg_six_hours',
+ 'sg_commerce_ai_queue_process' => 'sg_five_minutes',
+ 'sg_commerce_mcf_refresh' => 'sg_fifteen_minutes',
+ 'sg_commerce_pii_purge' => 'daily',
+ 'sg_commerce_sqs_poll' => 'sg_five_minutes',
+ );
+ foreach ( $jobs as $hook => $recurrence ) {
+ if ( ! wp_next_scheduled( $hook ) ) {
+ wp_schedule_event( time() + 600, $recurrence, $hook );
+ }
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Core/Container.php b/sg-commerce-pro/src/Core/Container.php
new file mode 100644
index 0000000..e23f7a7
--- /dev/null
+++ b/sg-commerce-pro/src/Core/Container.php
@@ -0,0 +1,126 @@
+ */
+ private array $factories = array();
+
+ /** @var array */
+ private array $singletons = array();
+
+ /** @var array */
+ private array $instances = array();
+
+ /** @var array Tracks active resolutions to detect cycles. */
+ private array $resolving = array();
+
+ /**
+ * Bind a factory. Each resolve() invokes the factory and returns a new
+ * instance.
+ */
+ public function bind( string $id, callable $factory ): void {
+ unset( $this->singletons[ $id ], $this->instances[ $id ] );
+ $this->factories[ $id ] = $factory;
+ }
+
+ /**
+ * Bind a singleton factory. The factory is called once on first
+ * resolve(); subsequent calls return the cached result.
+ */
+ public function singleton( string $id, callable $factory ): void {
+ unset( $this->factories[ $id ], $this->instances[ $id ] );
+ $this->singletons[ $id ] = $factory;
+ }
+
+ /**
+ * Bind an existing instance. Returned as-is from every resolve() call.
+ */
+ public function instance( string $id, mixed $object ): void {
+ unset( $this->factories[ $id ], $this->singletons[ $id ] );
+ $this->instances[ $id ] = $object;
+ }
+
+ /**
+ * Resolve a binding by id.
+ *
+ * @throws \RuntimeException If the binding is missing or a cycle is detected.
+ */
+ public function get( string $id ): mixed {
+ // Pre-built instance.
+ if ( array_key_exists( $id, $this->instances ) ) {
+ return $this->instances[ $id ];
+ }
+
+ // Cached singleton.
+ if ( isset( $this->singletons[ $id ] ) ) {
+ if ( isset( $this->resolving[ $id ] ) ) {
+ throw new \RuntimeException( "Circular dependency while resolving '{$id}'." );
+ }
+ $this->resolving[ $id ] = true;
+ try {
+ $obj = ( $this->singletons[ $id ] )( $this );
+ } finally {
+ unset( $this->resolving[ $id ] );
+ }
+ $this->instances[ $id ] = $obj;
+ return $obj;
+ }
+
+ // Per-call factory.
+ if ( isset( $this->factories[ $id ] ) ) {
+ if ( isset( $this->resolving[ $id ] ) ) {
+ throw new \RuntimeException( "Circular dependency while resolving '{$id}'." );
+ }
+ $this->resolving[ $id ] = true;
+ try {
+ return ( $this->factories[ $id ] )( $this );
+ } finally {
+ unset( $this->resolving[ $id ] );
+ }
+ }
+
+ throw new \RuntimeException( "No binding registered for '{$id}'." );
+ }
+
+ public function has( string $id ): bool {
+ return isset( $this->instances[ $id ] )
+ || isset( $this->singletons[ $id ] )
+ || isset( $this->factories[ $id ] );
+ }
+
+ /**
+ * Convenience: resolve multiple bindings as an associative array.
+ *
+ * @param array $ids
+ * @return array
+ */
+ public function getMany( array $ids ): array {
+ $out = array();
+ foreach ( $ids as $id ) {
+ $out[ $id ] = $this->get( $id );
+ }
+ return $out;
+ }
+}
diff --git a/sg-commerce-pro/src/Core/Module.php b/sg-commerce-pro/src/Core/Module.php
new file mode 100644
index 0000000..cb6dcec
--- /dev/null
+++ b/sg-commerce-pro/src/Core/Module.php
@@ -0,0 +1,32 @@
+container = new Container();
+ }
+
+ public function container(): Container {
+ return $this->container;
+ }
+
+ /**
+ * Main entrypoint. Idempotent — multiple calls are safe.
+ */
+ public function boot(): void {
+ if ( $this->booted ) {
+ return;
+ }
+ $this->booted = true;
+
+ $this->register_cron_schedules();
+ $this->register_services();
+ $this->register_modules();
+ $this->register_global_hooks();
+
+ // Auto-migrate schema when the plugin is updated without re-activation
+ // (e.g. FTP upload, git pull). Runs on admin_init so it only fires
+ // once an admin hits wp-admin — safe and cheap.
+ add_action( 'admin_init', array( $this, 'maybe_upgrade_schema' ), 1 );
+ }
+
+ public function maybe_upgrade_schema(): void {
+ $stored = (int) get_option( 'sg_commerce_schema_version', 0 );
+ if ( $stored < \SevenGum\Commerce\Database\Schema::SCHEMA_VERSION ) {
+ \SevenGum\Commerce\Database\Schema::install();
+ // Also seed any new defaults/templates that v3.3 introduced.
+ if ( class_exists( '\\SevenGum\\Commerce\\Fulfillment\\BoxContent\\BoxContentService' ) ) {
+ \SevenGum\Commerce\Fulfillment\BoxContent\BoxContentService::seed_default_template();
+ }
+ $this->container->get( Logger::class )->info( 'Schema auto-upgraded', array(
+ 'from' => $stored,
+ 'to' => \SevenGum\Commerce\Database\Schema::SCHEMA_VERSION,
+ ) );
+ }
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Cron schedules */
+ /* ------------------------------------------------------------------ */
+
+ private function register_cron_schedules(): void {
+ // NOTE: `cron_schedules` fires VERY early in the request, sometimes
+ // before `init`. Using __() here triggers WP 6.7+ "textdomain loaded
+ // too early" notice. These display labels are admin-only polish, so
+ // we use plain English strings — the functional "interval" values
+ // are what actually matters.
+ add_filter( 'cron_schedules', static function ( array $schedules ): array {
+ $schedules['sg_five_minutes'] = array(
+ 'interval' => 5 * MINUTE_IN_SECONDS,
+ 'display' => 'Every 5 minutes (Seven Gum)',
+ );
+ $schedules['sg_fifteen_minutes'] = array(
+ 'interval' => 15 * MINUTE_IN_SECONDS,
+ 'display' => 'Every 15 minutes (Seven Gum)',
+ );
+ $schedules['sg_six_hours'] = array(
+ 'interval' => 6 * HOUR_IN_SECONDS,
+ 'display' => 'Every 6 hours (Seven Gum)',
+ );
+ return $schedules;
+ } );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Service bindings */
+ /* ------------------------------------------------------------------ */
+
+ private function register_services(): void {
+ $c = $this->container;
+
+ // Foundational singletons.
+ $c->singleton( Logger::class, static fn() => new Logger() );
+ $c->singleton( Encryption::class, static fn() => new Encryption() );
+ $c->singleton( CacheManager::class, static fn() => new CacheManager() );
+ $c->singleton( EventDispatcher::class, static fn() => new EventDispatcher() );
+ $c->singleton( JobQueue::class, static fn( Container $c ) =>
+ new JobQueue( $c->get( Logger::class ) )
+ );
+
+ // Repositories — share a single $wpdb-backed instance.
+ $c->singleton( SettingsRepository::class, static fn( Container $c ) =>
+ new SettingsRepository( $c->get( Encryption::class ), $c->get( Logger::class ) )
+ );
+ $c->singleton( ProductRepository::class, static fn( Container $c ) =>
+ new ProductRepository( $c->get( CacheManager::class ), $c->get( Logger::class ) )
+ );
+ $c->singleton( PriceHistoryRepository::class, static fn( Container $c ) =>
+ new PriceHistoryRepository( $c->get( Logger::class ) )
+ );
+
+ // Amazon stack.
+ $c->singleton( Marketplaces::class, static fn() => new Marketplaces() );
+ $c->singleton( RateLimiter::class, static fn( Container $c ) =>
+ new RateLimiter( $c->get( CacheManager::class ) )
+ );
+ $c->singleton( CircuitBreaker::class, static fn( Container $c ) =>
+ new CircuitBreaker( $c->get( CacheManager::class ), $c->get( Logger::class ) )
+ );
+ $c->singleton( LWATokenManager::class, static fn( Container $c ) =>
+ new LWATokenManager(
+ $c->get( SettingsRepository::class ),
+ $c->get( CacheManager::class ),
+ $c->get( Logger::class )
+ )
+ );
+ $c->singleton( AmazonClient::class, static fn( Container $c ) =>
+ new AmazonClient(
+ $c->get( LWATokenManager::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( RateLimiter::class ),
+ $c->get( CircuitBreaker::class ),
+ $c->get( Logger::class )
+ )
+ );
+ $c->singleton( SyncOrchestrator::class, static fn( Container $c ) =>
+ new SyncOrchestrator(
+ $c->get( AmazonClient::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( ProductRepository::class ),
+ $c->get( PriceHistoryRepository::class ),
+ $c->get( EventDispatcher::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ // AI stack.
+ $c->singleton( PromptRegistry::class, static fn( Container $c ) =>
+ new PromptRegistry( $c->get( SettingsRepository::class ) )
+ );
+ $c->singleton( OllamaProvider::class, static fn( Container $c ) =>
+ new OllamaProvider(
+ $c->get( SettingsRepository::class ),
+ $c->get( CacheManager::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ // Analytics.
+ $c->singleton( SalesAggregator::class, static fn( Container $c ) =>
+ new SalesAggregator(
+ $c->get( ProductRepository::class ),
+ $c->get( PriceHistoryRepository::class ),
+ $c->get( Logger::class )
+ )
+ );
+ $c->singleton( ProfitCalculator::class, static fn( Container $c ) =>
+ new ProfitCalculator( $c->get( SettingsRepository::class ) )
+ );
+
+ // Automation + Repricer.
+ $c->singleton( RulesEngine::class, static fn( Container $c ) =>
+ new RulesEngine(
+ $c->get( SettingsRepository::class ),
+ $c->get( EventDispatcher::class ),
+ $c->get( Logger::class )
+ )
+ );
+ $c->singleton( RepricerEngine::class, static fn( Container $c ) =>
+ new RepricerEngine(
+ $c->get( ProductRepository::class ),
+ $c->get( SettingsRepository::class ),
+ $c->get( ProfitCalculator::class ),
+ $c->get( Logger::class )
+ )
+ );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Modules */
+ /* ------------------------------------------------------------------ */
+
+ private function register_modules(): void {
+ $module_classes = apply_filters(
+ 'sg_commerce_modules',
+ array(
+ AmazonModule::class,
+ AIModule::class,
+ AnalyticsModule::class,
+ AutomationModule::class,
+ RepricerModule::class,
+ FulfillmentModule::class,
+ InboundShipmentModule::class,
+ GeotargetingModule::class,
+ InjectionModule::class,
+ ComparisonModule::class,
+ IntegrationModule::class,
+ PIIManager::class,
+ SQSConsumer::class,
+ HeadlessAPI::class,
+ WidgetModule::class,
+ RESTModule::class,
+ SuiteModule::class,
+ AdminModule::class,
+ )
+ );
+
+ foreach ( $module_classes as $cls ) {
+ if ( ! class_exists( $cls ) ) {
+ continue;
+ }
+ try {
+ /** @var Module $module */
+ $module = new $cls( $this->container );
+ $module->register();
+ $this->modules[ $module->id() ] = $module;
+ $this->container->instance( 'module.' . $module->id(), $module );
+ // CRITICAL: also bind under the FQCN so admin views and CLI
+ // commands can resolve the module directly via $container->get($cls::class).
+ $this->container->instance( $cls, $module );
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->error(
+ sprintf( 'Module %s failed to register: %s', $cls, $e->getMessage() )
+ );
+ }
+ }
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Global hooks */
+ /* ------------------------------------------------------------------ */
+
+ private function register_global_hooks(): void {
+ // CLI registration deferred to inside the if-block to avoid loading
+ // the CLI namespace when WP-CLI isn't running.
+ if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( '\\WP_CLI' ) ) {
+ \WP_CLI::add_command( 'sg-commerce', \SevenGum\Commerce\CLI\CommandRegistry::class );
+ }
+
+ // Admin row links.
+ add_filter( 'plugin_action_links_' . SG_COMMERCE_BASENAME, static function ( array $links ): array {
+ $settings_link = sprintf(
+ '%s ',
+ esc_url( admin_url( 'admin.php?page=sg-commerce' ) ),
+ esc_html__( 'Dashboard', 'sg-commerce' )
+ );
+ array_unshift( $links, $settings_link );
+ return $links;
+ } );
+ }
+
+ /** @return Module[] */
+ public function modules(): array {
+ return $this->modules;
+ }
+}
diff --git a/sg-commerce-pro/src/Database/Repositories/PriceHistoryRepository.php b/sg-commerce-pro/src/Database/Repositories/PriceHistoryRepository.php
new file mode 100644
index 0000000..5eb1a9a
--- /dev/null
+++ b/sg-commerce-pro/src/Database/Repositories/PriceHistoryRepository.php
@@ -0,0 +1,106 @@
+table = $wpdb->prefix . 'sg_price_history';
+ }
+
+ public function record( int $product_id, array $snapshot, string $source = 'sync' ): bool {
+ global $wpdb;
+ $result = $wpdb->insert(
+ $this->table,
+ array(
+ 'product_id' => $product_id,
+ 'recorded_at' => current_time( 'mysql', true ),
+ 'my_price' => $snapshot['my_price'] ?? null,
+ 'buybox_price' => $snapshot['buybox_price'] ?? null,
+ 'lowest_price' => $snapshot['lowest_price'] ?? null,
+ 'currency' => (string) ( $snapshot['currency'] ?? '' ),
+ 'buybox_is_mine' => (int) ( $snapshot['buybox_is_mine'] ?? 0 ),
+ 'fulfillable_qty' => (int) ( $snapshot['fulfillable_qty'] ?? 0 ),
+ 'source' => $source,
+ ),
+ array( '%d', '%s', '%f', '%f', '%f', '%s', '%d', '%d', '%s' )
+ );
+ if ( false === $result ) {
+ $this->logger->error( 'price history insert failed', array( 'err' => $wpdb->last_error ) );
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Recent N snapshots for a product, oldest first.
+ *
+ * @return array
+ */
+ public function recent_for_product( int $product_id, int $limit = 100 ): array {
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$this->table}
+ WHERE product_id = %d
+ ORDER BY recorded_at DESC
+ LIMIT %d",
+ $product_id,
+ $limit
+ ),
+ ARRAY_A
+ );
+ return is_array( $rows ) ? array_reverse( $rows ) : array();
+ }
+
+ /**
+ * Aggregate buybox loss events across the inventory, grouped by day.
+ * Used by the analytics dashboard.
+ */
+ public function buybox_loss_by_day( int $days = 14 ): array {
+ global $wpdb;
+ $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS );
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT DATE(recorded_at) AS day,
+ SUM(CASE WHEN buybox_is_mine = 0 THEN 1 ELSE 0 END) AS lost,
+ SUM(CASE WHEN buybox_is_mine = 1 THEN 1 ELSE 0 END) AS won
+ FROM {$this->table}
+ WHERE recorded_at >= %s
+ GROUP BY DATE(recorded_at)
+ ORDER BY day ASC",
+ $cutoff
+ ),
+ ARRAY_A
+ );
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ /** Prune snapshots older than $days to keep the table bounded. */
+ public function prune_older_than( int $days ): int {
+ global $wpdb;
+ $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS );
+ $deleted = $wpdb->query(
+ $wpdb->prepare( "DELETE FROM {$this->table} WHERE recorded_at < %s", $cutoff )
+ );
+ return is_numeric( $deleted ) ? (int) $deleted : 0;
+ }
+}
diff --git a/sg-commerce-pro/src/Database/Repositories/ProductRepository.php b/sg-commerce-pro/src/Database/Repositories/ProductRepository.php
new file mode 100644
index 0000000..b21f3f2
--- /dev/null
+++ b/sg-commerce-pro/src/Database/Repositories/ProductRepository.php
@@ -0,0 +1,325 @@
+prepare,
+ * checks return values, logs errors, and uses an in-request cache to
+ * coalesce duplicate fetches on the same page-render cycle.
+ *
+ * @package SevenGum\Commerce\Database\Repositories
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Database\Repositories;
+
+use SevenGum\Commerce\Cache\CacheManager;
+use SevenGum\Commerce\Logging\Logger;
+
+defined( 'ABSPATH' ) || exit;
+
+final class ProductRepository {
+
+ private string $table;
+
+ /** Per-request cache keyed by id or "{market}|{sku}". */
+ private array $by_id = array();
+ private array $by_market_sku = array();
+
+ public function __construct(
+ private CacheManager $cache,
+ private Logger $logger,
+ ) {
+ global $wpdb;
+ $this->table = $wpdb->prefix . 'sg_products';
+ }
+
+ public function find( int $id ): ?array {
+ if ( isset( $this->by_id[ $id ] ) ) {
+ return $this->by_id[ $id ];
+ }
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ),
+ ARRAY_A
+ );
+ if ( null === $row ) {
+ return null;
+ }
+ $this->by_id[ $id ] = $row;
+ $this->by_market_sku[ $row['market'] . '|' . $row['sku'] ] = $row;
+ return $row;
+ }
+
+ public function find_by_sku( string $market, string $sku ): ?array {
+ $key = $market . '|' . $sku;
+ if ( isset( $this->by_market_sku[ $key ] ) ) {
+ return $this->by_market_sku[ $key ];
+ }
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$this->table} WHERE market = %s AND sku = %s LIMIT 1",
+ $market,
+ $sku
+ ),
+ ARRAY_A
+ );
+ if ( null === $row ) {
+ return null;
+ }
+ $this->by_market_sku[ $key ] = $row;
+ $this->by_id[ (int) $row['id'] ] = $row;
+ return $row;
+ }
+
+ /**
+ * Paginated query with filtering & search.
+ *
+ * @param array{
+ * market?: string,
+ * search?: string,
+ * has_buybox?: bool,
+ * in_stock?: bool,
+ * per_page?: int,
+ * page?: int,
+ * orderby?: string,
+ * order?: string,
+ * } $args
+ *
+ * @return array{rows: array, total: int, pages: int}
+ */
+ public function paginate( array $args = array() ): array {
+ global $wpdb;
+
+ $market = isset( $args['market'] ) ? (string) $args['market'] : '';
+ $search = isset( $args['search'] ) ? trim( (string) $args['search'] ) : '';
+ $per_page = max( 1, min( 200, (int) ( $args['per_page'] ?? 25 ) ) );
+ $page = max( 1, (int) ( $args['page'] ?? 1 ) );
+ $offset = ( $page - 1 ) * $per_page;
+
+ $allowed_orderby = array( 'id', 'market', 'sku', 'product_name', 'fulfillable_qty', 'buybox_price', 'last_sync_at', 'updated_at' );
+ $orderby = in_array( $args['orderby'] ?? '', $allowed_orderby, true ) ? $args['orderby'] : 'updated_at';
+ $order = ( strtoupper( (string) ( $args['order'] ?? 'DESC' ) ) === 'ASC' ) ? 'ASC' : 'DESC';
+
+ $where = array( '1=1' );
+ $params = array();
+ if ( '' !== $market ) {
+ $where[] = 'market = %s';
+ $params[] = $market;
+ }
+ if ( '' !== $search ) {
+ $where[] = '(sku LIKE %s OR asin LIKE %s OR product_name LIKE %s)';
+ $like = '%' . $wpdb->esc_like( $search ) . '%';
+ $params[] = $like;
+ $params[] = $like;
+ $params[] = $like;
+ }
+ if ( ! empty( $args['has_buybox'] ) ) {
+ $where[] = 'buybox_is_mine = 1';
+ }
+ if ( ! empty( $args['in_stock'] ) ) {
+ $where[] = 'fulfillable_qty > 0';
+ }
+
+ $where_sql = implode( ' AND ', $where );
+
+ // Total count.
+ $count_sql = "SELECT COUNT(*) FROM {$this->table} WHERE {$where_sql}";
+ $total = (int) $wpdb->get_var(
+ empty( $params ) ? $count_sql : $wpdb->prepare( $count_sql, ...$params )
+ );
+
+ // Page query.
+ $page_sql = "SELECT * FROM {$this->table} WHERE {$where_sql} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
+ $page_params = array_merge( $params, array( $per_page, $offset ) );
+ $rows = $wpdb->get_results(
+ $wpdb->prepare( $page_sql, ...$page_params ),
+ ARRAY_A
+ );
+ if ( ! is_array( $rows ) ) {
+ $this->logger->error( 'paginate query failed', array( 'last_error' => $wpdb->last_error ) );
+ $rows = array();
+ }
+
+ // Hydrate the in-request cache.
+ foreach ( $rows as $r ) {
+ $this->by_id[ (int) $r['id'] ] = $r;
+ $this->by_market_sku[ $r['market'] . '|' . $r['sku'] ] = $r;
+ }
+
+ return array(
+ 'rows' => $rows,
+ 'total' => $total,
+ 'pages' => (int) max( 1, ceil( $total / $per_page ) ),
+ );
+ }
+
+ /**
+ * Upsert from a sync payload. Returns the row id.
+ *
+ * @throws \RuntimeException on DB error.
+ */
+ public function upsert_from_sync( array $data ): int {
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+
+ $market = (string) ( $data['market'] ?? '' );
+ $sku = (string) ( $data['sku'] ?? '' );
+ if ( '' === $market || '' === $sku ) {
+ throw new \RuntimeException( 'upsert_from_sync requires market and sku.' );
+ }
+
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$this->table}
+ (market, sku, asin, product_name, image_url, fulfillable_qty, reserved_qty, inbound_qty,
+ buybox_price, buybox_currency, buybox_is_mine, lowest_price, my_price,
+ last_sync_at, last_priced_at)
+ VALUES (%s, %s, %s, %s, %s, %d, %d, %d, %s, %s, %d, %s, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE
+ asin = VALUES(asin),
+ product_name = IF(VALUES(product_name) = '', product_name, VALUES(product_name)),
+ image_url = IF(VALUES(image_url) = '', image_url, VALUES(image_url)),
+ fulfillable_qty = VALUES(fulfillable_qty),
+ reserved_qty = VALUES(reserved_qty),
+ inbound_qty = VALUES(inbound_qty),
+ buybox_price = COALESCE(VALUES(buybox_price), buybox_price),
+ buybox_currency = IF(VALUES(buybox_currency) = '', buybox_currency, VALUES(buybox_currency)),
+ buybox_is_mine = VALUES(buybox_is_mine),
+ lowest_price = COALESCE(VALUES(lowest_price), lowest_price),
+ my_price = COALESCE(VALUES(my_price), my_price),
+ last_sync_at = VALUES(last_sync_at),
+ last_priced_at = COALESCE(VALUES(last_priced_at), last_priced_at)",
+ $market,
+ $sku,
+ (string) ( $data['asin'] ?? '' ),
+ (string) ( $data['product_name'] ?? '' ),
+ (string) ( $data['image_url'] ?? '' ),
+ (int) ( $data['fulfillable_qty'] ?? 0 ),
+ (int) ( $data['reserved_qty'] ?? 0 ),
+ (int) ( $data['inbound_qty'] ?? 0 ),
+ isset( $data['buybox_price'] ) ? (string) $data['buybox_price'] : null,
+ (string) ( $data['buybox_currency'] ?? '' ),
+ (int) ( $data['buybox_is_mine'] ?? 0 ),
+ isset( $data['lowest_price'] ) ? (string) $data['lowest_price'] : null,
+ isset( $data['my_price'] ) ? (string) $data['my_price'] : null,
+ $now,
+ isset( $data['buybox_price'] ) ? $now : null
+ )
+ );
+
+ if ( false === $result ) {
+ $err = $wpdb->last_error;
+ $this->logger->error( 'upsert_from_sync DB error', array( 'sku' => $sku, 'market' => $market, 'err' => $err ) );
+ throw new \RuntimeException( "DB upsert failed: {$err}" );
+ }
+
+ // Reset cache for this row.
+ unset( $this->by_market_sku[ $market . '|' . $sku ] );
+ $row = $this->find_by_sku( $market, $sku );
+ return $row ? (int) $row['id'] : 0;
+ }
+
+ public function add_manual( array $data ): int {
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+ $market = strtoupper( (string) ( $data['market'] ?? 'US' ) );
+ $sku = (string) ( $data['sku'] ?? '' );
+ if ( '' === $sku ) {
+ throw new \RuntimeException( 'SKU required.' );
+ }
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$this->table}
+ (market, sku, asin, product_name, image_url, cost_price, fulfillable_qty, last_sync_at)
+ VALUES (%s, %s, %s, %s, %s, %s, 0, %s)
+ ON DUPLICATE KEY UPDATE
+ asin = VALUES(asin),
+ product_name = VALUES(product_name),
+ image_url = VALUES(image_url),
+ cost_price = VALUES(cost_price)",
+ $market,
+ $sku,
+ (string) ( $data['asin'] ?? '' ),
+ (string) ( $data['product_name'] ?? '' ),
+ (string) ( $data['image_url'] ?? '' ),
+ isset( $data['cost_price'] ) ? (string) $data['cost_price'] : null,
+ $now
+ )
+ );
+ if ( false === $result ) {
+ throw new \RuntimeException( 'add_manual DB error: ' . $wpdb->last_error );
+ }
+ unset( $this->by_market_sku[ $market . '|' . $sku ] );
+ $row = $this->find_by_sku( $market, $sku );
+ return $row ? (int) $row['id'] : 0;
+ }
+
+ public function update_cost( int $id, float $cost ): bool {
+ global $wpdb;
+ $result = $wpdb->update( $this->table, array( 'cost_price' => $cost ), array( 'id' => $id ), array( '%f' ), array( '%d' ) );
+ unset( $this->by_id[ $id ] );
+ return false !== $result;
+ }
+
+ public function update_my_price( int $id, float $price ): bool {
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+ $result = $wpdb->update(
+ $this->table,
+ array( 'my_price' => $price, 'last_priced_at' => $now ),
+ array( 'id' => $id ),
+ array( '%f', '%s' ),
+ array( '%d' )
+ );
+ unset( $this->by_id[ $id ] );
+ return false !== $result;
+ }
+
+ public function delete( int $id ): bool {
+ global $wpdb;
+ $row = $this->find( $id );
+ $result = $wpdb->delete( $this->table, array( 'id' => $id ), array( '%d' ) );
+ if ( $row ) {
+ unset( $this->by_id[ $id ], $this->by_market_sku[ $row['market'] . '|' . $row['sku'] ] );
+ }
+ return false !== $result;
+ }
+
+ /* -------- Aggregates for dashboard -------- */
+
+ public function count_total(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table}" );
+ }
+
+ public function count_in_stock(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table} WHERE fulfillable_qty > 0" );
+ }
+
+ public function count_winning_buybox(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table} WHERE buybox_is_mine = 1" );
+ }
+
+ public function last_sync_at(): ?string {
+ global $wpdb;
+ $v = $wpdb->get_var( "SELECT MAX(last_sync_at) FROM {$this->table}" );
+ return $v ? (string) $v : null;
+ }
+
+ public function counts_by_market(): array {
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ "SELECT market, COUNT(*) AS c FROM {$this->table} GROUP BY market ORDER BY market",
+ ARRAY_A
+ );
+ $out = array();
+ foreach ( (array) $rows as $r ) {
+ $out[ $r['market'] ] = (int) $r['c'];
+ }
+ return $out;
+ }
+}
diff --git a/sg-commerce-pro/src/Database/Repositories/SettingsRepository.php b/sg-commerce-pro/src/Database/Repositories/SettingsRepository.php
new file mode 100644
index 0000000..5daa5ae
--- /dev/null
+++ b/sg-commerce-pro/src/Database/Repositories/SettingsRepository.php
@@ -0,0 +1,140 @@
+'] one row per encrypted secret
+ *
+ * Why two stores? Secrets are read rarely and never autoload, while general
+ * settings autoload once per request. Splitting avoids decrypting unused
+ * secrets on every page view.
+ *
+ * @package SevenGum\Commerce\Database\Repositories
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Database\Repositories;
+
+use SevenGum\Commerce\Logging\Logger;
+use SevenGum\Commerce\Security\Encryption;
+
+defined( 'ABSPATH' ) || exit;
+
+final class SettingsRepository {
+
+ private const GENERAL_KEY = 'sg_commerce_settings';
+ private const SECRET_PREFIX = 'sg_commerce_secret_';
+
+ private const SECRETS = array(
+ 'amazon_client_id',
+ 'amazon_client_secret',
+ 'amazon_refresh_token',
+ 'amazon_seller_id',
+ 'openai_api_key',
+ 'anthropic_api_key',
+ 'ollama_auth_token',
+ // v4.0 — Amazon Ads API (Seller Suite PPC).
+ 'ads_client_id',
+ 'ads_client_secret',
+ 'ads_refresh_token',
+ );
+
+ private ?array $cache = null;
+
+ public function __construct(
+ private Encryption $encryption,
+ private Logger $logger,
+ ) {}
+
+ public function get( string $key, mixed $default = null ): mixed {
+ if ( in_array( $key, self::SECRETS, true ) ) {
+ return $this->get_secret( $key, $default );
+ }
+ $settings = $this->all();
+ return $settings[ $key ] ?? $default;
+ }
+
+ public function set( string $key, mixed $value ): bool {
+ if ( in_array( $key, self::SECRETS, true ) ) {
+ return $this->set_secret( $key, (string) $value );
+ }
+ $settings = $this->all();
+ $settings[ $key ] = $value;
+ $this->cache = $settings;
+ return update_option( self::GENERAL_KEY, $settings, true );
+ }
+
+ public function has( string $key ): bool {
+ if ( in_array( $key, self::SECRETS, true ) ) {
+ $raw = get_option( self::SECRET_PREFIX . $key, '' );
+ return is_string( $raw ) && '' !== $raw;
+ }
+ $settings = $this->all();
+ return isset( $settings[ $key ] ) && '' !== $settings[ $key ] && array() !== $settings[ $key ];
+ }
+
+ public function delete( string $key ): bool {
+ if ( in_array( $key, self::SECRETS, true ) ) {
+ return delete_option( self::SECRET_PREFIX . $key );
+ }
+ $settings = $this->all();
+ unset( $settings[ $key ] );
+ $this->cache = $settings;
+ return update_option( self::GENERAL_KEY, $settings, true );
+ }
+
+ /** All non-secret settings as an associative array. */
+ public function all(): array {
+ if ( null === $this->cache ) {
+ $raw = get_option( self::GENERAL_KEY, array() );
+ $this->cache = is_array( $raw ) ? $raw : array();
+ }
+ return $this->cache;
+ }
+
+ /** Mask a secret for safe display in admin forms. */
+ public function mask( string $key ): string {
+ return $this->has( $key ) ? '●●●●●●●● (saved — leave blank to keep)' : '';
+ }
+
+ /** Bulk update non-secret settings (single DB write). */
+ public function update_many( array $values ): bool {
+ $settings = $this->all();
+ foreach ( $values as $k => $v ) {
+ if ( in_array( $k, self::SECRETS, true ) ) {
+ continue; // secrets must use set() individually
+ }
+ $settings[ $k ] = $v;
+ }
+ $this->cache = $settings;
+ return update_option( self::GENERAL_KEY, $settings, true );
+ }
+
+ private function get_secret( string $key, mixed $default ): mixed {
+ $raw = get_option( self::SECRET_PREFIX . $key, '' );
+ if ( ! is_string( $raw ) || '' === $raw ) {
+ return $default;
+ }
+ try {
+ return $this->encryption->decrypt( $raw );
+ } catch ( \Throwable $e ) {
+ $this->logger->error( "Failed to decrypt secret '{$key}': " . $e->getMessage() );
+ return $default;
+ }
+ }
+
+ private function set_secret( string $key, string $value ): bool {
+ if ( '' === $value ) {
+ return delete_option( self::SECRET_PREFIX . $key );
+ }
+ try {
+ $encrypted = $this->encryption->encrypt( $value );
+ } catch ( \Throwable $e ) {
+ $this->logger->error( "Failed to encrypt secret '{$key}': " . $e->getMessage() );
+ return false;
+ }
+ return update_option( self::SECRET_PREFIX . $key, $encrypted, false );
+ }
+}
diff --git a/sg-commerce-pro/src/Database/Schema.php b/sg-commerce-pro/src/Database/Schema.php
new file mode 100644
index 0000000..e194292
--- /dev/null
+++ b/sg-commerce-pro/src/Database/Schema.php
@@ -0,0 +1,328 @@
+get_charset_collate();
+ $prefix = $wpdb->prefix;
+
+ // Products — primary inventory table.
+ dbDelta( "CREATE TABLE {$prefix}sg_products (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ sku VARCHAR(64) NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ product_name VARCHAR(255) DEFAULT '' NOT NULL,
+ image_url VARCHAR(512) DEFAULT '' NOT NULL,
+ fulfillable_qty INT DEFAULT 0 NOT NULL,
+ reserved_qty INT DEFAULT 0 NOT NULL,
+ inbound_qty INT DEFAULT 0 NOT NULL,
+ buybox_price DECIMAL(10,2) DEFAULT NULL,
+ buybox_currency VARCHAR(3) DEFAULT '' NOT NULL,
+ buybox_is_mine TINYINT(1) DEFAULT 0 NOT NULL,
+ lowest_price DECIMAL(10,2) DEFAULT NULL,
+ my_price DECIMAL(10,2) DEFAULT NULL,
+ cost_price DECIMAL(10,2) DEFAULT NULL,
+ last_sync_at DATETIME DEFAULT NULL,
+ last_priced_at DATETIME DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY market_sku (market, sku),
+ KEY asin (asin),
+ KEY market_qty (market, fulfillable_qty),
+ KEY last_sync (last_sync_at)
+ ) {$charset};" );
+
+ // Price history — append-only time series for analytics + repricer.
+ dbDelta( "CREATE TABLE {$prefix}sg_price_history (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ product_id BIGINT UNSIGNED NOT NULL,
+ recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ my_price DECIMAL(10,2) DEFAULT NULL,
+ buybox_price DECIMAL(10,2) DEFAULT NULL,
+ lowest_price DECIMAL(10,2) DEFAULT NULL,
+ currency VARCHAR(3) DEFAULT '' NOT NULL,
+ buybox_is_mine TINYINT(1) DEFAULT 0 NOT NULL,
+ fulfillable_qty INT DEFAULT 0 NOT NULL,
+ source VARCHAR(20) DEFAULT 'sync' NOT NULL,
+ PRIMARY KEY (id),
+ KEY product_time (product_id, recorded_at),
+ KEY recorded (recorded_at)
+ ) {$charset};" );
+
+ // Descriptions — one row per (product_id, language).
+ dbDelta( "CREATE TABLE {$prefix}sg_descriptions (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ product_id BIGINT UNSIGNED NOT NULL,
+ language VARCHAR(8) NOT NULL,
+ content LONGTEXT NOT NULL,
+ provider VARCHAR(32) DEFAULT 'ollama' NOT NULL,
+ model VARCHAR(64) DEFAULT '' NOT NULL,
+ temperature DECIMAL(3,2) DEFAULT NULL,
+ tokens INT DEFAULT 0 NOT NULL,
+ generation_ms INT DEFAULT 0 NOT NULL,
+ generated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY product_lang (product_id, language),
+ KEY language (language)
+ ) {$charset};" );
+
+ // Competitors — ASINs we monitor in addition to our own SKUs.
+ dbDelta( "CREATE TABLE {$prefix}sg_competitors (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ asin VARCHAR(20) NOT NULL,
+ seller_name VARCHAR(255) DEFAULT '' NOT NULL,
+ product_name VARCHAR(255) DEFAULT '' NOT NULL,
+ our_product_id BIGINT UNSIGNED DEFAULT NULL,
+ last_price DECIMAL(10,2) DEFAULT NULL,
+ currency VARCHAR(3) DEFAULT '' NOT NULL,
+ has_buybox TINYINT(1) DEFAULT 0 NOT NULL,
+ last_scanned_at DATETIME DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY market_asin (market, asin),
+ KEY our_product (our_product_id),
+ KEY scanned (last_scanned_at)
+ ) {$charset};" );
+
+ // Rules — automation engine.
+ dbDelta( "CREATE TABLE {$prefix}sg_rules (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(120) NOT NULL,
+ trigger_event VARCHAR(64) NOT NULL,
+ conditions LONGTEXT NOT NULL,
+ actions LONGTEXT NOT NULL,
+ is_active TINYINT(1) DEFAULT 1 NOT NULL,
+ run_count INT DEFAULT 0 NOT NULL,
+ last_run_at DATETIME DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY trigger_active (trigger_event, is_active)
+ ) {$charset};" );
+
+ // Alerts — admin notification feed.
+ dbDelta( "CREATE TABLE {$prefix}sg_alerts (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ level VARCHAR(16) DEFAULT 'info' NOT NULL,
+ category VARCHAR(32) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ message TEXT,
+ context LONGTEXT,
+ product_id BIGINT UNSIGNED DEFAULT NULL,
+ is_read TINYINT(1) DEFAULT 0 NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ KEY unread (is_read, created_at),
+ KEY category (category, created_at)
+ ) {$charset};" );
+
+ // Audit — append-only sensitive operation log.
+ dbDelta( "CREATE TABLE {$prefix}sg_audit (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ user_id BIGINT UNSIGNED DEFAULT 0 NOT NULL,
+ action VARCHAR(64) NOT NULL,
+ entity_type VARCHAR(32) DEFAULT '' NOT NULL,
+ entity_id VARCHAR(64) DEFAULT '' NOT NULL,
+ details LONGTEXT,
+ ip_address VARCHAR(45) DEFAULT '' NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ KEY user_time (user_id, created_at),
+ KEY action_time (action, created_at)
+ ) {$charset};" );
+
+ // MCF orders — Multi-Channel Fulfillment orders created through Amazon FBA
+ // but shipped to end customers outside of Amazon (e.g. our own storefront).
+ dbDelta( "CREATE TABLE {$prefix}sg_mcf_orders (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ seller_fulfillment_order_id VARCHAR(40) NOT NULL,
+ amazon_order_id VARCHAR(40) DEFAULT '' NOT NULL,
+ market VARCHAR(2) NOT NULL,
+ displayable_order_id VARCHAR(40) DEFAULT '' NOT NULL,
+ customer_name VARCHAR(200) DEFAULT '' NOT NULL,
+ customer_email VARCHAR(190) DEFAULT '' NOT NULL,
+ country_code VARCHAR(2) DEFAULT '' NOT NULL,
+ address_line1 VARCHAR(200) DEFAULT '' NOT NULL,
+ address_line2 VARCHAR(200) DEFAULT '' NOT NULL,
+ city VARCHAR(100) DEFAULT '' NOT NULL,
+ state_region VARCHAR(100) DEFAULT '' NOT NULL,
+ postal_code VARCHAR(30) DEFAULT '' NOT NULL,
+ phone VARCHAR(40) DEFAULT '' NOT NULL,
+ items LONGTEXT NOT NULL,
+ shipping_speed VARCHAR(20) DEFAULT 'Standard' NOT NULL,
+ status VARCHAR(40) DEFAULT 'new' NOT NULL,
+ amazon_status VARCHAR(60) DEFAULT '' NOT NULL,
+ tracking_numbers TEXT,
+ carrier_code VARCHAR(40) DEFAULT '' NOT NULL,
+ estimated_delivery_date DATETIME DEFAULT NULL,
+ submitted_at DATETIME DEFAULT NULL,
+ shipped_at DATETIME DEFAULT NULL,
+ delivered_at DATETIME DEFAULT NULL,
+ error_message TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY seller_order (seller_fulfillment_order_id),
+ KEY status (status, created_at),
+ KEY market (market, created_at)
+ ) {$charset};" );
+
+ // Comparison sets — reusable sku bundles for comparison tables.
+ dbDelta( "CREATE TABLE {$prefix}sg_comparisons (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ slug VARCHAR(60) NOT NULL,
+ title VARCHAR(200) DEFAULT '' NOT NULL,
+ skus LONGTEXT NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ features LONGTEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY slug (slug)
+ ) {$charset};" );
+
+ // Injection rules — auto-link SKU mentions in post_content.
+ dbDelta( "CREATE TABLE {$prefix}sg_injection_rules (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ pattern VARCHAR(200) NOT NULL,
+ pattern_type VARCHAR(20) DEFAULT 'keyword' NOT NULL,
+ sku VARCHAR(64) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ render_as VARCHAR(20) DEFAULT 'link' NOT NULL,
+ priority INT DEFAULT 10 NOT NULL,
+ max_per_post INT DEFAULT 1 NOT NULL,
+ is_active TINYINT(1) DEFAULT 1 NOT NULL,
+ hit_count INT DEFAULT 0 NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ KEY active_priority (is_active, priority)
+ ) {$charset};" );
+
+ // Settlements — financial events parsed from Amazon settlement reports.
+ // Used by Analytics to compute TRUE profit (after real FBA storage fees,
+ // referral fees, advertising, refunds, adjustments).
+ dbDelta( "CREATE TABLE {$prefix}sg_settlements (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ event_date DATE DEFAULT NULL,
+ event_type VARCHAR(40) NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ order_id VARCHAR(40) DEFAULT '' NOT NULL,
+ marketplace VARCHAR(40) DEFAULT '' NOT NULL,
+ amount DECIMAL(12,4) DEFAULT 0 NOT NULL,
+ currency VARCHAR(5) DEFAULT '' NOT NULL,
+ description VARCHAR(190) DEFAULT '' NOT NULL,
+ settlement_id VARCHAR(40) DEFAULT '' NOT NULL,
+ imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ KEY date_type (event_date, event_type),
+ KEY sku_date (sku, event_date),
+ KEY settlement (settlement_id)
+ ) {$charset};" );
+
+ // Inbound plans — Send to Amazon (STA) shipments from factory to FBA.
+ // Persists local state of the multi-step workflow so operators can
+ // resume a plan that's been in progress across multiple sessions.
+ dbDelta( "CREATE TABLE {$prefix}sg_inbound_plans (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ amazon_plan_id VARCHAR(60) DEFAULT '' NOT NULL,
+ name VARCHAR(200) NOT NULL,
+ market VARCHAR(2) NOT NULL,
+ status VARCHAR(40) DEFAULT 'draft' NOT NULL,
+ source_address LONGTEXT,
+ items LONGTEXT,
+ cartons LONGTEXT,
+ selected_packing_option_id VARCHAR(100) DEFAULT '' NOT NULL,
+ selected_placement_option_id VARCHAR(100) DEFAULT '' NOT NULL,
+ total_cartons INT DEFAULT 0 NOT NULL,
+ total_units INT DEFAULT 0 NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP,
+ confirmed_at DATETIME DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY amazon_plan (amazon_plan_id),
+ KEY status (status, updated_at)
+ ) {$charset};" );
+
+ // Product EAN map — associate our SKUs with the EAN-13 on the bottle
+ // AND the Amazon ASIN (if catalog lookup found a match). Populated
+ // when an operator creates/imports a listing; read by LabelGenerator
+ // and BoxContentService.
+ dbDelta( "CREATE TABLE {$prefix}sg_product_ean (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ sku VARCHAR(64) NOT NULL,
+ ean13 VARCHAR(13) NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ market VARCHAR(2) NOT NULL,
+ product_name VARCHAR(200) DEFAULT '' NOT NULL,
+ catalog_status VARCHAR(20) DEFAULT 'unknown' NOT NULL,
+ listing_status VARCHAR(20) DEFAULT 'draft' NOT NULL,
+ last_checked_at DATETIME DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY sku_market (sku, market),
+ KEY ean (ean13),
+ KEY asin (asin)
+ ) {$charset};" );
+
+ update_option( 'sg_commerce_schema_version', self::SCHEMA_VERSION, false );
+ }
+
+ public static function maybe_upgrade(): void {
+ $current = (int) get_option( 'sg_commerce_schema_version', 0 );
+ if ( $current < self::SCHEMA_VERSION ) {
+ self::install();
+ }
+ }
+
+ /** All tables we own — used by uninstall.php. */
+ public static function tables(): array {
+ global $wpdb;
+ $prefix = $wpdb->prefix;
+ return array(
+ "{$prefix}sg_products",
+ "{$prefix}sg_price_history",
+ "{$prefix}sg_descriptions",
+ "{$prefix}sg_competitors",
+ "{$prefix}sg_rules",
+ "{$prefix}sg_alerts",
+ "{$prefix}sg_audit",
+ "{$prefix}sg_mcf_orders",
+ "{$prefix}sg_comparisons",
+ "{$prefix}sg_injection_rules",
+ "{$prefix}sg_settlements",
+ "{$prefix}sg_inbound_plans",
+ "{$prefix}sg_product_ean",
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Events/EventDispatcher.php b/sg-commerce-pro/src/Events/EventDispatcher.php
new file mode 100644
index 0000000..3057ae6
--- /dev/null
+++ b/sg-commerce-pro/src/Events/EventDispatcher.php
@@ -0,0 +1,66 @@
+ Registered event names (for introspection). */
+ private array $known = array();
+
+ /**
+ * Emit an event. Listeners receive the $payload array as their single
+ * argument. Listeners may mutate $payload (returned by reference).
+ */
+ public function emit( string $event, array $payload = array() ): array {
+ $this->known[ $event ] = true;
+ $payload = apply_filters( self::HOOK_PREFIX . $event, $payload );
+ do_action( self::HOOK_PREFIX . $event . '.after', $payload );
+ return $payload;
+ }
+
+ /**
+ * Subscribe to an event. Listeners receive the payload array.
+ *
+ * @param string $event
+ * @param callable(array): array|void $listener
+ * @param int $priority
+ */
+ public function on( string $event, callable $listener, int $priority = 10 ): void {
+ $this->known[ $event ] = true;
+ add_filter( self::HOOK_PREFIX . $event, $listener, $priority );
+ }
+
+ /**
+ * Subscribe to the post-emit signal (cannot mutate payload).
+ */
+ public function after( string $event, callable $listener, int $priority = 10 ): void {
+ $this->known[ $event ] = true;
+ add_action( self::HOOK_PREFIX . $event . '.after', $listener, $priority );
+ }
+
+ /** @return string[] */
+ public function list_events(): array {
+ return array_keys( $this->known );
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/BoxContent/BoxContentService.php b/sg-commerce-pro/src/Fulfillment/BoxContent/BoxContentService.php
new file mode 100644
index 0000000..af0df0c
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/BoxContent/BoxContentService.php
@@ -0,0 +1,197 @@
+ 3500, // how many of this carton type
+ * 'weight_kg' => 8.5,
+ * 'length_cm' => 42,
+ * 'width_cm' => 32,
+ * 'height_cm' => 26,
+ * 'contents' => [['msku' => 'SG-COOKIE-01', 'quantity' => 48], ...],
+ * 'template_id'=> 'cookie-48-pack',
+ * ]
+ */
+ public function build_package_groupings( array $cartons ): array {
+ $groupings = array();
+ foreach ( $cartons as $c ) {
+ $items = array();
+ foreach ( (array) ( $c['contents'] ?? array() ) as $content ) {
+ $items[] = array(
+ 'msku' => (string) ( $content['msku'] ?? '' ),
+ 'quantity' => (int) ( $content['quantity'] ?? 0 ),
+ 'expiration' => isset( $content['expiration'] ) ? (string) $content['expiration'] : null,
+ 'manufacturingLotCode' => isset( $content['lot'] ) ? (string) $content['lot'] : null,
+ );
+ }
+
+ $groupings[] = array(
+ 'name' => (string) ( $c['template_id'] ?? ( 'Carton-' . count( $groupings ) ) ),
+ 'packingGroupId' => (string) ( $c['packing_group_id'] ?? 'default' ),
+ 'boxes' => array(
+ array(
+ 'quantity' => (int) ( $c['quantity'] ?? 1 ),
+ 'weight' => array(
+ 'value' => (float) ( $c['weight_kg'] ?? 0 ),
+ 'unit' => 'KG',
+ ),
+ 'dimensions' => array(
+ 'length' => (float) ( $c['length_cm'] ?? 0 ),
+ 'width' => (float) ( $c['width_cm'] ?? 0 ),
+ 'height' => (float) ( $c['height_cm'] ?? 0 ),
+ 'unit' => 'CM',
+ ),
+ 'items' => $items,
+ ),
+ ),
+ );
+ }
+ return $groupings;
+ }
+
+ /** Push box content to Amazon for an inbound plan (Step 4 in STA flow). */
+ public function push_to_amazon( string $plan_id, array $cartons, string $market ): array {
+ $groupings = $this->build_package_groupings( $cartons );
+ $response = $this->inbound->set_packing_information( $plan_id, $groupings, $market );
+ $this->logger->info( 'Box content pushed to Amazon', array(
+ 'plan_id' => $plan_id,
+ 'carton_types' => count( $cartons ),
+ 'total_units' => array_sum( array_map( static function ( $c ) {
+ return array_sum( array_column( (array) ( $c['contents'] ?? array() ), 'quantity' ) )
+ * (int) ( $c['quantity'] ?? 1 );
+ }, $cartons ) ),
+ ) );
+ return $response;
+ }
+
+ /* ---------------- Local carton templates ---------------- */
+
+ /**
+ * Persist a standard carton profile so operators don't have to re-enter
+ * dimensions for recurring shipment configurations.
+ */
+ public function save_template( string $id, array $template ): void {
+ $all = $this->get_templates();
+ $template['id'] = $id;
+ $template['updated_at'] = gmdate( 'c' );
+ $all[ $id ] = $template;
+ update_option( self::TEMPLATE_OPTION, $all, false );
+ }
+
+ public function get_templates(): array {
+ $all = get_option( self::TEMPLATE_OPTION, array() );
+ return is_array( $all ) ? $all : array();
+ }
+
+ public function get_template( string $id ): ?array {
+ $all = $this->get_templates();
+ return $all[ $id ] ?? null;
+ }
+
+ public function delete_template( string $id ): void {
+ $all = $this->get_templates();
+ unset( $all[ $id ] );
+ update_option( self::TEMPLATE_OPTION, $all, false );
+ }
+
+ /**
+ * Sanity-check a template against Amazon's limits.
+ * FBA caps: max 68 kg per carton, max 63.5 × 63.5 × 63.5 cm per box
+ * (or max single dimension 150 cm).
+ */
+ public function validate_template( array $template ): array {
+ $errors = array();
+ $w = (float) ( $template['weight_kg'] ?? 0 );
+ $l = (float) ( $template['length_cm'] ?? 0 );
+ $wd = (float) ( $template['width_cm'] ?? 0 );
+ $h = (float) ( $template['height_cm'] ?? 0 );
+
+ if ( $w <= 0 ) $errors[] = 'Weight must be positive.';
+ if ( $w > 30 ) $errors[] = 'Weight exceeds 30kg — many FBA centers require lifting assist.';
+ if ( $w > 68 ) $errors[] = 'CRITICAL: weight exceeds Amazon 68kg ceiling.';
+ foreach ( array( 'length' => $l, 'width' => $wd, 'height' => $h ) as $name => $v ) {
+ if ( $v <= 0 ) $errors[] = ucfirst( $name ) . ' must be positive.';
+ if ( $v > 150 ) $errors[] = ucfirst( $name ) . ' exceeds 150cm ceiling.';
+ }
+ $contents = (array) ( $template['contents'] ?? array() );
+ if ( empty( $contents ) ) $errors[] = 'Template needs at least one SKU.';
+ return $errors;
+ }
+
+ /**
+ * Seed Seven Gum's known master carton template on first activation.
+ * Called from Activator so operators don't have to re-enter these
+ * physical measurements for every new inbound plan.
+ *
+ * Source: factory spec sheet (Guangzhou, Apr 2026).
+ * - 33 × 9.5 × 22.5 cm exterior
+ * - Net weight 1.392 kg (product only)
+ * - Gross weight 2.1 kg (with packaging)
+ * - Contents: 4 inner boxes × 6 packs = 24 packs per master carton
+ * - Each pack carries the EAN-13 printed on its label.
+ */
+ public static function seed_default_template(): void {
+ $templates = get_option( self::TEMPLATE_OPTION, array() );
+ if ( ! is_array( $templates ) ) $templates = array();
+ if ( isset( $templates['sg-master-24pack'] ) ) {
+ return; // Already seeded.
+ }
+ $templates['sg-master-24pack'] = array(
+ 'id' => 'sg-master-24pack',
+ 'name' => 'Seven Gum master carton — 4 inner × 6 packs',
+ 'length_cm' => 33.0,
+ 'width_cm' => 9.5,
+ 'height_cm' => 22.5,
+ 'weight_kg' => 2.1, // gross weight (with packaging)
+ 'net_weight_kg'=> 1.392, // product only
+ 'packs_per_carton' => 24,
+ 'inner_boxes' => 4,
+ 'packs_per_inner_box' => 6,
+ 'origin' => 'Guangzhou, CN',
+ 'notes' => 'EAN-13 printed on each pack. Commingled inventory — no FNSKU labels required.',
+ 'created_at' => gmdate( 'c' ),
+ 'updated_at' => gmdate( 'c' ),
+ );
+ update_option( self::TEMPLATE_OPTION, $templates, false );
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/FulfillmentModule.php b/sg-commerce-pro/src/Fulfillment/FulfillmentModule.php
new file mode 100644
index 0000000..4d311f7
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/FulfillmentModule.php
@@ -0,0 +1,196 @@
+container;
+
+ $c->singleton( MCFOrderRepository::class, static fn( Container $c ) =>
+ new MCFOrderRepository( $c->get( Logger::class ) )
+ );
+ $c->singleton( MCFClient::class, static fn( Container $c ) =>
+ new MCFClient(
+ $c->get( AmazonClient::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ // Cron: refresh tracking for pending MCF orders.
+ add_action( 'sg_commerce_mcf_refresh', array( $this, 'refresh_pending_tracking' ) );
+
+ // Register a 10-minute cron if not already.
+ if ( ! wp_next_scheduled( 'sg_commerce_mcf_refresh' ) ) {
+ wp_schedule_event( time() + 300, 'sg_fifteen_minutes', 'sg_commerce_mcf_refresh' );
+ }
+
+ // Public action other plugins can fire.
+ add_action( 'sg_commerce_fulfill', array( $this, 'handle_fulfill_action' ), 10, 1 );
+
+ // WooCommerce integration — only attaches if WC is active AND auto-fulfill is on.
+ add_action( 'woocommerce_order_status_processing', array( $this, 'maybe_fulfill_wc_order' ), 10, 1 );
+ }
+
+ public function refresh_pending_tracking(): void {
+ if ( ! $this->container->get( AmazonClient::class )->has_credentials() ) {
+ return;
+ }
+ $repo = $this->container->get( MCFOrderRepository::class );
+ $client = $this->container->get( MCFClient::class );
+ $logger = $this->container->get( Logger::class );
+
+ foreach ( $repo->pending_tracking_refresh() as $row ) {
+ try {
+ $info = $client->get( (string) $row['market'], (string) $row['seller_fulfillment_order_id'] );
+ $repo->update_tracking( (int) $row['id'], $info );
+ } catch ( \Throwable $e ) {
+ $logger->warning( 'MCF tracking refresh failed', array(
+ 'id' => (int) $row['id'],
+ 'err' => $e->getMessage(),
+ ) );
+ }
+ }
+ }
+
+ /**
+ * Generic action entry. Payload shape:
+ * [
+ * 'displayable_order_id' => 'WEB-12345',
+ * 'market' => 'US',
+ * 'customer' => ['name'=>..., 'email'=>..., 'phone'=>...],
+ * 'address' => ['line1'=>..., 'city'=>..., 'country_code'=>...],
+ * 'items' => [ ['sku'=>..., 'quantity'=>...], ... ],
+ * 'shipping_speed' => 'Standard',
+ * 'comment' => '',
+ * ]
+ */
+ public function handle_fulfill_action( array $payload ): ?array {
+ try {
+ $repo = $this->container->get( MCFOrderRepository::class );
+ $client = $this->container->get( MCFClient::class );
+
+ $seller_order_id = 'SG-' . wp_generate_uuid4();
+ $request = new MCFOrderRequest(
+ seller_fulfillment_order_id: $seller_order_id,
+ displayable_order_id: (string) ( $payload['displayable_order_id'] ?? $seller_order_id ),
+ market: (string) ( $payload['market'] ?? 'US' ),
+ customer: (array) ( $payload['customer'] ?? array() ),
+ address: (array) ( $payload['address'] ?? array() ),
+ items: (array) ( $payload['items'] ?? array() ),
+ shipping_speed: (string) ( $payload['shipping_speed'] ?? 'Standard' ),
+ comment: (string) ( $payload['comment'] ?? '' ),
+ notification_email: (string) ( $payload['notification_email'] ?? get_option( 'admin_email', '' ) ),
+ );
+
+ $id = $repo->insert( $request );
+ try {
+ $client->create( $request );
+ $repo->mark_submitted( $id );
+ return array( 'id' => $id, 'seller_order_id' => $seller_order_id );
+ } catch ( \Throwable $e ) {
+ $repo->mark_failed( $id, $e->getMessage() );
+ throw $e;
+ }
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->error( 'MCF fulfillment failed: ' . $e->getMessage() );
+ return null;
+ }
+ }
+
+ /**
+ * WooCommerce: when a WC order moves to processing, optionally submit
+ * to Amazon MCF. Only active when the `mcf_auto_wc` setting is on.
+ */
+ public function maybe_fulfill_wc_order( int $order_id ): void {
+ $settings = $this->container->get( SettingsRepository::class );
+ if ( ! $settings->get( 'mcf_auto_wc', false ) ) {
+ return;
+ }
+ if ( ! function_exists( 'wc_get_order' ) ) {
+ return;
+ }
+ $order = wc_get_order( $order_id );
+ if ( ! $order ) {
+ return;
+ }
+
+ // Avoid duplicate fulfillment.
+ if ( $order->get_meta( '_sg_mcf_order_id' ) ) {
+ return;
+ }
+
+ $items = array();
+ foreach ( $order->get_items() as $line ) {
+ $product = $line->get_product();
+ if ( ! $product ) continue;
+ $items[] = array(
+ 'sku' => (string) $product->get_sku(),
+ 'quantity' => (int) $line->get_quantity(),
+ );
+ }
+
+ $payload = array(
+ 'displayable_order_id' => 'WC-' . $order_id,
+ 'market' => (string) $settings->get( 'mcf_default_market', 'US' ),
+ 'customer' => array(
+ 'name' => trim( $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name() ),
+ 'email' => (string) $order->get_billing_email(),
+ 'phone' => (string) $order->get_billing_phone(),
+ ),
+ 'address' => array(
+ 'line1' => (string) $order->get_shipping_address_1(),
+ 'line2' => (string) $order->get_shipping_address_2(),
+ 'city' => (string) $order->get_shipping_city(),
+ 'state_region' => (string) $order->get_shipping_state(),
+ 'postal_code' => (string) $order->get_shipping_postcode(),
+ 'country_code' => (string) $order->get_shipping_country(),
+ ),
+ 'items' => $items,
+ 'shipping_speed' => (string) $settings->get( 'mcf_default_speed', 'Standard' ),
+ 'comment' => 'Order from ' . get_bloginfo( 'name' ),
+ );
+
+ $result = $this->handle_fulfill_action( $payload );
+ if ( null !== $result ) {
+ $order->update_meta_data( '_sg_mcf_order_id', $result['id'] );
+ $order->update_meta_data( '_sg_mcf_seller_order_id', $result['seller_order_id'] );
+ $order->add_order_note( sprintf( 'Seven Gum MCF: submitted to Amazon as %s', $result['seller_order_id'] ) );
+ $order->save();
+ } else {
+ $order->add_order_note( 'Seven Gum MCF: submission failed — check logs.' );
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/LabelGenerator/LabelGenerator.php b/sg-commerce-pro/src/Fulfillment/LabelGenerator/LabelGenerator.php
new file mode 100644
index 0000000..bea68d2
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/LabelGenerator/LabelGenerator.php
@@ -0,0 +1,187 @@
+-U" + destination warehouse + barcode
+ * - Amazon returns the label PDF from the Inbound Shipments API, OR
+ * we can generate matching 4"x6" thermal-printer labels locally.
+ *
+ * B. Pallet Labels
+ * - When shipping pallet-in rather than floor-loaded
+ * - Shows plan/shipment IDs and pallet count
+ *
+ * For v3.3 we use TCPDF (bundled with WordPress via its fallback chain,
+ * or we fall back to a lightweight PDF builder that emits raw PDF bytes
+ * without any dependency).
+ *
+ * This class generates LOCAL labels that match the spec. For the actual
+ * Amazon-authoritative labels, use InboundShipmentClient::get_labels()
+ * which returns official PDFs from Amazon.
+ *
+ * Output: 4" × 6" (101.6 × 152.4 mm) standard FBA thermal label size.
+ *
+ * @package SevenGum\Commerce\Fulfillment\LabelGenerator
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Fulfillment\LabelGenerator;
+
+use SevenGum\Commerce\Logging\Logger;
+
+defined( 'ABSPATH' ) || exit;
+
+final class LabelGenerator {
+
+ /** Standard FBA thermal label: 4" × 6" at 72dpi. */
+ private const WIDTH_PT = 288; // 4 inches
+ private const HEIGHT_PT = 432; // 6 inches
+
+ public function __construct( private Logger $logger ) {}
+
+ /**
+ * Generate a set of box labels for a shipment. Returns raw PDF bytes.
+ *
+ * @param array $boxes Each element:
+ * [
+ * 'box_id' => 'FBA17XYZABC-U00001', // Amazon-assigned
+ * 'plan_name' => 'SG-2026-Q2-Guangzhou',
+ * 'ship_to' => ['name'=>, 'address1'=>, 'city'=>, 'state'=>, 'postal'=>, 'country'=>],
+ * 'sku_contents' => [['sku'=>, 'quantity'=>], ...],
+ * 'weight_kg' => 12.5,
+ * 'dimensions' => ['length_cm'=>40, 'width_cm'=>30, 'height_cm'=>25],
+ * ]
+ */
+ public function generate_box_labels( array $boxes ): string {
+ if ( empty( $boxes ) ) {
+ throw new \InvalidArgumentException( 'At least one box required' );
+ }
+
+ $pdf = new PdfBuilder( self::WIDTH_PT, self::HEIGHT_PT );
+ foreach ( $boxes as $i => $box ) {
+ if ( $i > 0 ) $pdf->new_page();
+ $this->draw_box_label( $pdf, $box );
+ }
+ $bytes = $pdf->render();
+ $this->logger->info( 'Generated box labels', array( 'count' => count( $boxes ), 'bytes' => strlen( $bytes ) ) );
+ return $bytes;
+ }
+
+ public function generate_pallet_label( array $pallet ): string {
+ $pdf = new PdfBuilder( self::WIDTH_PT, self::HEIGHT_PT );
+ $pdf->text( 20, 20, 'PALLET LABEL', 18 );
+ $pdf->text( 20, 50, 'Plan: ' . (string) ( $pallet['plan_name'] ?? '' ), 11 );
+ $pdf->text( 20, 65, 'Shipment: ' . (string) ( $pallet['shipment_id'] ?? '' ), 11 );
+ $pdf->text( 20, 85, 'Pallet: ' . (string) ( $pallet['pallet_number'] ?? '' ) . ' of ' . (int) ( $pallet['pallet_total'] ?? 1 ), 14 );
+ $pdf->text( 20, 110, 'Total cartons: ' . (int) ( $pallet['total_cartons'] ?? 0 ), 11 );
+ $pdf->text( 20, 125, 'Total weight: ' . (string) ( $pallet['total_weight_kg'] ?? '' ) . ' kg', 11 );
+
+ $ship = (array) ( $pallet['ship_to'] ?? array() );
+ $pdf->text( 20, 165, 'SHIP TO', 9 );
+ $pdf->text( 20, 180, (string) ( $ship['name'] ?? '' ), 11 );
+ $pdf->text( 20, 195, (string) ( $ship['address1'] ?? '' ), 11 );
+ $pdf->text( 20, 210, (string) ( $ship['city'] ?? '' ) . ', ' . (string) ( $ship['state'] ?? '' ) . ' ' . (string) ( $ship['postal'] ?? '' ), 11 );
+ $pdf->text( 20, 225, (string) ( $ship['country'] ?? '' ), 11 );
+
+ // Code128 barcode of the plan.
+ $this->draw_code128( $pdf, 20, 260, 250, 60, (string) ( $pallet['plan_name'] ?? 'UNKNOWN' ) );
+
+ return $pdf->render();
+ }
+
+ private function draw_box_label( PdfBuilder $pdf, array $box ): void {
+ $box_id = (string) ( $box['box_id'] ?? 'UNKNOWN' );
+ $plan_name = (string) ( $box['plan_name'] ?? '' );
+ $ship = (array) ( $box['ship_to'] ?? array() );
+ $contents = (array) ( $box['sku_contents'] ?? array() );
+ $weight = (string) ( $box['weight_kg'] ?? '' );
+ $dims = (array) ( $box['dimensions'] ?? array() );
+
+ // Title
+ $pdf->text( 20, 20, 'FBA BOX ID', 11 );
+
+ // Large box ID
+ $pdf->text( 20, 42, $box_id, 20 );
+
+ // Plan + destination
+ $pdf->text( 20, 75, 'Plan: ' . $plan_name, 9 );
+ $pdf->text( 20, 90, 'SHIP TO:', 9 );
+ $pdf->text( 20, 105, (string) ( $ship['name'] ?? '' ), 12 );
+ $pdf->text( 20, 120, (string) ( $ship['address1'] ?? '' ), 10 );
+ $pdf->text( 20, 134, (string) ( $ship['city'] ?? '' ) . ', ' . (string) ( $ship['state'] ?? '' ), 10 );
+ $pdf->text( 20, 148, (string) ( $ship['postal'] ?? '' ) . ' ' . (string) ( $ship['country'] ?? '' ), 10 );
+
+ // Horizontal rule
+ $pdf->rule( 20, 165, self::WIDTH_PT - 40 );
+
+ // Contents
+ $pdf->text( 20, 180, 'CONTENTS:', 9 );
+ $y = 195;
+ foreach ( array_slice( $contents, 0, 6 ) as $item ) {
+ $pdf->text( 20, $y,
+ (string) ( $item['sku'] ?? '' ) . ' × ' . (int) ( $item['quantity'] ?? 0 ),
+ 9 );
+ $y += 13;
+ }
+
+ // Weight + dimensions footer
+ $pdf->rule( 20, 275, self::WIDTH_PT - 40 );
+ $pdf->text( 20, 290, 'Weight: ' . $weight . ' kg', 9 );
+ $pdf->text( 20, 302,
+ 'Dims: ' .
+ (int) ( $dims['length_cm'] ?? 0 ) . ' × ' .
+ (int) ( $dims['width_cm'] ?? 0 ) . ' × ' .
+ (int) ( $dims['height_cm'] ?? 0 ) . ' cm',
+ 9 );
+
+ // Code128 barcode of the box ID
+ $this->draw_code128( $pdf, 20, 330, self::WIDTH_PT - 40, 70, $box_id );
+ }
+
+ /**
+ * Minimal Code128-B barcode renderer as black vertical bars on the PDF.
+ *
+ * This is NOT a full Code128 implementation — it renders fixed-width
+ * bars based on a simple hash of the input, which is readable by
+ * handheld scanners with forgiving modulation but is NOT the
+ * authoritative Amazon-generated label. For the authoritative version,
+ * use InboundShipmentClient::get_labels().
+ */
+ private function draw_code128( PdfBuilder $pdf, float $x, float $y, float $w, float $h, string $data ): void {
+ if ( '' === $data ) return;
+ $pattern = $this->code128_pattern( $data );
+ $bar_w = $w / strlen( $pattern );
+ $cx = $x;
+ for ( $i = 0; $i < strlen( $pattern ); $i++ ) {
+ if ( '1' === $pattern[ $i ] ) {
+ $pdf->rect_filled( $cx, $y, $bar_w, $h );
+ }
+ $cx += $bar_w;
+ }
+ // Human-readable text below
+ $pdf->text( $x, $y + $h + 10, $data, 8 );
+ }
+
+ private function code128_pattern( string $data ): string {
+ // Start B + payload + checksum + stop — simplified repeating encoding.
+ $out = '11010010000'; // Start B
+ $checksum = 104;
+ for ( $i = 0; $i < strlen( $data ); $i++ ) {
+ $c = ord( $data[ $i ] ) - 32;
+ if ( $c < 0 || $c > 94 ) $c = 0;
+ $checksum += ( $i + 1 ) * $c;
+ // Pick a simple 11-bit barcode pattern per char (dependent on char value parity).
+ $out .= ( $c % 2 === 0 ) ? '11011001100' : '11001101100';
+ }
+ $mod = $checksum % 103;
+ $out .= ( $mod % 2 === 0 ) ? '11011001100' : '11001101100';
+ $out .= '1100011101011'; // Stop
+ return $out;
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/LabelGenerator/PdfBuilder.php b/sg-commerce-pro/src/Fulfillment/LabelGenerator/PdfBuilder.php
new file mode 100644
index 0000000..0457a9f
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/LabelGenerator/PdfBuilder.php
@@ -0,0 +1,111 @@
+new_page();
+ }
+
+ public function new_page(): void {
+ if ( '' !== $this->current ) {
+ $this->pages[] = $this->current;
+ }
+ $this->current = '';
+ }
+
+ /** Place text at top-left coords $x,$y in the given font size. */
+ public function text( float $x, float $y, string $text, float $size = 10 ): void {
+ $pdf_y = $this->height - $y - $size;
+ $escaped = str_replace( array( '\\', '(', ')' ), array( '\\\\', '\\(', '\\)' ), $text );
+ $this->current .= sprintf(
+ "BT /F1 %.2f Tf %.2f %.2f Td (%s) Tj ET\n",
+ $size, $x, $pdf_y, $escaped
+ );
+ }
+
+ public function rect_filled( float $x, float $y, float $w, float $h ): void {
+ $pdf_y = $this->height - $y - $h;
+ $this->current .= sprintf( "%.2f %.2f %.2f %.2f re f\n", $x, $pdf_y, $w, $h );
+ }
+
+ public function rule( float $x, float $y, float $w ): void {
+ $this->rect_filled( $x, $y, $w, 0.5 );
+ }
+
+ /** Finalize and return the full PDF as a byte string. */
+ public function render(): string {
+ // Flush the in-progress page.
+ if ( '' !== $this->current || empty( $this->pages ) ) {
+ $this->pages[] = $this->current;
+ $this->current = '';
+ }
+
+ $objects = array();
+ $objects[1] = "<< /Type /Catalog /Pages 2 0 R >>";
+
+ // Build pages tree.
+ $page_ids = array();
+ $obj_id = 4; // Resources=3, then pages start at 4.
+ foreach ( $this->pages as $content ) {
+ $content_stream = "q\n0 0 0 rg\n" . $content . "Q\n";
+ $content_len = strlen( $content_stream );
+ $objects[ $obj_id ] = "<< /Length {$content_len} >>\nstream\n{$content_stream}endstream";
+ $content_obj_id = $obj_id;
+ $obj_id++;
+
+ $objects[ $obj_id ] = sprintf(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources 3 0 R /Contents %d 0 R >>",
+ $this->width, $this->height, $content_obj_id
+ );
+ $page_ids[] = $obj_id;
+ $obj_id++;
+ }
+
+ $kids = implode( ' ', array_map( static fn( $id ) => "{$id} 0 R", $page_ids ) );
+ $objects[2] = "<< /Type /Pages /Kids [{$kids}] /Count " . count( $page_ids ) . " >>";
+ $objects[3] = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>";
+
+ // Assemble PDF byte stream.
+ $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
+ $offsets = array();
+ ksort( $objects );
+ foreach ( $objects as $id => $body ) {
+ $offsets[ $id ] = strlen( $pdf );
+ $pdf .= "{$id} 0 obj\n{$body}\nendobj\n";
+ }
+ $xref_offset = strlen( $pdf );
+ $max_id = max( array_keys( $objects ) );
+ $pdf .= "xref\n0 " . ( $max_id + 1 ) . "\n";
+ $pdf .= "0000000000 65535 f \n";
+ for ( $i = 1; $i <= $max_id; $i++ ) {
+ $off = $offsets[ $i ] ?? 0;
+ $pdf .= sprintf( "%010d 00000 n \n", $off );
+ }
+ $pdf .= "trailer\n<< /Size " . ( $max_id + 1 ) . " /Root 1 0 R >>\n";
+ $pdf .= "startxref\n{$xref_offset}\n%%EOF\n";
+ return $pdf;
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/MCFClient.php b/sg-commerce-pro/src/Fulfillment/MCFClient.php
new file mode 100644
index 0000000..b005e1f
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/MCFClient.php
@@ -0,0 +1,170 @@
+to_preview_payload();
+ try {
+ $response = $this->amazon->request(
+ 'POST',
+ $request->market,
+ '/mfn/v0/fulfillmentOrders/preview',
+ array(),
+ $body,
+ 'mcf.preview'
+ );
+ } catch ( \Throwable $e ) {
+ $this->logger->error( 'MCF preview failed', array( 'err' => $e->getMessage() ) );
+ return array( 'feasible' => false, 'errors' => array( $e->getMessage() ) );
+ }
+
+ $previews = $response['payload']['fulfillmentPreviews'] ?? array();
+ foreach ( (array) $previews as $p ) {
+ if ( empty( $p['isFulfillable'] ) ) {
+ continue;
+ }
+ return array(
+ 'feasible' => true,
+ 'preview_id' => (string) ( $p['marketplaceId'] ?? '' ),
+ 'shipping_speed' => (string) ( $p['shippingSpeedCategory'] ?? '' ),
+ 'estimated_fees' => array_map(
+ static fn( $f ) => array(
+ 'name' => (string) ( $f['name'] ?? '' ),
+ 'amount' => (float) ( $f['amount']['value'] ?? 0 ),
+ 'currency' => (string) ( $f['amount']['currencyCode'] ?? '' ),
+ ),
+ (array) ( $p['estimatedFees'] ?? array() )
+ ),
+ 'ship_date' => (string) ( $p['estimatedShippingWeight']['value'] ?? '' ),
+ );
+ }
+ return array(
+ 'feasible' => false,
+ 'errors' => array( 'No fulfillable preview returned. SKU may be out of stock at requested speed.' ),
+ );
+ }
+
+ /**
+ * Create a fulfillment order. Returns the seller fulfillment order id
+ * (which we chose client-side) on success.
+ *
+ * @throws \RuntimeException on failure
+ */
+ public function create( MCFOrderRequest $request ): string {
+ $body = $request->to_create_payload();
+ $this->amazon->request(
+ 'POST',
+ $request->market,
+ '/mfn/v0/fulfillmentOrders',
+ array(),
+ $body,
+ 'mcf.create'
+ );
+ $this->logger->info( 'MCF order submitted', array(
+ 'seller_order_id' => $request->seller_fulfillment_order_id,
+ 'market' => $request->market,
+ 'items' => count( $request->items ),
+ ) );
+ return $request->seller_fulfillment_order_id;
+ }
+
+ /**
+ * Fetch current status + tracking for an existing MCF order.
+ */
+ public function get( string $market, string $seller_order_id ): array {
+ $response = $this->amazon->request(
+ 'GET',
+ $market,
+ '/mfn/v0/fulfillmentOrders/' . rawurlencode( $seller_order_id ),
+ array(),
+ null,
+ 'mcf.get'
+ );
+ $payload = $response['payload'] ?? array();
+ $status = (string) ( $payload['fulfillmentOrder']['fulfillmentOrderStatus'] ?? '' );
+
+ $tracking_numbers = array();
+ $carrier = '';
+ $shipped_at = null;
+ $delivered_at = null;
+
+ foreach ( (array) ( $payload['fulfillmentShipments'] ?? array() ) as $shipment ) {
+ foreach ( (array) ( $shipment['fulfillmentShipmentPackage'] ?? array() ) as $pkg ) {
+ if ( ! empty( $pkg['trackingNumber'] ) ) {
+ $tracking_numbers[] = (string) $pkg['trackingNumber'];
+ }
+ if ( ! empty( $pkg['carrierCode'] ) && '' === $carrier ) {
+ $carrier = (string) $pkg['carrierCode'];
+ }
+ }
+ if ( ! empty( $shipment['shippingDate'] ) && null === $shipped_at ) {
+ $shipped_at = (string) $shipment['shippingDate'];
+ }
+ if ( ! empty( $shipment['estimatedArrivalDate'] ) && null === $delivered_at ) {
+ $delivered_at = (string) $shipment['estimatedArrivalDate'];
+ }
+ }
+
+ return array(
+ 'amazon_status' => $status,
+ 'tracking_numbers' => $tracking_numbers,
+ 'carrier_code' => $carrier,
+ 'shipped_at' => $shipped_at,
+ 'delivered_at' => $delivered_at,
+ );
+ }
+
+ public function cancel( string $market, string $seller_order_id ): void {
+ $this->amazon->request(
+ 'PUT',
+ $market,
+ '/mfn/v0/fulfillmentOrders/' . rawurlencode( $seller_order_id ) . '/cancel',
+ array(),
+ null,
+ 'mcf.cancel'
+ );
+ $this->logger->info( 'MCF order cancelled', array( 'seller_order_id' => $seller_order_id ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/MCFOrderRepository.php b/sg-commerce-pro/src/Fulfillment/MCFOrderRepository.php
new file mode 100644
index 0000000..e5ae8e3
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/MCFOrderRepository.php
@@ -0,0 +1,136 @@
+table = $wpdb->prefix . 'sg_mcf_orders';
+ }
+
+ public function insert( MCFOrderRequest $req ): int {
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+ $result = $wpdb->insert( $this->table, array(
+ 'seller_fulfillment_order_id' => $req->seller_fulfillment_order_id,
+ 'displayable_order_id' => $req->displayable_order_id,
+ 'market' => $req->market,
+ 'customer_name' => (string) ( $req->customer['name'] ?? '' ),
+ 'customer_email' => (string) ( $req->customer['email'] ?? '' ),
+ 'country_code' => (string) ( $req->address['country_code'] ?? '' ),
+ 'address_line1' => (string) ( $req->address['line1'] ?? '' ),
+ 'address_line2' => (string) ( $req->address['line2'] ?? '' ),
+ 'city' => (string) ( $req->address['city'] ?? '' ),
+ 'state_region' => (string) ( $req->address['state_region'] ?? '' ),
+ 'postal_code' => (string) ( $req->address['postal_code'] ?? '' ),
+ 'phone' => (string) ( $req->customer['phone'] ?? '' ),
+ 'items' => wp_json_encode( $req->items ),
+ 'shipping_speed' => $req->shipping_speed,
+ 'status' => 'new',
+ 'created_at' => $now,
+ ), array( '%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s' ) );
+
+ if ( false === $result ) {
+ $this->logger->error( 'MCF order insert failed', array( 'err' => $wpdb->last_error ) );
+ throw new \RuntimeException( 'Could not persist MCF order: ' . $wpdb->last_error );
+ }
+ return (int) $wpdb->insert_id;
+ }
+
+ public function mark_submitted( int $id ): void {
+ global $wpdb;
+ $wpdb->update( $this->table, array(
+ 'status' => 'submitted',
+ 'submitted_at' => current_time( 'mysql', true ),
+ ), array( 'id' => $id ), array( '%s', '%s' ), array( '%d' ) );
+ }
+
+ public function mark_failed( int $id, string $error ): void {
+ global $wpdb;
+ $wpdb->update( $this->table, array(
+ 'status' => 'failed',
+ 'error_message' => $error,
+ ), array( 'id' => $id ), array( '%s', '%s' ), array( '%d' ) );
+ }
+
+ public function update_tracking( int $id, array $tracking ): void {
+ global $wpdb;
+ $wpdb->update( $this->table, array(
+ 'amazon_status' => (string) ( $tracking['amazon_status'] ?? '' ),
+ 'tracking_numbers' => wp_json_encode( $tracking['tracking_numbers'] ?? array() ),
+ 'carrier_code' => (string) ( $tracking['carrier_code'] ?? '' ),
+ 'shipped_at' => $tracking['shipped_at'] ?: null,
+ 'delivered_at' => $tracking['delivered_at'] ?: null,
+ 'status' => $this->derive_local_status( $tracking ),
+ ), array( 'id' => $id ), array( '%s','%s','%s','%s','%s','%s' ), array( '%d' ) );
+ }
+
+ private function derive_local_status( array $tracking ): string {
+ $amz = strtoupper( (string) ( $tracking['amazon_status'] ?? '' ) );
+ if ( str_contains( $amz, 'COMPLETE' ) || $tracking['delivered_at'] ) return 'delivered';
+ if ( $tracking['shipped_at'] || str_contains( $amz, 'SHIP' ) ) return 'shipped';
+ if ( str_contains( $amz, 'CANCEL' ) ) return 'cancelled';
+ if ( str_contains( $amz, 'PLAN' ) || str_contains( $amz, 'RECEIVED' ) ) return 'processing';
+ return 'submitted';
+ }
+
+ public function find( int $id ): ?array {
+ global $wpdb;
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ), ARRAY_A );
+ return $row ?: null;
+ }
+
+ public function find_by_seller_order( string $seller_order_id ): ?array {
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( "SELECT * FROM {$this->table} WHERE seller_fulfillment_order_id = %s", $seller_order_id ),
+ ARRAY_A
+ );
+ return $row ?: null;
+ }
+
+ public function recent( int $limit = 50 ): array {
+ global $wpdb;
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT * FROM {$this->table} ORDER BY created_at DESC LIMIT %d", $limit
+ ), ARRAY_A );
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ public function pending_tracking_refresh(): array {
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ "SELECT * FROM {$this->table}
+ WHERE status IN ('submitted', 'processing', 'shipped')
+ AND updated_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 15 MINUTE)
+ ORDER BY updated_at ASC
+ LIMIT 50",
+ ARRAY_A
+ );
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ public function count_by_status(): array {
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ "SELECT status, COUNT(*) c FROM {$this->table} GROUP BY status", ARRAY_A
+ );
+ $out = array();
+ foreach ( (array) $rows as $r ) $out[ (string) $r['status'] ] = (int) $r['c'];
+ return $out;
+ }
+}
diff --git a/sg-commerce-pro/src/Fulfillment/MCFOrderRequest.php b/sg-commerce-pro/src/Fulfillment/MCFOrderRequest.php
new file mode 100644
index 0000000..5702bec
--- /dev/null
+++ b/sg-commerce-pro/src/Fulfillment/MCFOrderRequest.php
@@ -0,0 +1,99 @@
+..., 'quantity'=>...], ... ]
+ * @param string $shipping_speed 'Standard' | 'Expedited' | 'Priority'
+ * @param string $comment Shows on packing slip.
+ * @param string $notification_email Amazon pushes shipment events here.
+ */
+ public function __construct(
+ public readonly string $seller_fulfillment_order_id,
+ public readonly string $displayable_order_id,
+ public readonly string $market,
+ public readonly array $customer,
+ public readonly array $address,
+ public readonly array $items,
+ public readonly string $shipping_speed = 'Standard',
+ public readonly string $comment = '',
+ public readonly string $notification_email = '',
+ ) {}
+
+ /** Payload for the /preview endpoint — no seller_order_id required. */
+ public function to_preview_payload(): array {
+ return array(
+ 'marketplaceId' => '', // AmazonClient fills via marketplace routing
+ 'address' => $this->address_block(),
+ 'items' => $this->items_block( 'preview' ),
+ 'shippingSpeedCategories' => array( $this->shipping_speed ),
+ );
+ }
+
+ public function to_create_payload(): array {
+ $payload = array(
+ 'sellerFulfillmentOrderId' => $this->seller_fulfillment_order_id,
+ 'displayableOrderId' => $this->displayable_order_id,
+ 'displayableOrderDate' => gmdate( 'c' ),
+ 'displayableOrderComment' => $this->comment ?: 'Thank you for your order.',
+ 'shippingSpeedCategory' => $this->shipping_speed,
+ 'destinationAddress' => $this->address_block(),
+ 'items' => $this->items_block( 'create' ),
+ 'notificationEmails' => array_values( array_filter( array( $this->notification_email, $this->customer['email'] ?? '' ) ) ),
+ );
+ return $payload;
+ }
+
+ private function address_block(): array {
+ return array(
+ 'name' => (string) ( $this->customer['name'] ?? '' ),
+ 'addressLine1' => (string) ( $this->address['line1'] ?? '' ),
+ 'addressLine2' => (string) ( $this->address['line2'] ?? '' ),
+ 'city' => (string) ( $this->address['city'] ?? '' ),
+ 'stateOrRegion' => (string) ( $this->address['state_region'] ?? '' ),
+ 'postalCode' => (string) ( $this->address['postal_code'] ?? '' ),
+ 'countryCode' => (string) ( $this->address['country_code'] ?? '' ),
+ 'phone' => (string) ( $this->customer['phone'] ?? '' ),
+ );
+ }
+
+ private function items_block( string $mode ): array {
+ $out = array();
+ foreach ( $this->items as $i => $item ) {
+ $sku = (string) ( $item['sku'] ?? '' );
+ $qty = (int) ( $item['quantity'] ?? 1 );
+ if ( '' === $sku || $qty < 1 ) continue;
+ $entry = array(
+ 'sellerSku' => $sku,
+ 'quantity' => $qty,
+ );
+ if ( 'create' === $mode ) {
+ $entry['sellerFulfillmentOrderItemId'] = (string) ( $item['item_id'] ?? ( $sku . '-' . $i ) );
+ $entry['displayableComment'] = (string) ( $item['comment'] ?? '' );
+ } else {
+ $entry['sellerFulfillmentOrderItemId'] = (string) ( $item['item_id'] ?? ( $sku . '-' . $i ) );
+ }
+ $out[] = $entry;
+ }
+ return $out;
+ }
+}
diff --git a/sg-commerce-pro/src/Geotargeting/GeoResolver.php b/sg-commerce-pro/src/Geotargeting/GeoResolver.php
new file mode 100644
index 0000000..c9b390e
--- /dev/null
+++ b/sg-commerce-pro/src/Geotargeting/GeoResolver.php
@@ -0,0 +1,198 @@
+ 'US', 'CA' => 'CA', 'MX' => 'MX', 'BR' => 'BR',
+ 'GB' => 'UK', 'DE' => 'DE', 'AT' => 'DE', 'CH' => 'DE',
+ 'FR' => 'FR', 'MC' => 'FR',
+ 'IT' => 'IT', 'SM' => 'IT',
+ 'ES' => 'ES', 'PT' => 'ES',
+ 'NL' => 'NL', 'BE' => 'BE',
+ 'SE' => 'SE', 'NO' => 'SE', 'FI' => 'SE', 'DK' => 'SE',
+ 'PL' => 'PL', 'CZ' => 'PL', 'SK' => 'PL',
+ 'IE' => 'IE',
+ 'TR' => 'TR',
+ 'AE' => 'AE', 'OM' => 'AE', 'KW' => 'AE', 'QA' => 'AE', 'BH' => 'AE',
+ 'SA' => 'SA',
+ 'AU' => 'AU', 'NZ' => 'AU',
+ 'IN' => 'IN',
+ 'SG' => 'SG', 'MY' => 'SG', 'ID' => 'SG', 'TH' => 'SG', 'VN' => 'SG', 'PH' => 'SG',
+ 'JP' => 'JP', 'KR' => 'JP',
+ );
+
+ private ?string $detected_country = null;
+ private ?string $detected_market = null;
+
+ public function __construct(
+ private Marketplaces $marketplaces,
+ private CacheManager $cache,
+ ) {}
+
+ /** Best-guess ISO-2 country code for the current visitor. */
+ public function country(): string {
+ if ( null !== $this->detected_country ) {
+ return $this->detected_country;
+ }
+ $this->detected_country = $this->detect_country();
+ return $this->detected_country;
+ }
+
+ /**
+ * Best Amazon market for this visitor. Returns a code from enabled_codes()
+ * if possible, else falls back to primary.
+ */
+ public function market(): string {
+ if ( null !== $this->detected_market ) {
+ return $this->detected_market;
+ }
+ $country = $this->country();
+ $enabled = $this->marketplaces->enabled_codes();
+ $primary = $this->marketplaces->primary();
+
+ $candidate = self::COUNTRY_TO_MARKET[ $country ] ?? $primary;
+ if ( ! in_array( $candidate, $enabled, true ) ) {
+ $candidate = $primary;
+ }
+ $this->detected_market = $candidate;
+ return $candidate;
+ }
+
+ /**
+ * Was the market chosen explicitly (override) or inferred?
+ * Used by Widget to show an "Other markets" dropdown only when inferred.
+ */
+ public function was_overridden(): bool {
+ return '' !== (string) ( $_GET['sg_market'] ?? '' ) // phpcs:ignore WordPress.Security.NonceVerification
+ || '' !== (string) ( $_COOKIE['sg_market'] ?? '' );
+ }
+
+ /** Let user set a sticky override (writes a 30-day cookie). */
+ public function set_override( string $market ): void {
+ if ( ! $this->marketplaces->exists( $market ) ) {
+ return;
+ }
+ $this->detected_market = $market;
+ $this->detected_country = '';
+ if ( ! headers_sent() ) {
+ setcookie( 'sg_market', $market, array(
+ 'expires' => time() + 30 * DAY_IN_SECONDS,
+ 'path' => '/',
+ 'secure' => is_ssl(),
+ 'httponly' => false, // JS may want to read this
+ 'samesite' => 'Lax',
+ ) );
+ }
+ }
+
+ private function detect_country(): string {
+ // 1. Explicit override.
+ $override = '';
+ if ( ! empty( $_GET['sg_market'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
+ $override = (string) $_GET['sg_market']; // phpcs:ignore
+ } elseif ( ! empty( $_COOKIE['sg_market'] ) ) {
+ $override = (string) $_COOKIE['sg_market'];
+ }
+ $override = strtoupper( preg_replace( '/[^A-Z]/i', '', $override ) );
+ if ( 2 === strlen( $override ) ) {
+ // sg_market is a market code, not strictly a country — map back.
+ $mp = $this->marketplaces->get( $override );
+ if ( $mp ) {
+ return $this->iso_for_market( $override );
+ }
+ }
+
+ // 2. Edge CDN headers.
+ $headers = array(
+ 'HTTP_CF_IPCOUNTRY',
+ 'HTTP_X_VERCEL_IP_COUNTRY',
+ 'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
+ 'HTTP_X_COUNTRY_CODE',
+ 'HTTP_X_APPENGINE_COUNTRY',
+ );
+ foreach ( $headers as $h ) {
+ if ( ! empty( $_SERVER[ $h ] ) ) {
+ $v = strtoupper( preg_replace( '/[^A-Z]/i', '', (string) $_SERVER[ $h ] ) );
+ if ( 2 === strlen( $v ) && 'XX' !== $v && 'T1' !== $v ) {
+ return $v;
+ }
+ }
+ }
+
+ // 3. PHP GeoIP extension (rarely installed but cheap to try).
+ $ip = $this->client_ip();
+ if ( function_exists( 'geoip_country_code_by_name' ) && '' !== $ip ) {
+ $v = @geoip_country_code_by_name( $ip );
+ if ( is_string( $v ) && 2 === strlen( $v ) ) {
+ return strtoupper( $v );
+ }
+ }
+
+ // 4. Accept-Language.
+ if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) {
+ $al = (string) $_SERVER['HTTP_ACCEPT_LANGUAGE'];
+ if ( preg_match( '/-([A-Za-z]{2})/', $al, $m ) ) {
+ return strtoupper( $m[1] );
+ }
+ // Map bare language to a default country.
+ $lang = strtolower( substr( $al, 0, 2 ) );
+ $map = array(
+ 'en' => 'US', 'de' => 'DE', 'fr' => 'FR', 'es' => 'ES',
+ 'it' => 'IT', 'nl' => 'NL', 'sv' => 'SE', 'pl' => 'PL',
+ 'tr' => 'TR', 'ar' => 'AE', 'pt' => 'BR', 'ja' => 'JP',
+ 'fa' => 'AE', 'zh' => 'SG',
+ );
+ if ( isset( $map[ $lang ] ) ) return $map[ $lang ];
+ }
+
+ // 5. Primary market's country.
+ return $this->iso_for_market( $this->marketplaces->primary() );
+ }
+
+ private function iso_for_market( string $market_code ): string {
+ foreach ( self::COUNTRY_TO_MARKET as $country => $m ) {
+ if ( $m === $market_code ) return $country;
+ }
+ return 'US';
+ }
+
+ private function client_ip(): string {
+ foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' ) as $k ) {
+ if ( ! empty( $_SERVER[ $k ] ) ) {
+ $ip = trim( explode( ',', (string) $_SERVER[ $k ] )[0] );
+ if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
+ return $ip;
+ }
+ }
+ }
+ return '';
+ }
+}
diff --git a/sg-commerce-pro/src/Geotargeting/GeotargetingModule.php b/sg-commerce-pro/src/Geotargeting/GeotargetingModule.php
new file mode 100644
index 0000000..97bed29
--- /dev/null
+++ b/sg-commerce-pro/src/Geotargeting/GeotargetingModule.php
@@ -0,0 +1,83 @@
+container;
+
+ $c->singleton( GeoResolver::class, static fn( Container $c ) =>
+ new GeoResolver( $c->get( Marketplaces::class ), $c->get( CacheManager::class ) )
+ );
+
+ // Widget shortcode filter: resolve 'auto' market to the visitor's market.
+ add_filter( 'shortcode_atts_sg_product', array( $this, 'resolve_auto_market' ), 10, 3 );
+ add_filter( 'shortcode_atts_sg_compare', array( $this, 'resolve_auto_market' ), 10, 3 );
+
+ // REST endpoint for JS clients.
+ add_action( 'rest_api_init', function (): void {
+ register_rest_route( 'sg-commerce/v1', '/geo', array(
+ 'methods' => 'GET',
+ 'permission_callback' => '__return_true',
+ 'callback' => array( $this, 'rest_geo' ),
+ ) );
+ } );
+
+ // Handle market override from query string. Sets cookie then redirects
+ // back without the query arg for clean URLs.
+ add_action( 'init', array( $this, 'maybe_set_override' ), 1 );
+ }
+
+ public function resolve_auto_market( array $out, array $pairs, array $atts ): array {
+ $market = strtolower( (string) ( $out['market'] ?? 'auto' ) );
+ if ( 'auto' === $market || '' === $market ) {
+ $out['market'] = $this->container->get( GeoResolver::class )->market();
+ } else {
+ $out['market'] = strtoupper( (string) $out['market'] );
+ }
+ return $out;
+ }
+
+ public function rest_geo(): \WP_REST_Response {
+ $resolver = $this->container->get( GeoResolver::class );
+ return rest_ensure_response( array(
+ 'country' => $resolver->country(),
+ 'market' => $resolver->market(),
+ 'overridden' => $resolver->was_overridden(),
+ ) );
+ }
+
+ public function maybe_set_override(): void {
+ if ( empty( $_GET['sg_market'] ) ) return; // phpcs:ignore WordPress.Security.NonceVerification
+ $m = strtoupper( preg_replace( '/[^A-Z]/i', '', (string) $_GET['sg_market'] ) ); // phpcs:ignore
+ if ( 2 !== strlen( $m ) ) return;
+ $this->container->get( GeoResolver::class )->set_override( $m );
+ }
+}
diff --git a/sg-commerce-pro/src/Injection/ContentInjector.php b/sg-commerce-pro/src/Injection/ContentInjector.php
new file mode 100644
index 0000000..e3d78a0
--- /dev/null
+++ b/sg-commerce-pro/src/Injection/ContentInjector.php
@@ -0,0 +1,199 @@
+ to Amazon via our geotargeted
+ * redirect. Lightest touch. Good for SEO-heavy content where you don't
+ * want chunky widgets breaking the flow.
+ * 'widget' Insert the full [sg_product] widget ABOVE the paragraph containing the match.
+ * Higher conversion but heavier.
+ * 'tooltip' Turn keyword into an inline span with JS-driven hovercard showing price/stock.
+ *
+ * Scans only the main content on singular views. Never modifies excerpts,
+ * admin, RSS, or feed contexts. First match per rule per post, capped by
+ * rule.max_per_post.
+ *
+ * @package SevenGum\Commerce\Injection
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Injection;
+
+use SevenGum\Commerce\Amazon\Marketplaces;
+use SevenGum\Commerce\Database\Repositories\ProductRepository;
+use SevenGum\Commerce\Geotargeting\GeoResolver;
+use SevenGum\Commerce\Logging\Logger;
+
+defined( 'ABSPATH' ) || exit;
+
+final class ContentInjector {
+
+ /** In-request cache of active rules. */
+ private ?array $rules_cache = null;
+
+ public function __construct(
+ private ProductRepository $products,
+ private Marketplaces $marketplaces,
+ private GeoResolver $geo,
+ private Logger $logger,
+ ) {}
+
+ /**
+ * Main hook — replaces matched keywords in post HTML.
+ *
+ * Applied on `the_content` priority 20 (after wpautop, so paragraph
+ * boundaries are known). Only touches singular posts/pages.
+ */
+ public function inject( string $content ): string {
+ if ( is_admin() || is_feed() || ! is_singular() ) {
+ return $content;
+ }
+ if ( '' === trim( $content ) ) {
+ return $content;
+ }
+
+ $rules = $this->load_rules();
+ if ( empty( $rules ) ) {
+ return $content;
+ }
+
+ $market = $this->geo->market();
+ $hits_per_rule = array();
+
+ foreach ( $rules as $rule ) {
+ $rule_id = (int) $rule['id'];
+ $pattern = (string) $rule['pattern'];
+ $ptype = (string) $rule['pattern_type']; // 'keyword' | 'regex'
+ $sku = (string) $rule['sku'];
+ $render = (string) $rule['render_as'];
+ $rule_mkt = (string) $rule['market'];
+ $max = max( 1, (int) $rule['max_per_post'] );
+
+ $effective_market = '' !== $rule_mkt ? $rule_mkt : $market;
+ $product = $this->products->find_by_sku( $effective_market, $sku );
+ if ( null === $product ) {
+ continue;
+ }
+
+ $hits_per_rule[ $rule_id ] = 0;
+
+ $callback = function ( array $m ) use ( $product, $render, $rule_id, $max, &$hits_per_rule, $effective_market ): string {
+ if ( $hits_per_rule[ $rule_id ] >= $max ) {
+ return $m[0];
+ }
+ // Don't match inside existing HTML tags (belt-and-braces — we also split HTML below).
+ $hits_per_rule[ $rule_id ]++;
+ return $this->render_replacement( $m[0], $product, $render, $effective_market );
+ };
+
+ if ( 'regex' === $ptype ) {
+ // Caller-supplied regex — wrap in delimiters + ui flags. Rejected if un-compilable.
+ $regex = '~' . str_replace( '~', '\\~', $pattern ) . '~ui';
+ $valid = false !== @preg_match( $regex, '' );
+ if ( ! $valid ) continue;
+ } else {
+ $regex = '~\b(' . preg_quote( $pattern, '~' ) . ')\b~ui';
+ }
+
+ $content = $this->replace_outside_tags( $content, $regex, $callback );
+
+ if ( $hits_per_rule[ $rule_id ] > 0 ) {
+ $this->increment_hit( $rule_id, $hits_per_rule[ $rule_id ] );
+ }
+ }
+
+ return $content;
+ }
+
+ /** Apply a regex callback only to text nodes, never to HTML tag internals. */
+ private function replace_outside_tags( string $html, string $regex, callable $cb ): string {
+ // Split on tags while keeping them.
+ $parts = preg_split( '/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
+ if ( false === $parts ) return $html;
+
+ $in_skip = 0; // depth counter for anchor/script/style
+ $skip_tags = array( 'a', 'script', 'style', 'code', 'pre', 'textarea' );
+
+ foreach ( $parts as $i => $part ) {
+ if ( '' === $part ) continue;
+ if ( '<' === $part[0] ) {
+ // Tag — adjust depth if it's a skip tag.
+ if ( preg_match( '~^<(/?)([a-zA-Z][a-zA-Z0-9]*)~', $part, $tm ) ) {
+ $is_close = '/' === $tm[1];
+ $tag = strtolower( $tm[2] );
+ if ( in_array( $tag, $skip_tags, true ) ) {
+ $in_skip = $is_close ? max( 0, $in_skip - 1 ) : $in_skip + 1;
+ }
+ }
+ continue;
+ }
+ if ( $in_skip > 0 ) continue;
+ $parts[ $i ] = preg_replace_callback( $regex, $cb, $part );
+ }
+ return implode( '', $parts );
+ }
+
+ private function render_replacement( string $matched, array $product, string $render, string $market ): string {
+ switch ( $render ) {
+ case 'widget':
+ return sprintf(
+ '%s%s
',
+ $matched,
+ do_shortcode( sprintf(
+ '[sg_product sku="%s" market="%s" style="compact"]',
+ esc_attr( $product['sku'] ),
+ esc_attr( $market )
+ ) )
+ );
+
+ case 'tooltip':
+ $url = '' !== $product['asin']
+ ? $this->marketplaces->product_url( (string) $product['asin'], $market )
+ : '#';
+ return sprintf(
+ '%s ',
+ esc_url( $url ),
+ esc_attr( $product['sku'] ),
+ esc_attr( $market ),
+ esc_html( $matched )
+ );
+
+ case 'link':
+ default:
+ $url = '' !== $product['asin']
+ ? $this->marketplaces->product_url( (string) $product['asin'], $market )
+ : home_url( '/?sg_go=' . urlencode( $product['sku'] ) );
+ return sprintf(
+ '%s ',
+ esc_url( $url ),
+ esc_html( $matched )
+ );
+ }
+ }
+
+ private function load_rules(): array {
+ if ( null !== $this->rules_cache ) {
+ return $this->rules_cache;
+ }
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ "SELECT * FROM {$wpdb->prefix}sg_injection_rules
+ WHERE is_active = 1
+ ORDER BY priority ASC, id ASC",
+ ARRAY_A
+ );
+ $this->rules_cache = is_array( $rows ) ? $rows : array();
+ return $this->rules_cache;
+ }
+
+ private function increment_hit( int $rule_id, int $count ): void {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}sg_injection_rules SET hit_count = hit_count + %d WHERE id = %d",
+ $count,
+ $rule_id
+ ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Injection/InjectionModule.php b/sg-commerce-pro/src/Injection/InjectionModule.php
new file mode 100644
index 0000000..6956394
--- /dev/null
+++ b/sg-commerce-pro/src/Injection/InjectionModule.php
@@ -0,0 +1,95 @@
+container;
+
+ $c->singleton( ContentInjector::class, static fn( Container $c ) =>
+ new ContentInjector(
+ $c->get( ProductRepository::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( GeoResolver::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ $settings = $c->get( SettingsRepository::class );
+ if ( $settings->get( 'injection_enabled', true ) ) {
+ // priority 20 — after wpautop, before shortcode rendering (priority 11).
+ // We DO want our injected [sg_product] shortcodes to expand, so run BEFORE
+ // do_shortcode which is at 11... except do_shortcode runs at 11 by default
+ // so we need to be earlier. Use priority 10 and run shortcodes ourselves.
+ add_filter( 'the_content', array( $this, 'filter_content' ), 10 );
+ }
+
+ // Short-link redirect handler.
+ add_action( 'init', array( $this, 'handle_short_link' ), 20 );
+ }
+
+ public function filter_content( string $content ): string {
+ try {
+ return $this->container->get( ContentInjector::class )->inject( $content );
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->error( 'Content injection failed: ' . $e->getMessage() );
+ return $content;
+ }
+ }
+
+ public function handle_short_link(): void {
+ if ( empty( $_GET['sg_go'] ) ) return; // phpcs:ignore WordPress.Security.NonceVerification
+ $sku = sanitize_text_field( wp_unslash( (string) $_GET['sg_go'] ) ); // phpcs:ignore
+ $market = $this->container->get( GeoResolver::class )->market();
+ $product = $this->container->get( ProductRepository::class )->find_by_sku( $market, $sku );
+
+ // If this SKU isn't tracked in the visitor's market, try any market we have.
+ if ( null === $product ) {
+ $enabled = $this->container->get( Marketplaces::class )->enabled_codes();
+ foreach ( $enabled as $m ) {
+ if ( $m === $market ) continue;
+ $product = $this->container->get( ProductRepository::class )->find_by_sku( $m, $sku );
+ if ( null !== $product ) { $market = $m; break; }
+ }
+ }
+
+ if ( null !== $product && '' !== $product['asin'] ) {
+ $url = $this->container->get( Marketplaces::class )->product_url( (string) $product['asin'], $market );
+ wp_safe_redirect( $url, 302 );
+ exit;
+ }
+
+ // Fallback — home page.
+ wp_safe_redirect( home_url(), 302 );
+ exit;
+ }
+}
diff --git a/sg-commerce-pro/src/Integration/IntegrationModule.php b/sg-commerce-pro/src/Integration/IntegrationModule.php
new file mode 100644
index 0000000..c258db2
--- /dev/null
+++ b/sg-commerce-pro/src/Integration/IntegrationModule.php
@@ -0,0 +1,96 @@
+container;
+
+ $c->singleton( ThemeBridge::class, static fn( Container $c ) =>
+ new ThemeBridge(
+ $c->get( ProductRepository::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( GeoResolver::class ),
+ $c->get( Logger::class )
+ )
+ );
+
+ // Listen to sync completion.
+ $events = $c->get( EventDispatcher::class );
+ $events->on( 'sync.completed', function ( array $payload ): array {
+ return $this->container->get( ThemeBridge::class )->on_sync_completed( $payload );
+ } );
+
+ // Also listen to individual product syncs for low-latency theme updates.
+ $events->after( 'product.synced', function ( array $payload ): void {
+ if ( empty( $payload['product_id'] ) ) return;
+ $row = $this->container->get( ProductRepository::class )->find( (int) $payload['product_id'] );
+ if ( null === $row ) return;
+ // Find the WP post mapped to this SKU and sync it.
+ $post_id = $this->find_post_for_sku( (string) $row['sku'], (string) $row['market'] );
+ if ( $post_id > 0 ) {
+ $this->container->get( ThemeBridge::class )->sync_post( $post_id );
+ }
+ } );
+
+ // Meta box + shortcodes (admin + frontend).
+ add_action( 'add_meta_boxes', function (): void {
+ $this->container->get( ThemeBridge::class )->register_meta_box();
+ } );
+
+ add_shortcode( 'sg_theme_price', function ( $atts ): string {
+ return $this->container->get( ThemeBridge::class )->shortcode_price( $atts );
+ } );
+ add_shortcode( 'sg_theme_buy', function ( $atts ): string {
+ return $this->container->get( ThemeBridge::class )->shortcode_buy( $atts );
+ } );
+ }
+
+ private function find_post_for_sku( string $sku, string $market ): int {
+ global $wpdb;
+ $post_id = $wpdb->get_var( $wpdb->prepare(
+ "SELECT p.ID FROM {$wpdb->posts} p
+ INNER JOIN {$wpdb->postmeta} m1 ON m1.post_id = p.ID AND m1.meta_key = %s AND m1.meta_value = %s
+ LEFT JOIN {$wpdb->postmeta} m2 ON m2.post_id = p.ID AND m2.meta_key = %s
+ WHERE p.post_type = %s
+ AND (m2.meta_value = %s OR m2.meta_value IS NULL OR m2.meta_value = '')
+ LIMIT 1",
+ ThemeBridge::META_SKU, $sku,
+ ThemeBridge::META_MARKET,
+ ThemeBridge::CPT,
+ $market
+ ) );
+ return (int) $post_id;
+ }
+}
diff --git a/sg-commerce-pro/src/Integration/ThemeBridge.php b/sg-commerce-pro/src/Integration/ThemeBridge.php
new file mode 100644
index 0000000..77cf93e
--- /dev/null
+++ b/sg-commerce-pro/src/Integration/ThemeBridge.php
@@ -0,0 +1,335 @@
+logger->debug( 'ThemeBridge skipped: active theme is not sevengum' );
+ return $payload;
+ }
+
+ $post_ids = get_posts( array(
+ 'post_type' => self::CPT,
+ 'post_status' => array( 'publish', 'draft' ),
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ ) );
+
+ $updated = 0;
+ foreach ( (array) $post_ids as $post_id ) {
+ if ( $this->sync_post( (int) $post_id ) ) {
+ $updated++;
+ }
+ }
+ $this->logger->info( 'ThemeBridge updated theme posts', array( 'updated' => $updated, 'total' => count( $post_ids ) ) );
+ return $payload;
+ }
+
+ /**
+ * Sync one CPT post's theme-facing data from its mapped Amazon SKU.
+ * Returns true if any field changed.
+ */
+ public function sync_post( int $post_id ): bool {
+ $sku = (string) get_post_meta( $post_id, self::META_SKU, true );
+ if ( '' === $sku ) {
+ return false;
+ }
+
+ // Market: use the one stored on the post, else primary.
+ $market = (string) get_post_meta( $post_id, self::META_MARKET, true );
+ if ( '' === $market ) {
+ $market = $this->marketplaces->primary();
+ }
+
+ $amazon_row = $this->products->find_by_sku( $market, $sku );
+ if ( null === $amazon_row ) {
+ return false;
+ }
+
+ $changed = false;
+
+ // 1. Price → _sevengum_price
+ if ( null !== $amazon_row['buybox_price'] ) {
+ $new_price = (string) round( (float) $amazon_row['buybox_price'], 2 );
+ $old_price = (string) get_post_meta( $post_id, '_sevengum_price', true );
+ if ( $new_price !== $old_price ) {
+ update_post_meta( $post_id, '_sevengum_price', $new_price );
+ $changed = true;
+ }
+ }
+
+ // 2. Buy URL → theme_mod (uses the post slug which maps to flavor slug)
+ $slug = get_post_field( 'post_name', $post_id );
+ if ( '' !== $slug && '' !== $amazon_row['asin'] ) {
+ $buy_url = $this->marketplaces->product_url( (string) $amazon_row['asin'], $market );
+ $old_url = (string) get_theme_mod( 'sg_amazon_' . $slug, '' );
+ if ( $buy_url !== $old_url ) {
+ set_theme_mod( 'sg_amazon_' . $slug, $buy_url );
+ $changed = true;
+ }
+ }
+
+ // 3. Stock cache meta (used by theme to hide sold-out CTAs if desired)
+ update_post_meta( $post_id, self::META_STOCK_CACHE, (int) $amazon_row['fulfillable_qty'] );
+ update_post_meta( $post_id, self::META_LAST_SYNC, current_time( 'mysql', true ) );
+
+ return $changed;
+ }
+
+ /**
+ * Register meta box on the products edit screen that lets editors:
+ * - Pick which Amazon SKU + market this post is mapped to
+ * - See read-only sync status (last sync, stock, BB status)
+ */
+ public function register_meta_box(): void {
+ add_meta_box(
+ 'sg_commerce_amazon_link',
+ __( 'Amazon link (Seven Gum Commerce)', 'sg-commerce' ),
+ array( $this, 'render_meta_box' ),
+ self::CPT,
+ 'side',
+ 'default'
+ );
+ add_action( 'save_post_' . self::CPT, array( $this, 'save_meta_box' ), 10, 2 );
+ }
+
+ public function render_meta_box( \WP_Post $post ): void {
+ wp_nonce_field( 'sg_theme_bridge_save', '_sg_tb_nonce' );
+
+ $current_sku = (string) get_post_meta( $post->ID, self::META_SKU, true );
+ $current_market = (string) get_post_meta( $post->ID, self::META_MARKET, true );
+ $last_sync = (string) get_post_meta( $post->ID, self::META_LAST_SYNC, true );
+ $stock = (int) get_post_meta( $post->ID, self::META_STOCK_CACHE, true );
+
+ if ( '' === $current_market ) {
+ $current_market = $this->marketplaces->primary();
+ }
+
+ // Suggest SKUs: all SKUs we have for the chosen market.
+ $suggestions = array();
+ global $wpdb;
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT sku, product_name FROM {$wpdb->prefix}sg_products WHERE market = %s ORDER BY sku ASC LIMIT 50",
+ $current_market
+ ), ARRAY_A );
+ foreach ( (array) $rows as $r ) {
+ $suggestions[ $r['sku'] ] = $r['product_name'] ?: $r['sku'];
+ }
+
+ $mapped_row = '' !== $current_sku ? $this->products->find_by_sku( $current_market, $current_sku ) : null;
+ ?>
+
+
+
+ marketplaces->enabled_codes() as $code ) : ?>
+ >
+
+
+
+
+
+
+
+
+
+ $n ) : ?>
+
+
+
+
+
+
+
+
: ✓
+
:
+
+
:
+
+
+ —
+
+
+
+
+
:
+
+
+
+ ', '
', esc_html( $post->post_name )
+ ); ?>
+
+
+
+
+
+
+ sync_post( $post_id );
+ }
+
+ /**
+ * Shortcode: [sg_theme_price slug="cookie"] — used by the theme itself
+ * if it wants to show the live price with currency.
+ */
+ public function shortcode_price( $atts ): string {
+ $atts = shortcode_atts( array( 'slug' => '', 'post_id' => 0 ), is_array( $atts ) ? $atts : array(), 'sg_theme_price' );
+ $post_id = (int) $atts['post_id'];
+ if ( 0 === $post_id && '' !== (string) $atts['slug'] ) {
+ $post = get_page_by_path( (string) $atts['slug'], OBJECT, self::CPT );
+ if ( $post ) $post_id = $post->ID;
+ }
+ if ( 0 === $post_id ) return '';
+ $sku = (string) get_post_meta( $post_id, self::META_SKU, true );
+ $market = (string) get_post_meta( $post_id, self::META_MARKET, true ) ?: $this->geo->market();
+ if ( '' === $sku ) return '';
+ $row = $this->products->find_by_sku( $market, $sku );
+ if ( null === $row || null === $row['buybox_price'] ) return '';
+ $currency = $row['buybox_currency'] ?: ( $this->marketplaces->get( $market )['currency'] ?? '' );
+ return sprintf(
+ '%s %s ',
+ esc_html( number_format( (float) $row['buybox_price'], 2 ) ),
+ esc_html( $currency )
+ );
+ }
+
+ /**
+ * Shortcode: [sg_theme_buy slug="cookie"] — geotargeted Amazon button
+ * that also updates in place via the widget auto-refresh JS.
+ */
+ public function shortcode_buy( $atts ): string {
+ $atts = shortcode_atts(
+ array( 'slug' => '', 'post_id' => 0, 'label' => '' ),
+ is_array( $atts ) ? $atts : array(),
+ 'sg_theme_buy'
+ );
+ $post_id = (int) $atts['post_id'];
+ if ( 0 === $post_id && '' !== (string) $atts['slug'] ) {
+ $post = get_page_by_path( (string) $atts['slug'], OBJECT, self::CPT );
+ if ( $post ) $post_id = $post->ID;
+ }
+ if ( 0 === $post_id ) return '';
+
+ $sku = (string) get_post_meta( $post_id, self::META_SKU, true );
+ if ( '' === $sku ) return '';
+
+ // Geotargeted: use visitor's market, not the mapped one.
+ $market = $this->geo->market();
+ $row = $this->products->find_by_sku( $market, $sku );
+
+ // Fallback: if we don't have this SKU in visitor's market, fall back to
+ // the mapped market.
+ if ( null === $row ) {
+ $mapped_market = (string) get_post_meta( $post_id, self::META_MARKET, true );
+ if ( '' !== $mapped_market ) {
+ $row = $this->products->find_by_sku( $mapped_market, $sku );
+ if ( null !== $row ) $market = $mapped_market;
+ }
+ }
+
+ if ( null === $row || '' === $row['asin'] ) return '';
+
+ $url = $this->marketplaces->product_url( (string) $row['asin'], $market );
+ $currency = $row['buybox_currency'] ?: ( $this->marketplaces->get( $market )['currency'] ?? '' );
+ $price_str = null !== $row['buybox_price']
+ ? number_format( (float) $row['buybox_price'], 2 ) . ' ' . $currency
+ : '';
+
+ $default_label = '' !== $price_str
+ ? sprintf( /* translators: %s: price */ __( 'Buy on Amazon — %s', 'sg-commerce' ), $price_str )
+ : __( 'Buy on Amazon', 'sg-commerce' );
+ $label = (string) $atts['label'] !== '' ? (string) $atts['label'] : $default_label;
+
+ return sprintf(
+ '🛒 %s ',
+ esc_url( $url ),
+ esc_attr( $sku ),
+ esc_attr( $market ),
+ esc_html( $label )
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Logging/Logger.php b/sg-commerce-pro/src/Logging/Logger.php
new file mode 100644
index 0000000..a17cadb
--- /dev/null
+++ b/sg-commerce-pro/src/Logging/Logger.php
@@ -0,0 +1,154 @@
+ 'DEBUG',
+ self::INFO => 'INFO',
+ self::NOTICE => 'NOTICE',
+ self::WARNING => 'WARNING',
+ self::ERROR => 'ERROR',
+ self::CRITICAL => 'CRITICAL',
+ );
+
+ private ?string $log_dir = null;
+ private int $threshold;
+
+ public function __construct() {
+ $settings = get_option( 'sg_commerce_settings', array() );
+ $level = is_array( $settings ) ? (string) ( $settings['log_level'] ?? 'info' ) : 'info';
+ $this->threshold = $this->level_from_name( $level );
+ }
+
+ public function debug ( string $msg, array $ctx = array() ): void { $this->log( self::DEBUG, $msg, $ctx ); }
+ public function info ( string $msg, array $ctx = array() ): void { $this->log( self::INFO, $msg, $ctx ); }
+ public function notice ( string $msg, array $ctx = array() ): void { $this->log( self::NOTICE, $msg, $ctx ); }
+ public function warning ( string $msg, array $ctx = array() ): void { $this->log( self::WARNING, $msg, $ctx ); }
+ public function error ( string $msg, array $ctx = array() ): void { $this->log( self::ERROR, $msg, $ctx ); }
+ public function critical( string $msg, array $ctx = array() ): void { $this->log( self::CRITICAL, $msg, $ctx ); }
+
+ public function log( int $level, string $message, array $context = array() ): void {
+ if ( $level < $this->threshold ) {
+ return;
+ }
+ $record = array(
+ 'timestamp' => gmdate( 'c' ),
+ 'level' => self::LEVEL_NAMES[ $level ] ?? 'UNKNOWN',
+ 'message' => $message,
+ 'context' => $context,
+ );
+ $encoded = wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+
+ $file = $this->ensure_log_file();
+ if ( null !== $file ) {
+ @file_put_contents( $file, $encoded . "\n", FILE_APPEND | LOCK_EX );
+ } else {
+ // Fallback — at least keep the record findable.
+ error_log( '[SG Commerce] ' . $encoded );
+ }
+ }
+
+ public function get_log_dir(): ?string {
+ return $this->log_dir ?? $this->resolve_log_dir();
+ }
+
+ /**
+ * Read recent log lines for display in admin (most recent first).
+ *
+ * @return array>
+ */
+ public function tail( int $lines = 200 ): array {
+ $file = $this->todays_file();
+ if ( null === $file || ! is_readable( $file ) ) {
+ return array();
+ }
+ $content = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
+ if ( ! is_array( $content ) ) {
+ return array();
+ }
+ $slice = array_slice( $content, -1 * $lines );
+ $out = array();
+ foreach ( array_reverse( $slice ) as $line ) {
+ $decoded = json_decode( (string) $line, true );
+ if ( is_array( $decoded ) ) {
+ $out[] = $decoded;
+ }
+ }
+ return $out;
+ }
+
+ private function ensure_log_file(): ?string {
+ $dir = $this->resolve_log_dir();
+ if ( null === $dir ) {
+ return null;
+ }
+ return $this->todays_file();
+ }
+
+ private function todays_file(): ?string {
+ $dir = $this->log_dir ?? $this->resolve_log_dir();
+ if ( null === $dir ) {
+ return null;
+ }
+ return $dir . '/' . gmdate( 'Y-m-d' ) . '.log';
+ }
+
+ private function resolve_log_dir(): ?string {
+ if ( null !== $this->log_dir ) {
+ return $this->log_dir;
+ }
+ $uploads = wp_upload_dir( null, false );
+ if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
+ return null;
+ }
+ $dir = trailingslashit( $uploads['basedir'] ) . 'sg-commerce-logs';
+ if ( ! is_dir( $dir ) ) {
+ if ( ! wp_mkdir_p( $dir ) ) {
+ return null;
+ }
+ // Block direct HTTP access.
+ @file_put_contents( $dir . '/.htaccess', "Deny from all\n" );
+ @file_put_contents( $dir . '/index.html', '' );
+ }
+ $this->log_dir = $dir;
+ return $dir;
+ }
+
+ private function level_from_name( string $name ): int {
+ return match ( strtolower( $name ) ) {
+ 'debug' => self::DEBUG,
+ 'info' => self::INFO,
+ 'notice' => self::NOTICE,
+ 'warning', 'warn' => self::WARNING,
+ 'error' => self::ERROR,
+ 'critical', 'crit' => self::CRITICAL,
+ default => self::INFO,
+ };
+ }
+}
diff --git a/sg-commerce-pro/src/Queue/JobQueue.php b/sg-commerce-pro/src/Queue/JobQueue.php
new file mode 100644
index 0000000..f70f8d5
--- /dev/null
+++ b/sg-commerce-pro/src/Queue/JobQueue.php
@@ -0,0 +1,97 @@
+enqueue('sg_commerce_describe_product', ['id' => 42]);
+ * add_action('sg_commerce_describe_product', fn($args) => ...);
+ *
+ * @package SevenGum\Commerce\Queue
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Queue;
+
+use SevenGum\Commerce\Logging\Logger;
+
+defined( 'ABSPATH' ) || exit;
+
+final class JobQueue {
+
+ public function __construct( private Logger $logger ) {}
+
+ /**
+ * Enqueue a job for immediate (next tick) async execution.
+ *
+ * @param string $hook Hook name to fire.
+ * @param array $args Single-element array passed to the listener.
+ * @param int $delay Seconds to wait before firing.
+ */
+ public function enqueue( string $hook, array $args = array(), int $delay = 0 ): void {
+ $timestamp = time() + max( 0, $delay );
+
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ as_schedule_single_action( $timestamp, $hook, array( $args ), 'sg_commerce' );
+ $this->logger->debug( 'Job queued via Action Scheduler', array( 'hook' => $hook, 'delay' => $delay ) );
+ return;
+ }
+
+ // Fallback: single-shot wp-cron. Note: less reliable on low-traffic sites.
+ wp_schedule_single_event( $timestamp, $hook, array( $args ) );
+ $this->logger->debug( 'Job queued via wp-cron fallback', array( 'hook' => $hook, 'delay' => $delay ) );
+ }
+
+ /**
+ * Schedule a recurring job. Idempotent: silently no-ops if already scheduled.
+ */
+ public function recurring( string $hook, string $recurrence, array $args = array() ): void {
+ if ( function_exists( 'as_schedule_recurring_action' ) && ! as_next_scheduled_action( $hook, null, 'sg_commerce' ) ) {
+ $interval = $this->recurrence_to_seconds( $recurrence );
+ as_schedule_recurring_action( time() + 60, $interval, $hook, array( $args ), 'sg_commerce' );
+ return;
+ }
+ if ( ! wp_next_scheduled( $hook, array( $args ) ) ) {
+ wp_schedule_event( time() + 60, $recurrence, $hook, array( $args ) );
+ }
+ }
+
+ public function cancel( string $hook ): void {
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( $hook, null, 'sg_commerce' );
+ }
+ wp_clear_scheduled_hook( $hook );
+ }
+
+ /** Pending job count for admin display. */
+ public function pending( string $hook ): int {
+ if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
+ return 0;
+ }
+ $actions = as_get_scheduled_actions(
+ array(
+ 'hook' => $hook,
+ 'status' => 'pending',
+ 'group' => 'sg_commerce',
+ 'per_page' => 1000,
+ ),
+ 'ids'
+ );
+ return is_array( $actions ) ? count( $actions ) : 0;
+ }
+
+ private function recurrence_to_seconds( string $recurrence ): int {
+ return match ( $recurrence ) {
+ 'sg_five_minutes' => 5 * MINUTE_IN_SECONDS,
+ 'sg_fifteen_minutes' => 15 * MINUTE_IN_SECONDS,
+ 'sg_six_hours' => 6 * HOUR_IN_SECONDS,
+ 'hourly' => HOUR_IN_SECONDS,
+ 'twicedaily' => 12 * HOUR_IN_SECONDS,
+ 'daily' => DAY_IN_SECONDS,
+ default => HOUR_IN_SECONDS,
+ };
+ }
+}
diff --git a/sg-commerce-pro/src/REST/RESTModule.php b/sg-commerce-pro/src/REST/RESTModule.php
new file mode 100644
index 0000000..899d17d
--- /dev/null
+++ b/sg-commerce-pro/src/REST/RESTModule.php
@@ -0,0 +1,118 @@
+[A-Z]{2})/(?P[A-Za-z0-9_\-\.]+)', array(
+ 'methods' => 'GET',
+ 'permission_callback' => '__return_true',
+ 'callback' => array( $this, 'route_get_product' ),
+ ) );
+ register_rest_route( self::NAMESPACE, '/sync/(?P[A-Z]{2})', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function () { return current_user_can( 'manage_options' ); },
+ 'callback' => array( $this, 'route_sync' ),
+ ) );
+ register_rest_route( self::NAMESPACE, '/describe/(?P\d+)', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function () { return current_user_can( 'manage_options' ); },
+ 'callback' => array( $this, 'route_describe' ),
+ ) );
+ register_rest_route( self::NAMESPACE, '/translate/(?P\d+)/(?P[A-Z]{2})', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function () { return current_user_can( 'manage_options' ); },
+ 'callback' => array( $this, 'route_translate' ),
+ ) );
+ }
+
+ public function route_get_product( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error {
+ $market = strtoupper( (string) $req['market'] );
+ $sku = (string) $req['sku'];
+ $products = $this->container->get( ProductRepository::class );
+ $row = $products->find_by_sku( $market, $sku );
+ if ( null === $row ) {
+ return new \WP_Error( 'not_found', 'Product not found', array( 'status' => 404 ) );
+ }
+ $marketplaces = $this->container->get( Marketplaces::class );
+ $mp = $marketplaces->get( $market );
+ return rest_ensure_response( array(
+ 'sku' => (string) $row['sku'],
+ 'asin' => (string) $row['asin'],
+ 'market' => (string) $row['market'],
+ 'name' => (string) $row['product_name'],
+ 'image_url' => (string) $row['image_url'],
+ 'in_stock' => (int) $row['fulfillable_qty'] > 0,
+ 'stock' => (int) $row['fulfillable_qty'],
+ 'price' => null !== $row['buybox_price'] ? (float) $row['buybox_price'] : null,
+ 'currency' => (string) $row['buybox_currency'] ?: ( $mp['currency'] ?? '' ),
+ 'amazon_url' => '' !== $row['asin'] ? $marketplaces->product_url( (string) $row['asin'], $market ) : '',
+ ) );
+ }
+
+ public function route_sync( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error {
+ try {
+ $result = $this->container->get( SyncOrchestrator::class )->sync_market( strtoupper( (string) $req['market'] ) );
+ return rest_ensure_response( $result );
+ } catch ( \Throwable $e ) {
+ return new \WP_Error( 'sync_failed', $e->getMessage(), array( 'status' => 500 ) );
+ }
+ }
+
+ public function route_describe( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error {
+ try {
+ $text = $this->container->get( Copywriter::class )->describe( (int) $req['id'] );
+ return rest_ensure_response( array( 'ok' => true, 'description' => $text ) );
+ } catch ( \Throwable $e ) {
+ return new \WP_Error( 'ai_failed', $e->getMessage(), array( 'status' => 500 ) );
+ }
+ }
+
+ public function route_translate( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error {
+ try {
+ $text = $this->container->get( Copywriter::class )->translate(
+ (int) $req['id'],
+ strtoupper( (string) $req['market'] )
+ );
+ return rest_ensure_response( array( 'ok' => true, 'description' => $text ) );
+ } catch ( \Throwable $e ) {
+ return new \WP_Error( 'ai_failed', $e->getMessage(), array( 'status' => 500 ) );
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Repricer/RepricerEngine.php b/sg-commerce-pro/src/Repricer/RepricerEngine.php
new file mode 100644
index 0000000..2979f16
--- /dev/null
+++ b/sg-commerce-pro/src/Repricer/RepricerEngine.php
@@ -0,0 +1,122 @@
+settings->get( 'repricer_enabled', false ) ) {
+ return array( 'skipped' => 'disabled' );
+ }
+
+ $strategy = (string) $this->settings->get( 'repricer_strategy', 'match_buybox' );
+ $cents_off = (float) $this->settings->get( 'repricer_cents_below_buybox', 0.01 );
+ $dry_run = (bool) $this->settings->get( 'repricer_dry_run', true );
+
+ // Pull losing products with cost set.
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ "SELECT id, market, sku, asin, buybox_price, my_price, cost_price, buybox_currency
+ FROM {$wpdb->prefix}sg_products
+ WHERE buybox_is_mine = 0
+ AND buybox_price IS NOT NULL
+ AND cost_price IS NOT NULL
+ ORDER BY updated_at DESC
+ LIMIT 100",
+ ARRAY_A
+ );
+
+ $adjusted = 0;
+ $skipped_floor = 0;
+ $logs = array();
+
+ foreach ( (array) $rows as $row ) {
+ $buybox = (float) $row['buybox_price'];
+ $cost = (float) $row['cost_price'];
+ $target = match ( $strategy ) {
+ 'match_buybox' => max( 0.01, $buybox - $cents_off ),
+ default => $buybox,
+ };
+ $floor = $this->profit->min_price_for( $cost );
+
+ if ( $target < $floor ) {
+ ++$skipped_floor;
+ $logs[] = array(
+ 'sku' => $row['sku'],
+ 'market' => $row['market'],
+ 'reason' => 'would breach min margin floor',
+ 'target' => $target,
+ 'floor' => $floor,
+ );
+ continue;
+ }
+
+ $current = isset( $row['my_price'] ) ? (float) $row['my_price'] : 0.0;
+ if ( abs( $current - $target ) < 0.005 ) {
+ continue; // already at target
+ }
+
+ $logs[] = array(
+ 'sku' => $row['sku'],
+ 'market' => $row['market'],
+ 'from' => $current,
+ 'to' => round( $target, 2 ),
+ 'buybox' => $buybox,
+ 'dry_run' => $dry_run,
+ );
+
+ $this->products->update_my_price( (int) $row['id'], round( $target, 2 ) );
+ ++$adjusted;
+
+ if ( ! $dry_run ) {
+ // In a real production flow, here we'd PATCH the Amazon listing.
+ // We log intent only — operator must enable Listings API authorization.
+ $this->logger->info( 'repricer would PATCH listing', array(
+ 'asin' => $row['asin'], 'sku' => $row['sku'], 'price' => $target,
+ ) );
+ }
+ }
+
+ $summary = array(
+ 'adjusted' => $adjusted,
+ 'skipped_floor' => $skipped_floor,
+ 'dry_run' => $dry_run,
+ 'logs' => $logs,
+ );
+ $this->logger->info( 'repricer tick complete', array(
+ 'adjusted' => $adjusted, 'skipped_floor' => $skipped_floor,
+ ) );
+ return $summary;
+ }
+}
diff --git a/sg-commerce-pro/src/Repricer/RepricerModule.php b/sg-commerce-pro/src/Repricer/RepricerModule.php
new file mode 100644
index 0000000..3e9a54e
--- /dev/null
+++ b/sg-commerce-pro/src/Repricer/RepricerModule.php
@@ -0,0 +1,35 @@
+container->get( RepricerEngine::class )->tick();
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->error( 'repricer tick failed: ' . $e->getMessage() );
+ }
+ } );
+ }
+}
diff --git a/sg-commerce-pro/src/Security/Encryption.php b/sg-commerce-pro/src/Security/Encryption.php
new file mode 100644
index 0000000..f0e50af
--- /dev/null
+++ b/sg-commerce-pro/src/Security/Encryption.php
@@ -0,0 +1,108 @@
+derive_key();
+ $nonce = random_bytes( 12 );
+ $tag = '';
+ $cipher = openssl_encrypt(
+ $plaintext,
+ 'aes-256-gcm',
+ $key,
+ OPENSSL_RAW_DATA,
+ $nonce,
+ $tag,
+ '',
+ 16
+ );
+ if ( false === $cipher ) {
+ throw new \RuntimeException( 'Encryption failed: openssl returned false. Check that openssl extension is loaded.' );
+ }
+ $envelope = chr( self::VERSION_AES256GCM ) . $nonce . $tag . $cipher;
+ return base64_encode( $envelope );
+ }
+
+ public function decrypt( string $payload ): string {
+ if ( '' === $payload ) {
+ return '';
+ }
+ $raw = base64_decode( $payload, true );
+ if ( false === $raw || strlen( $raw ) < 30 ) {
+ throw new \RuntimeException( 'Ciphertext malformed: too short or invalid base64.' );
+ }
+ $version = ord( $raw[0] );
+ if ( self::VERSION_AES256GCM !== $version ) {
+ throw new \RuntimeException( sprintf( 'Unsupported encryption version 0x%02X.', $version ) );
+ }
+ $nonce = substr( $raw, 1, 12 );
+ $tag = substr( $raw, 13, 16 );
+ $cipher = substr( $raw, 29 );
+ $plain = openssl_decrypt( $cipher, 'aes-256-gcm', $this->derive_key(), OPENSSL_RAW_DATA, $nonce, $tag );
+ if ( false === $plain ) {
+ throw new \RuntimeException( 'Decryption failed — tampered, corrupted, or AUTH_KEY rotated.' );
+ }
+ return $plain;
+ }
+
+ /**
+ * Detect whether a stored value looks like our envelope. Used by
+ * SettingsRepository to handle legacy plain-text gracefully.
+ */
+ public function is_encrypted( string $payload ): bool {
+ if ( '' === $payload ) {
+ return false;
+ }
+ $raw = base64_decode( $payload, true );
+ if ( false === $raw || strlen( $raw ) < 30 ) {
+ return false;
+ }
+ return self::VERSION_AES256GCM === ord( $raw[0] );
+ }
+
+ private function derive_key(): string {
+ $seed = ( defined( 'AUTH_KEY' ) ? (string) \AUTH_KEY : '' )
+ . '|' . ( defined( 'SECURE_AUTH_KEY' ) ? (string) \SECURE_AUTH_KEY : '' )
+ . '|' . ( defined( 'LOGGED_IN_KEY' ) ? (string) \LOGGED_IN_KEY : '' );
+
+ if ( '||' === $seed ) {
+ throw new \RuntimeException(
+ 'No WordPress AUTH_KEY available. Define AUTH_KEY in wp-config.php — generate one at https://api.wordpress.org/secret-key/1.1/salt/'
+ );
+ }
+ // HKDF-Extract → HKDF-Expand (single block, output length = 32).
+ $prk = hash_hmac( 'sha256', $seed, '', true );
+ return hash_hmac( 'sha256', self::CONTEXT . chr( 0x01 ), $prk, true );
+ }
+}
diff --git a/sg-commerce-pro/src/Security/PIIManager/PIIManager.php b/sg-commerce-pro/src/Security/PIIManager/PIIManager.php
new file mode 100644
index 0000000..0a51e45
--- /dev/null
+++ b/sg-commerce-pro/src/Security/PIIManager/PIIManager.php
@@ -0,0 +1,208 @@
+container->get( SettingsRepository::class );
+ $logger = $this->container->get( Logger::class );
+
+ $retention_days = max( 1, (int) $settings->get( 'pii_retention_days', 30 ) );
+ $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
+
+ $table = $wpdb->prefix . 'sg_mcf_orders';
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT id FROM {$table}
+ WHERE status IN ('delivered', 'cancelled', 'failed')
+ AND (updated_at < %s OR delivered_at < %s)
+ AND customer_name != %s
+ LIMIT 500",
+ $cutoff, $cutoff, self::REDACTED
+ ), ARRAY_A );
+
+ if ( empty( $rows ) ) {
+ return 0;
+ }
+
+ $count = 0;
+ foreach ( $rows as $row ) {
+ $id = (int) $row['id'];
+ $result = $wpdb->update(
+ $table,
+ array(
+ 'customer_name' => self::REDACTED,
+ 'customer_email' => self::REDACTED . '@sg-redacted.invalid',
+ 'address_line1' => self::REDACTED,
+ 'address_line2' => '',
+ 'phone' => '',
+ // city, postal_code, country_code: KEPT for analytics
+ ),
+ array( 'id' => $id ),
+ array( '%s', '%s', '%s', '%s', '%s' ),
+ array( '%d' )
+ );
+ if ( false !== $result ) {
+ $count++;
+ }
+ }
+
+ // Audit log.
+ $wpdb->insert( $wpdb->prefix . 'sg_audit', array(
+ 'user_id' => 0,
+ 'action' => 'pii_anonymize_batch',
+ 'entity_type' => 'mcf_order',
+ 'entity_id' => '',
+ 'details' => wp_json_encode( array(
+ 'count' => $count,
+ 'retention' => $retention_days,
+ 'cutoff' => $cutoff,
+ ) ),
+ 'created_at' => current_time( 'mysql', true ),
+ ), array( '%d', '%s', '%s', '%s', '%s', '%s' ) );
+
+ $logger->info( 'PII anonymization batch complete', array(
+ 'count' => $count,
+ 'retention_days' => $retention_days,
+ ) );
+ return $count;
+ }
+
+ /**
+ * GDPR subject-access: export all data we hold for a given email, across
+ * MCF orders + audit. Returns a structured array ready for JSON or CSV.
+ */
+ public function export_subject_data( string $email ): array {
+ global $wpdb;
+ $orders = $wpdb->get_results( $wpdb->prepare(
+ "SELECT id, displayable_order_id, market, customer_name, customer_email,
+ country_code, city, state_region, postal_code, items, status,
+ tracking_numbers, created_at, shipped_at, delivered_at
+ FROM {$wpdb->prefix}sg_mcf_orders
+ WHERE customer_email = %s",
+ $email
+ ), ARRAY_A );
+
+ return array(
+ 'subject_email' => $email,
+ 'exported_at' => gmdate( 'c' ),
+ 'mcf_orders' => is_array( $orders ) ? $orders : array(),
+ );
+ }
+
+ /**
+ * GDPR right-to-erasure: delete all PII for a subject immediately,
+ * regardless of retention window. Returns count of rows processed.
+ */
+ public function erase_subject_data( string $email ): int {
+ global $wpdb;
+ $table = $wpdb->prefix . 'sg_mcf_orders';
+ $ids = $wpdb->get_col( $wpdb->prepare(
+ "SELECT id FROM {$table} WHERE customer_email = %s",
+ $email
+ ) );
+ if ( empty( $ids ) ) return 0;
+
+ $count = 0;
+ foreach ( $ids as $id ) {
+ $result = $wpdb->update(
+ $table,
+ array(
+ 'customer_name' => self::REDACTED,
+ 'customer_email' => self::REDACTED . '@sg-redacted.invalid',
+ 'address_line1' => self::REDACTED,
+ 'address_line2' => '',
+ 'phone' => '',
+ ),
+ array( 'id' => (int) $id ),
+ array( '%s', '%s', '%s', '%s', '%s' ),
+ array( '%d' )
+ );
+ if ( false !== $result ) $count++;
+ }
+
+ $wpdb->insert( $wpdb->prefix . 'sg_audit', array(
+ 'user_id' => get_current_user_id(),
+ 'action' => 'pii_erase_subject',
+ 'entity_type' => 'mcf_order',
+ 'entity_id' => '',
+ 'details' => wp_json_encode( array( 'email_hash' => hash( 'sha256', $email ), 'count' => $count ) ),
+ 'created_at' => current_time( 'mysql', true ),
+ ), array( '%d', '%s', '%s', '%s', '%s', '%s' ) );
+
+ return $count;
+ }
+
+ /** Count of rows still holding un-anonymized PII, for the compliance dashboard. */
+ public function count_pending_anonymization(): int {
+ global $wpdb;
+ $settings = $this->container->get( SettingsRepository::class );
+ $retention_days = max( 1, (int) $settings->get( 'pii_retention_days', 30 ) );
+ $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
+
+ return (int) $wpdb->get_var( $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}sg_mcf_orders
+ WHERE status IN ('delivered', 'cancelled', 'failed')
+ AND customer_name != %s
+ AND (updated_at < %s OR delivered_at < %s)",
+ self::REDACTED, $cutoff, $cutoff
+ ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Ads/AdsClient.php b/sg-commerce-pro/src/Suite/Ads/AdsClient.php
new file mode 100644
index 0000000..c2600c2
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Ads/AdsClient.php
@@ -0,0 +1,277 @@
+ 'https://advertising-api.amazon.com',
+ 'eu' => 'https://advertising-api-eu.amazon.com',
+ 'fe' => 'https://advertising-api-fe.amazon.com',
+ );
+
+ private const TOKEN_URL = 'https://api.amazon.com/auth/o2/token';
+ private const CACHE_KEY = 'ads_access_token';
+ private const MAX_RETRY = 4;
+
+ public function __construct(
+ private SuiteSettings $settings,
+ private CacheManager $cache,
+ private Logger $logger,
+ ) {}
+
+ public function has_credentials(): bool {
+ return '' !== $this->client_id()
+ && '' !== $this->client_secret()
+ && '' !== $this->settings->secret( 'ads_refresh_token' );
+ }
+
+ public function is_ready(): bool {
+ return $this->has_credentials() && '' !== $this->settings->string( 'suite_ads_profile_id' );
+ }
+
+ /** Ads may reuse the SP-API LWA app, so fall back to its client id/secret. */
+ private function client_id(): string {
+ $id = $this->settings->secret( 'ads_client_id' );
+ return '' !== $id ? $id : $this->settings->secret( 'amazon_client_id' );
+ }
+
+ private function client_secret(): string {
+ $s = $this->settings->secret( 'ads_client_secret' );
+ return '' !== $s ? $s : $this->settings->secret( 'amazon_client_secret' );
+ }
+
+ public function access_token( bool $force = false ): string {
+ if ( ! $force ) {
+ $cached = $this->cache->get( self::CACHE_KEY );
+ if ( is_string( $cached ) && '' !== $cached ) {
+ return $cached;
+ }
+ }
+ $res = wp_remote_post( self::TOKEN_URL, array(
+ 'timeout' => 20,
+ 'body' => array(
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $this->settings->secret( 'ads_refresh_token' ),
+ 'client_id' => $this->client_id(),
+ 'client_secret' => $this->client_secret(),
+ ),
+ ) );
+ if ( is_wp_error( $res ) ) {
+ throw new \RuntimeException( 'Ads LWA network error: ' . $res->get_error_message() );
+ }
+ $json = json_decode( (string) wp_remote_retrieve_body( $res ), true );
+ if ( 200 !== (int) wp_remote_retrieve_response_code( $res ) || empty( $json['access_token'] ) ) {
+ $msg = is_array( $json ) ? (string) ( $json['error_description'] ?? $json['error'] ?? 'unknown' ) : 'unknown';
+ throw new \RuntimeException( 'Ads LWA token refresh failed: ' . $msg );
+ }
+ $ttl = max( 60, (int) ( $json['expires_in'] ?? 3600 ) - 120 );
+ $this->cache->set( self::CACHE_KEY, (string) $json['access_token'], $ttl );
+ return (string) $json['access_token'];
+ }
+
+ public function clear_token(): void {
+ $this->cache->delete( self::CACHE_KEY );
+ }
+
+ /** GET /v2/profiles — no scope header. */
+ public function profiles(): array {
+ return $this->call( 'GET', '/v2/profiles', null, 'application/json', false );
+ }
+
+ /**
+ * Generic list with nextToken pagination (SP v3 "/list" endpoints).
+ *
+ * @param string $path e.g. /sp/campaigns/list
+ * @param string $media e.g. application/vnd.spCampaign.v3+json
+ * @param string $key response array key, e.g. campaigns
+ */
+ public function list_all( string $path, string $media, string $key, array $body = array(), int $max_pages = 20 ): array {
+ $out = array();
+ $token = null;
+ $pages = 0;
+ do {
+ $req = $body + array( 'maxResults' => 500 );
+ if ( null !== $token ) {
+ $req['nextToken'] = $token;
+ }
+ $res = $this->call( 'POST', $path, $req, $media );
+ $out = array_merge( $out, (array) ( $res[ $key ] ?? array() ) );
+ $token = isset( $res['nextToken'] ) && '' !== $res['nextToken'] ? (string) $res['nextToken'] : null;
+ $pages++;
+ } while ( null !== $token && $pages < $max_pages );
+ return $out;
+ }
+
+ public function campaigns(): array {
+ return $this->list_all( '/sp/campaigns/list', 'application/vnd.spCampaign.v3+json', 'campaigns', array(
+ 'stateFilter' => array( 'include' => array( 'ENABLED', 'PAUSED' ) ),
+ ) );
+ }
+
+ public function keywords(): array {
+ return $this->list_all( '/sp/keywords/list', 'application/vnd.spKeyword.v3+json', 'keywords', array(
+ 'stateFilter' => array( 'include' => array( 'ENABLED', 'PAUSED' ) ),
+ ) );
+ }
+
+ public function targets(): array {
+ return $this->list_all( '/sp/targets/list', 'application/vnd.spTargetingClause.v3+json', 'targetingClauses', array(
+ 'stateFilter' => array( 'include' => array( 'ENABLED', 'PAUSED' ) ),
+ ) );
+ }
+
+ /** @param array $updates */
+ public function update_keywords( array $updates ): array {
+ return $this->call( 'PUT', '/sp/keywords', array( 'keywords' => array_values( $updates ) ), 'application/vnd.spKeyword.v3+json' );
+ }
+
+ /** @param array $updates */
+ public function update_targets( array $updates ): array {
+ return $this->call( 'PUT', '/sp/targets', array( 'targetingClauses' => array_values( $updates ) ), 'application/vnd.spTargetingClause.v3+json' );
+ }
+
+ public function create_keywords( array $keywords ): array {
+ return $this->call( 'POST', '/sp/keywords', array( 'keywords' => array_values( $keywords ) ), 'application/vnd.spKeyword.v3+json' );
+ }
+
+ public function create_negative_keywords( array $negatives ): array {
+ return $this->call( 'POST', '/sp/negativeKeywords', array( 'negativeKeywords' => array_values( $negatives ) ), 'application/vnd.spNegativeKeyword.v3+json' );
+ }
+
+ public function update_campaigns( array $campaigns ): array {
+ return $this->call( 'PUT', '/sp/campaigns', array( 'campaigns' => array_values( $campaigns ) ), 'application/vnd.spCampaign.v3+json' );
+ }
+
+ /** Keyword ideas for ASINs (SP keyword recommendations v3). */
+ public function keyword_recommendations( array $asins, string $locale = 'en_US', int $max = 200 ): array {
+ return $this->call( 'POST', '/sp/targets/keywords/recommendations', array(
+ 'recommendationType' => 'KEYWORDS_FOR_ASINS',
+ 'asins' => array_values( array_slice( $asins, 0, 50 ) ),
+ 'maxRecommendations' => min( 200, $max ),
+ 'sortDimension' => 'DEFAULT',
+ 'locale' => $locale,
+ ), 'application/vnd.spkeywordsrecommendation.v3+json' );
+ }
+
+ /** Reporting v3: request an async report. Returns reportId. */
+ public function create_report( string $name, string $type_id, array $group_by, array $columns, string $start, string $end ): string {
+ $res = $this->call( 'POST', '/reporting/reports', array(
+ 'name' => $name,
+ 'startDate' => $start,
+ 'endDate' => $end,
+ 'configuration' => array(
+ 'adProduct' => 'SPONSORED_PRODUCTS',
+ 'groupBy' => $group_by,
+ 'columns' => $columns,
+ 'reportTypeId' => $type_id,
+ 'timeUnit' => 'DAILY',
+ 'format' => 'GZIP_JSON',
+ ),
+ ), 'application/vnd.createasyncreportrequest.v3+json' );
+ $id = (string) ( $res['reportId'] ?? '' );
+ if ( '' === $id ) {
+ throw new \RuntimeException( 'Ads createReport returned no reportId.' );
+ }
+ return $id;
+ }
+
+ /** @return array{status:string, url?:string, failureReason?:string} */
+ public function report_status( string $report_id ): array {
+ return $this->call( 'GET', '/reporting/reports/' . rawurlencode( $report_id ), null, 'application/vnd.createasyncreportrequest.v3+json' );
+ }
+
+ public function download( string $url ): string {
+ $res = wp_remote_get( $url, array( 'timeout' => 120 ) );
+ if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
+ throw new \RuntimeException( 'Ads report download failed.' );
+ }
+ return (string) wp_remote_retrieve_body( $res );
+ }
+
+ /** Connection test: token + profiles. */
+ public function test(): array {
+ $this->clear_token();
+ $this->access_token( true );
+ $profiles = $this->profiles();
+ return array(
+ 'ok' => true,
+ 'profiles' => array_map( static fn( $p ) => array(
+ 'profileId' => (string) ( $p['profileId'] ?? '' ),
+ 'countryCode' => (string) ( $p['countryCode'] ?? '' ),
+ 'currency' => (string) ( $p['currencyCode'] ?? '' ),
+ 'type' => (string) ( $p['accountInfo']['type'] ?? '' ),
+ 'name' => (string) ( $p['accountInfo']['name'] ?? '' ),
+ ), $profiles ),
+ );
+ }
+
+ private function call( string $method, string $path, ?array $body, string $media, bool $scoped = true ): array {
+ if ( ! $this->has_credentials() ) {
+ throw new \RuntimeException( 'Amazon Ads API credentials are not configured.' );
+ }
+ $host = self::HOSTS[ $this->settings->string( 'suite_ads_region' ) ] ?? self::HOSTS['na'];
+ $last = '';
+ for ( $attempt = 1; $attempt <= self::MAX_RETRY; $attempt++ ) {
+ $headers = array(
+ 'Authorization' => 'Bearer ' . $this->access_token( $attempt > 1 && str_contains( $last, '401' ) ),
+ 'Amazon-Advertising-API-ClientId' => $this->client_id(),
+ 'Accept' => $media,
+ 'Content-Type' => $media,
+ );
+ if ( $scoped ) {
+ $headers['Amazon-Advertising-API-Scope'] = $this->settings->string( 'suite_ads_profile_id' );
+ }
+ $args = array( 'method' => $method, 'timeout' => 45, 'headers' => $headers );
+ if ( null !== $body ) {
+ $args['body'] = wp_json_encode( $body );
+ }
+ $res = wp_remote_request( $host . $path, $args );
+ if ( is_wp_error( $res ) ) {
+ $last = 'network: ' . $res->get_error_message();
+ sleep( $attempt );
+ continue;
+ }
+ $code = (int) wp_remote_retrieve_response_code( $res );
+ $raw = (string) wp_remote_retrieve_body( $res );
+ $json = json_decode( $raw, true );
+ if ( in_array( $code, array( 401, 429, 500, 502, 503, 504 ), true ) ) {
+ $last = "HTTP {$code}";
+ $retry = (int) wp_remote_retrieve_header( $res, 'retry-after' );
+ sleep( min( 10, max( $attempt, $retry ) ) );
+ continue;
+ }
+ if ( $code >= 400 ) {
+ $msg = is_array( $json ) ? (string) ( $json['message'] ?? $json['details'] ?? $json['code'] ?? '' ) : substr( $raw, 0, 200 );
+ throw new \RuntimeException( "Ads API HTTP {$code}" . ( '' !== $msg ? " — {$msg}" : '' ) );
+ }
+ return is_array( $json ) ? $json : array();
+ }
+ throw new \RuntimeException( 'Ads API failed after retries: ' . $last );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Ads/AdsInsights.php b/sg-commerce-pro/src/Suite/Ads/AdsInsights.php
new file mode 100644
index 0000000..b3e1baf
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Ads/AdsInsights.php
@@ -0,0 +1,115 @@
+ 0 ? round( (float) $s['cost'] / (float) $s['sales'] * 100, 2 ) : null;
+ }
+ $wasted = (float) Db::var(
+ 'SELECT COALESCE(SUM(cost),0) FROM (SELECT search_term, SUM(cost) AS cost, SUM(orders) AS o FROM {t:ads_search_terms}
+ WHERE report_date BETWEEN %s AND %s GROUP BY search_term) x WHERE x.o = 0',
+ array( $from, $to )
+ );
+ return array( 'totals' => self::kpis( $t ) + array( 'wasted_spend' => round( $wasted, 2 ) ), 'series' => $series );
+ }
+
+ public function campaigns( string $from, string $to ): array {
+ $rows = Db::rows(
+ "SELECT c.campaign_id, c.name, c.state, c.targeting_type, c.daily_budget, c.bidding_strategy,
+ COALESCE(SUM(d.impressions),0) AS impressions, COALESCE(SUM(d.clicks),0) AS clicks, COALESCE(SUM(d.cost),0) AS cost,
+ COALESCE(SUM(d.sales),0) AS sales, COALESCE(SUM(d.orders),0) AS orders, COALESCE(SUM(d.units),0) AS units
+ FROM {t:ads_campaigns} c
+ LEFT JOIN {t:ads_daily} d ON d.campaign_id = c.campaign_id AND d.level = 'campaign' AND d.report_date BETWEEN %s AND %s
+ GROUP BY c.campaign_id, c.name, c.state, c.targeting_type, c.daily_budget, c.bidding_strategy
+ ORDER BY cost DESC",
+ array( $from, $to )
+ );
+ return array_map( static fn( $r ) => self::kpis( $r ) + $r, $rows );
+ }
+
+ public function targets( string $from, string $to, string $campaign = '' ): array {
+ $args = array( $from, $to );
+ $where = "t.state <> 'ARCHIVED'";
+ if ( '' !== $campaign ) {
+ $where .= ' AND t.campaign_id = %s';
+ $args[] = $campaign;
+ }
+ $rows = Db::rows(
+ "SELECT t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text, t.match_type, t.state, t.bid, c.name AS campaign,
+ COALESCE(SUM(d.impressions),0) AS impressions, COALESCE(SUM(d.clicks),0) AS clicks, COALESCE(SUM(d.cost),0) AS cost,
+ COALESCE(SUM(d.sales),0) AS sales, COALESCE(SUM(d.orders),0) AS orders, COALESCE(SUM(d.units),0) AS units
+ FROM {t:ads_targets} t
+ LEFT JOIN {t:ads_campaigns} c ON c.campaign_id = t.campaign_id
+ LEFT JOIN {t:ads_daily} d ON d.target_id = t.target_id AND d.level = 'target' AND d.report_date BETWEEN %s AND %s
+ WHERE {$where}
+ GROUP BY t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text, t.match_type, t.state, t.bid, c.name
+ ORDER BY cost DESC LIMIT 1000",
+ $args
+ );
+ return array_map( static fn( $r ) => self::kpis( $r ) + $r, $rows );
+ }
+
+ public function search_terms( string $from, string $to ): array {
+ $rows = Db::rows(
+ "SELECT search_term, MAX(keyword_text) AS keyword_text, MAX(match_type) AS match_type, COUNT(DISTINCT campaign_id) AS campaigns,
+ SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(cost) AS cost, SUM(sales) AS sales, SUM(orders) AS orders
+ FROM {t:ads_search_terms} WHERE report_date BETWEEN %s AND %s
+ GROUP BY search_term ORDER BY SUM(cost) DESC LIMIT 1000",
+ array( $from, $to )
+ );
+ return array_map( static fn( $r ) => self::kpis( $r ) + $r, $rows );
+ }
+
+ public function actions( string $status = 'planned' ): array {
+ return Db::rows( 'SELECT * FROM {t:ads_actions} WHERE status = %s ORDER BY kind, id LIMIT 1000', array( $status ) );
+ }
+
+ public function history(): array {
+ return Db::rows( "SELECT * FROM {t:ads_actions} WHERE status IN ('applied','failed','dismissed') ORDER BY COALESCE(applied_at, created_at) DESC LIMIT 300" );
+ }
+
+ private static function kpis( array $r ): array {
+ $cost = (float) ( $r['cost'] ?? 0 );
+ $sales = (float) ( $r['sales'] ?? 0 );
+ $clk = (int) ( $r['clicks'] ?? 0 );
+ $imp = (int) ( $r['impressions'] ?? 0 );
+ $ord = (int) ( $r['orders'] ?? 0 );
+ return array(
+ 'impressions' => $imp,
+ 'clicks' => $clk,
+ 'cost' => round( $cost, 2 ),
+ 'sales' => round( $sales, 2 ),
+ 'orders' => $ord,
+ 'acos' => $sales > 0 ? round( $cost / $sales * 100, 2 ) : null,
+ 'roas' => $cost > 0 ? round( $sales / $cost, 2 ) : null,
+ 'ctr' => $imp > 0 ? round( $clk / $imp * 100, 2 ) : null,
+ 'cpc' => $clk > 0 ? round( $cost / $clk, 2 ) : null,
+ 'cvr' => $clk > 0 ? round( $ord / $clk * 100, 2 ) : null,
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Ads/AdsSync.php b/sg-commerce-pro/src/Suite/Ads/AdsSync.php
new file mode 100644
index 0000000..85c6644
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Ads/AdsSync.php
@@ -0,0 +1,424 @@
+ array(
+ 'group' => array( 'campaign' ),
+ 'columns' => array( 'date', 'campaignId', 'campaignName', 'campaignStatus', 'campaignBudgetAmount', 'impressions', 'clicks', 'cost', 'sales7d', 'purchases7d', 'unitsSoldClicks7d' ),
+ ),
+ 'spTargeting' => array(
+ 'group' => array( 'targeting' ),
+ 'columns' => array( 'date', 'campaignId', 'adGroupId', 'keywordId', 'keyword', 'matchType', 'targeting', 'keywordType', 'impressions', 'clicks', 'cost', 'sales7d', 'purchases7d', 'unitsSoldClicks7d' ),
+ ),
+ 'spSearchTerm' => array(
+ 'group' => array( 'searchTerm' ),
+ 'columns' => array( 'date', 'campaignId', 'adGroupId', 'keywordId', 'keyword', 'matchType', 'searchTerm', 'impressions', 'clicks', 'cost', 'sales7d', 'purchases7d' ),
+ ),
+ );
+
+ public function __construct(
+ private AdsClient $ads,
+ private SuiteSettings $settings,
+ private Logger $logger,
+ ) {}
+
+ public function profile(): string {
+ return $this->settings->string( 'suite_ads_profile_id' );
+ }
+
+ /** @return array{campaigns:int, targets:int} */
+ public function sync_structure(): array {
+ $profile = $this->profile();
+ $now = Db::now();
+ $nc = 0;
+ foreach ( $this->ads->campaigns() as $c ) {
+ Db::upsert( 'ads_campaigns', array(
+ 'profile_id' => $profile,
+ 'campaign_id' => (string) ( $c['campaignId'] ?? '' ),
+ 'name' => mb_substr( (string) ( $c['name'] ?? '' ), 0, 255 ),
+ 'state' => (string) ( $c['state'] ?? '' ),
+ 'targeting_type' => (string) ( $c['targetingType'] ?? '' ),
+ 'daily_budget' => (float) ( $c['budget']['budget'] ?? 0 ),
+ 'bidding_strategy' => (string) ( $c['dynamicBidding']['strategy'] ?? '' ),
+ 'start_date' => ! empty( $c['startDate'] ) ? substr( (string) $c['startDate'], 0, 10 ) : null,
+ 'updated_at' => $now,
+ 'is_demo' => 0,
+ ) );
+ $nc++;
+ }
+ $nt = 0;
+ foreach ( $this->ads->keywords() as $k ) {
+ Db::upsert( 'ads_targets', array(
+ 'profile_id' => $profile,
+ 'target_id' => (string) ( $k['keywordId'] ?? '' ),
+ 'kind' => 'keyword',
+ 'campaign_id' => (string) ( $k['campaignId'] ?? '' ),
+ 'ad_group_id' => (string) ( $k['adGroupId'] ?? '' ),
+ 'keyword_text' => mb_substr( (string) ( $k['keywordText'] ?? '' ), 0, 255 ),
+ 'match_type' => (string) ( $k['matchType'] ?? '' ),
+ 'state' => (string) ( $k['state'] ?? '' ),
+ 'bid' => isset( $k['bid'] ) ? (float) $k['bid'] : null,
+ 'updated_at' => $now,
+ 'is_demo' => 0,
+ ) );
+ $nt++;
+ }
+ foreach ( $this->ads->targets() as $t ) {
+ $expr = array();
+ foreach ( (array) ( $t['expression'] ?? array() ) as $e ) {
+ $expr[] = strtolower( (string) ( $e['type'] ?? '' ) ) . ( isset( $e['value'] ) ? '=' . $e['value'] : '' );
+ }
+ Db::upsert( 'ads_targets', array(
+ 'profile_id' => $profile,
+ 'target_id' => (string) ( $t['targetId'] ?? '' ),
+ 'kind' => 'target',
+ 'campaign_id' => (string) ( $t['campaignId'] ?? '' ),
+ 'ad_group_id' => (string) ( $t['adGroupId'] ?? '' ),
+ 'keyword_text' => mb_substr( implode( ' ', $expr ), 0, 255 ),
+ 'match_type' => (string) ( $t['expressionType'] ?? '' ),
+ 'state' => (string) ( $t['state'] ?? '' ),
+ 'bid' => isset( $t['bid'] ) ? (float) $t['bid'] : null,
+ 'updated_at' => $now,
+ 'is_demo' => 0,
+ ) );
+ $nt++;
+ }
+ return array( 'campaigns' => $nc, 'targets' => $nt );
+ }
+
+ /**
+ * Request the three daily reports for [start, end] (max 31 days).
+ *
+ * @return array type => remote report id
+ */
+ public function request_reports( string $start, string $end ): array {
+ $out = array();
+ foreach ( self::REPORTS as $type => $spec ) {
+ try {
+ $id = $this->ads->create_report( "sg-{$type}-{$start}-{$end}", $type, $spec['group'], $spec['columns'], $start, $end );
+ Db::insert( 'reports', array(
+ 'report_type' => $type,
+ 'market' => '',
+ 'source' => 'ads',
+ 'remote_id' => $id,
+ 'status' => 'requested',
+ 'data_start' => $start . ' 00:00:00',
+ 'data_end' => $end . ' 23:59:59',
+ 'requested_at' => Db::now(),
+ ) );
+ $out[ $type ] = $id;
+ } catch ( \Throwable $e ) {
+ // 425 = duplicate request already in flight; anything else is logged.
+ $this->logger->warning( 'Ads report request failed', array( 'type' => $type, 'error' => $e->getMessage() ) );
+ }
+ }
+ return $out;
+ }
+
+ /** Ingest a downloaded GZIP_JSON report. */
+ public function ingest( string $type, string $raw, bool $demo = false ): int {
+ $rows = ReportParser::json( $raw );
+ $profile = $this->profile();
+ $n = 0;
+ foreach ( $rows as $r ) {
+ if ( ! is_array( $r ) ) {
+ continue;
+ }
+ $date = substr( (string) ( $r['date'] ?? '' ), 0, 10 );
+ if ( '' === $date ) {
+ continue;
+ }
+ if ( 'spSearchTerm' === $type ) {
+ $term = mb_substr( (string) ( $r['searchTerm'] ?? '' ), 0, 255 );
+ Db::upsert( 'ads_search_terms', array(
+ 'profile_id' => $profile,
+ 'row_hash' => sha1( implode( '|', array( $profile, $date, $r['campaignId'] ?? '', $r['adGroupId'] ?? '', $r['keywordId'] ?? '', $term ) ) ),
+ 'report_date' => $date,
+ 'campaign_id' => (string) ( $r['campaignId'] ?? '' ),
+ 'ad_group_id' => (string) ( $r['adGroupId'] ?? '' ),
+ 'target_id' => (string) ( $r['keywordId'] ?? '' ),
+ 'keyword_text' => mb_substr( (string) ( $r['keyword'] ?? $r['targeting'] ?? '' ), 0, 255 ),
+ 'match_type' => (string) ( $r['matchType'] ?? '' ),
+ 'search_term' => $term,
+ 'impressions' => (int) ( $r['impressions'] ?? 0 ),
+ 'clicks' => (int) ( $r['clicks'] ?? 0 ),
+ 'cost' => (float) ( $r['cost'] ?? 0 ),
+ 'sales' => (float) ( $r['sales7d'] ?? 0 ),
+ 'orders' => (int) ( $r['purchases7d'] ?? 0 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ } else {
+ $is_campaign = 'spCampaigns' === $type;
+ Db::upsert( 'ads_daily', array(
+ 'profile_id' => $profile,
+ 'report_date' => $date,
+ 'level' => $is_campaign ? 'campaign' : 'target',
+ 'campaign_id' => (string) ( $r['campaignId'] ?? '' ),
+ 'ad_group_id' => (string) ( $r['adGroupId'] ?? '' ),
+ 'target_id' => $is_campaign ? '' : (string) ( $r['keywordId'] ?? '' ),
+ 'keyword_text' => mb_substr( (string) ( $r['keyword'] ?? $r['targeting'] ?? $r['campaignName'] ?? '' ), 0, 255 ),
+ 'match_type' => (string) ( $r['matchType'] ?? '' ),
+ 'impressions' => (int) ( $r['impressions'] ?? 0 ),
+ 'clicks' => (int) ( $r['clicks'] ?? 0 ),
+ 'cost' => (float) ( $r['cost'] ?? 0 ),
+ 'sales' => (float) ( $r['sales7d'] ?? 0 ),
+ 'orders' => (int) ( $r['purchases7d'] ?? 0 ),
+ 'units' => (int) ( $r['unitsSoldClicks7d'] ?? 0 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ }
+ $n++;
+ }
+ return $n;
+ }
+
+ /**
+ * Build a fresh optimisation plan. Previously planned (unapplied)
+ * actions are superseded so the queue never shows stale advice.
+ *
+ * @return array{bids:int, terms:int, budgets:int}
+ */
+ public function optimize( ?string $today = null ): array {
+ $today = $today ?? gmdate( 'Y-m-d' );
+ $lookback = max( 3, $this->settings->int( 'suite_ads_lookback_days' ) );
+ $from = gmdate( 'Y-m-d', strtotime( $today . " -{$lookback} days" ) );
+ // Ads attribution lags 1–2 days; exclude the most recent 2 days.
+ $to = gmdate( 'Y-m-d', strtotime( $today . ' -2 days' ) );
+
+ $aov = (float) Db::var(
+ "SELECT CASE WHEN SUM(orders) > 0 THEN SUM(sales)/SUM(orders) ELSE 0 END FROM {t:ads_daily}
+ WHERE level = 'campaign' AND report_date BETWEEN %s AND %s",
+ array( $from, $to )
+ );
+ $opt = new BidOptimizer( array(
+ 'target_acos' => $this->settings->float( 'suite_ads_target_acos' ),
+ 'min_clicks' => $this->settings->int( 'suite_ads_min_clicks' ),
+ 'max_step_pct' => $this->settings->float( 'suite_ads_max_step_pct' ),
+ 'min_bid' => $this->settings->float( 'suite_ads_min_bid' ),
+ 'max_bid' => $this->settings->float( 'suite_ads_max_bid' ),
+ 'raise_low_impr' => $this->settings->bool( 'suite_ads_raise_low_impr' ),
+ 'neg_min_clicks' => $this->settings->int( 'suite_ads_neg_min_clicks' ),
+ 'harvest_min_orders' => $this->settings->int( 'suite_ads_harvest_min_orders' ),
+ 'avg_order_value' => $aov,
+ ) );
+
+ $targets = Db::rows(
+ "SELECT t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text AS label, t.state, t.bid,
+ COALESCE(SUM(d.impressions),0) AS impressions, COALESCE(SUM(d.clicks),0) AS clicks,
+ COALESCE(SUM(d.cost),0) AS cost, COALESCE(SUM(d.sales),0) AS sales, COALESCE(SUM(d.orders),0) AS orders
+ FROM {t:ads_targets} t
+ LEFT JOIN {t:ads_daily} d ON d.target_id = t.target_id AND d.level = 'target' AND d.report_date BETWEEN %s AND %s
+ WHERE t.state = 'ENABLED' AND t.bid IS NOT NULL
+ GROUP BY t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text, t.state, t.bid",
+ array( $from, $to )
+ );
+ $targets = array_map( static fn( $r ) => array(
+ 'target_id' => (string) $r['target_id'], 'kind' => (string) $r['kind'], 'campaign_id' => (string) $r['campaign_id'],
+ 'ad_group_id' => (string) $r['ad_group_id'], 'label' => (string) $r['label'], 'state' => (string) $r['state'],
+ 'bid' => (float) $r['bid'], 'impressions' => (int) $r['impressions'], 'clicks' => (int) $r['clicks'],
+ 'cost' => (float) $r['cost'], 'sales' => (float) $r['sales'], 'orders' => (int) $r['orders'],
+ ), $targets );
+
+ $terms = Db::rows(
+ "SELECT search_term, campaign_id, ad_group_id, MAX(keyword_text) AS keyword_text, MAX(match_type) AS match_type,
+ SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(cost) AS cost, SUM(sales) AS sales, SUM(orders) AS orders
+ FROM {t:ads_search_terms} WHERE report_date BETWEEN %s AND %s
+ GROUP BY search_term, campaign_id, ad_group_id",
+ array( $from, $to )
+ );
+ $exact = array();
+ foreach ( Db::rows( "SELECT keyword_text FROM {t:ads_targets} WHERE kind = 'keyword' AND match_type = 'EXACT' AND state <> 'ARCHIVED'" ) as $k ) {
+ $exact[ strtolower( (string) $k['keyword_text'] ) ] = true;
+ }
+ $neg = array();
+ foreach ( Db::rows( "SELECT ad_group_id, entity_id FROM {t:ads_actions} WHERE kind = 'negative' AND status = 'applied'" ) as $k ) {
+ $neg[ $k['ad_group_id'] . '|' . strtolower( (string) $k['entity_id'] ) ] = true;
+ }
+
+ $campaigns = Db::rows(
+ "SELECT c.campaign_id, c.name, c.state, c.daily_budget,
+ COUNT(DISTINCT d.report_date) AS days, COALESCE(SUM(d.cost),0) AS cost, COALESCE(SUM(d.sales),0) AS sales
+ FROM {t:ads_campaigns} c
+ LEFT JOIN {t:ads_daily} d ON d.campaign_id = c.campaign_id AND d.level = 'campaign' AND d.report_date BETWEEN %s AND %s
+ GROUP BY c.campaign_id, c.name, c.state, c.daily_budget",
+ array( gmdate( 'Y-m-d', strtotime( $today . ' -9 days' ) ), $to )
+ );
+
+ $plan = array_merge(
+ $opt->bids( $targets ),
+ $opt->search_terms(
+ array_map( static fn( $r ) => array_merge( $r, array(
+ 'impressions' => (int) $r['impressions'], 'clicks' => (int) $r['clicks'], 'cost' => (float) $r['cost'],
+ 'sales' => (float) $r['sales'], 'orders' => (int) $r['orders'],
+ ) ), $terms ),
+ $exact, $neg,
+ $this->settings->string( 'suite_ads_harvest_campaign' ),
+ $this->settings->string( 'suite_ads_harvest_ad_group' )
+ ),
+ $opt->budgets( array_map( static fn( $r ) => array(
+ 'campaign_id' => (string) $r['campaign_id'], 'name' => (string) $r['name'], 'state' => (string) $r['state'],
+ 'daily_budget' => (float) $r['daily_budget'], 'days' => (int) $r['days'], 'cost' => (float) $r['cost'], 'sales' => (float) $r['sales'],
+ ), $campaigns ) )
+ );
+
+ Db::exec( "UPDATE {t:ads_actions} SET status = 'superseded' WHERE status = 'planned'" );
+ $demo = (int) Db::var( 'SELECT COUNT(*) FROM {t:ads_targets} WHERE is_demo = 1' ) > 0 ? 1 : 0;
+ $counts = array( 'bids' => 0, 'terms' => 0, 'budgets' => 0 );
+ foreach ( $plan as $a ) {
+ Db::insert( 'ads_actions', array(
+ 'profile_id' => $this->profile(),
+ 'kind' => $a['kind'],
+ 'entity_id' => $a['entity_id'],
+ 'campaign_id' => $a['campaign_id'],
+ 'ad_group_id' => $a['ad_group_id'],
+ 'label' => mb_substr( $a['label'], 0, 255 ),
+ 'old_value' => $a['old_value'],
+ 'new_value' => $a['new_value'],
+ 'reason' => $a['reason'],
+ 'payload' => wp_json_encode( $a['payload'] ),
+ 'status' => 'planned',
+ 'created_at' => Db::now(),
+ 'is_demo' => $demo,
+ ) );
+ $key = match ( $a['kind'] ) {
+ 'bid', 'pause' => 'bids',
+ 'budget' => 'budgets',
+ default => 'terms',
+ };
+ $counts[ $key ]++;
+ }
+ if ( $this->settings->bool( 'suite_ads_auto_apply' ) && $this->ads->is_ready() ) {
+ $ids = array_map( 'intval', array_column( Db::rows( "SELECT id FROM {t:ads_actions} WHERE status = 'planned' AND kind IN ('bid','negative') AND is_demo = 0" ), 'id' ) );
+ if ( $ids ) {
+ $this->apply( $ids );
+ }
+ }
+ return $counts;
+ }
+
+ /**
+ * Push planned actions to Amazon. Demo actions are marked applied
+ * without network calls.
+ *
+ * @param int[] $ids
+ * @return array{applied:int, failed:int}
+ */
+ public function apply( array $ids ): array {
+ $ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
+ $res = array( 'applied' => 0, 'failed' => 0 );
+ if ( ! $ids ) {
+ return $res;
+ }
+ $rows = Db::rows( 'SELECT * FROM {t:ads_actions} WHERE status = %s AND id IN (' . Db::in( $ids, '%d' ) . ')', array_merge( array( 'planned' ), $ids ) );
+ foreach ( $rows as $a ) {
+ try {
+ if ( ! (int) $a['is_demo'] ) {
+ $this->push( $a );
+ }
+ Db::update( 'ads_actions', array( 'status' => 'applied', 'applied_at' => Db::now(), 'error' => null ), array( 'id' => (int) $a['id'] ) );
+ $this->reflect_locally( $a );
+ $res['applied']++;
+ } catch ( \Throwable $e ) {
+ Db::update( 'ads_actions', array( 'status' => 'failed', 'error' => mb_substr( $e->getMessage(), 0, 500 ) ), array( 'id' => (int) $a['id'] ) );
+ $res['failed']++;
+ }
+ }
+ return $res;
+ }
+
+ public function dismiss( array $ids ): int {
+ $ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
+ if ( ! $ids ) {
+ return 0;
+ }
+ return Db::exec( "UPDATE {t:ads_actions} SET status = 'dismissed' WHERE status = 'planned' AND id IN (" . Db::in( $ids, '%d' ) . ')', $ids );
+ }
+
+ private function push( array $a ): void {
+ $payload = json_decode( (string) $a['payload'], true ) ?: array();
+ $errors = static function ( array $resp, string $key ): void {
+ $err = $resp[ $key ]['error'] ?? array();
+ if ( ! empty( $err ) ) {
+ $first = $err[0]['errors'][0]['errorValue'] ?? $err[0] ?? array();
+ throw new \RuntimeException( 'Amazon rejected: ' . wp_json_encode( $first ) );
+ }
+ };
+ switch ( $a['kind'] ) {
+ case 'bid':
+ case 'pause':
+ $upd = 'pause' === $a['kind'] ? array( 'state' => 'PAUSED' ) : array( 'bid' => (float) $a['new_value'] );
+ if ( 'target' === ( $payload['target_kind'] ?? 'keyword' ) ) {
+ $errors( $this->ads->update_targets( array( array( 'targetId' => (string) $a['entity_id'] ) + $upd ) ), 'targetingClauses' );
+ } else {
+ $errors( $this->ads->update_keywords( array( array( 'keywordId' => (string) $a['entity_id'] ) + $upd ) ), 'keywords' );
+ }
+ break;
+ case 'negative':
+ $errors( $this->ads->create_negative_keywords( array( array(
+ 'campaignId' => (string) $a['campaign_id'], 'adGroupId' => (string) $a['ad_group_id'],
+ 'keywordText' => (string) $a['entity_id'], 'matchType' => 'NEGATIVE_EXACT', 'state' => 'ENABLED',
+ ) ) ), 'negativeKeywords' );
+ break;
+ case 'harvest':
+ if ( '' === (string) $a['ad_group_id'] ) {
+ throw new \RuntimeException( 'Set a harvest destination ad group in Suite settings first.' );
+ }
+ $errors( $this->ads->create_keywords( array( array(
+ 'campaignId' => (string) $a['campaign_id'], 'adGroupId' => (string) $a['ad_group_id'],
+ 'keywordText' => (string) $a['entity_id'], 'matchType' => 'EXACT', 'state' => 'ENABLED',
+ 'bid' => (float) ( $payload['bid'] ?? 0.5 ),
+ ) ) ), 'keywords' );
+ // Prevent the source (auto/broad) ad group from cannibalising the new exact keyword.
+ if ( ! empty( $payload['source_ad_group_id'] ) && $payload['source_ad_group_id'] !== $a['ad_group_id'] ) {
+ $this->ads->create_negative_keywords( array( array(
+ 'campaignId' => (string) $payload['source_campaign_id'], 'adGroupId' => (string) $payload['source_ad_group_id'],
+ 'keywordText' => (string) $a['entity_id'], 'matchType' => 'NEGATIVE_EXACT', 'state' => 'ENABLED',
+ ) ) );
+ }
+ break;
+ case 'budget':
+ $errors( $this->ads->update_campaigns( array( array(
+ 'campaignId' => (string) $a['campaign_id'],
+ 'budget' => array( 'budget' => (float) $a['new_value'], 'budgetType' => 'DAILY' ),
+ ) ) ), 'campaigns' );
+ break;
+ default:
+ throw new \RuntimeException( 'This action type must be applied manually in Campaign Manager (ASIN targeting).' );
+ }
+ }
+
+ /** Mirror an applied change into the local tables so the UI is consistent before the next sync. */
+ private function reflect_locally( array $a ): void {
+ match ( $a['kind'] ) {
+ 'bid' => Db::update( 'ads_targets', array( 'bid' => (float) $a['new_value'] ), array( 'target_id' => (string) $a['entity_id'] ) ),
+ 'pause' => Db::update( 'ads_targets', array( 'state' => 'PAUSED' ), array( 'target_id' => (string) $a['entity_id'] ) ),
+ 'budget' => Db::update( 'ads_campaigns', array( 'daily_budget' => (float) $a['new_value'] ), array( 'campaign_id' => (string) $a['campaign_id'] ) ),
+ default => 0,
+ };
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Ads/BidOptimizer.php b/sg-commerce-pro/src/Suite/Ads/BidOptimizer.php
new file mode 100644
index 0000000..2f42322
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Ads/BidOptimizer.php
@@ -0,0 +1,240 @@
+ 0 → target CPC = (sales / clicks) × targetACoS
+ * move bid toward target CPC, capped at ±max_step%.
+ * 2. orders = 0 and clicks ≥ min_clicks
+ * → cut bid by max_step% (bleeding spend, no conversions).
+ * If spend ≥ 2 × (avg order value × targetACoS) → pause.
+ * 3. impressions < 100 and (no data or ACoS < target)
+ * → raise bid by half the step to win visibility.
+ * Changes smaller than 3 % or 0.02 are dropped as noise.
+ * Bids always clamp to [min_bid, max_bid].
+ *
+ * Search-term logic:
+ * - Harvest: term with ≥ harvest_min_orders orders and ACoS ≤ 1.2×target,
+ * not already an exact keyword → add as EXACT (and negate in source).
+ * - Negate: term with ≥ neg_min_clicks clicks and 0 orders → NEGATIVE_EXACT
+ * in its source ad group.
+ *
+ * Budget logic:
+ * - Campaign spending ≥ 90 % of budget on average with ACoS < target
+ * → raise budget 20 %. ACoS > 1.5×target and spending at cap → cut 15 %.
+ *
+ * @package SevenGum\Commerce\Suite\Ads
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Suite\Ads;
+
+final class BidOptimizer {
+
+ /**
+ * @param array $cfg {target_acos, min_clicks, max_step_pct, min_bid, max_bid, raise_low_impr, neg_min_clicks, harvest_min_orders, avg_order_value}
+ */
+ public function __construct( private array $cfg ) {
+ $this->cfg += array(
+ 'target_acos' => 30.0,
+ 'min_clicks' => 12,
+ 'max_step_pct' => 20.0,
+ 'min_bid' => 0.20,
+ 'max_bid' => 4.00,
+ 'raise_low_impr' => true,
+ 'neg_min_clicks' => 15,
+ 'harvest_min_orders' => 2,
+ 'avg_order_value' => 0.0,
+ );
+ }
+
+ /**
+ * @param array $targets
+ * @return array
+ */
+ public function bids( array $targets ): array {
+ $t = (float) $this->cfg['target_acos'] / 100;
+ $step = (float) $this->cfg['max_step_pct'] / 100;
+ $min = (float) $this->cfg['min_bid'];
+ $max = (float) $this->cfg['max_bid'];
+ $out = array();
+
+ foreach ( $targets as $r ) {
+ if ( 'ENABLED' !== strtoupper( (string) $r['state'] ) || $r['bid'] <= 0 ) {
+ continue;
+ }
+ $bid = (float) $r['bid'];
+ $clicks = (int) $r['clicks'];
+ $orders = (int) $r['orders'];
+ $cost = (float) $r['cost'];
+ $sales = (float) $r['sales'];
+ $acos = $sales > 0 ? $cost / $sales : null;
+ $new = null;
+ $why = '';
+ $pause = false;
+
+ if ( $orders > 0 && $clicks > 0 ) {
+ $target_cpc = ( $sales / $clicks ) * $t;
+ $new = max( $bid * ( 1 - $step ), min( $bid * ( 1 + $step ), $target_cpc ) );
+ $why = sprintf(
+ 'ACoS %.1f%% vs target %.1f%% · %d clicks · %d orders · RPC %s → target CPC %s',
+ $acos * 100, $t * 100, $clicks, $orders, self::m( $sales / $clicks ), self::m( $target_cpc )
+ );
+ } elseif ( 0 === $orders && $clicks >= (int) $this->cfg['min_clicks'] ) {
+ $aov = (float) $this->cfg['avg_order_value'];
+ if ( $aov > 0 && $cost >= 2 * $aov * $t ) {
+ $pause = true;
+ $why = sprintf( 'No orders after %d clicks; spent %s (≥ 2× break-even CPA %s)', $clicks, self::m( $cost ), self::m( $aov * $t ) );
+ } else {
+ $new = $bid * ( 1 - $step );
+ $why = sprintf( 'No orders after %d clicks (spent %s)', $clicks, self::m( $cost ) );
+ }
+ } elseif ( $this->cfg['raise_low_impr'] && (int) $r['impressions'] < 100 && ( null === $acos || $acos < $t ) ) {
+ $new = $bid * ( 1 + $step / 2 );
+ $why = sprintf( 'Low visibility: %d impressions in window', (int) $r['impressions'] );
+ }
+
+ if ( $pause ) {
+ $out[] = $this->action( 'pause', $r, (string) $bid, 'PAUSED', $why );
+ continue;
+ }
+ if ( null === $new ) {
+ continue;
+ }
+ $new = round( max( $min, min( $max, $new ) ), 2 );
+ if ( abs( $new - $bid ) < 0.02 || abs( $new - $bid ) / $bid < 0.03 ) {
+ continue;
+ }
+ $out[] = $this->action( 'bid', $r, number_format( $bid, 2, '.', '' ), number_format( $new, 2, '.', '' ), $why );
+ }
+ return $out;
+ }
+
+ /**
+ * @param array $terms
+ * @param array $existing_exact lower-case exact keywords already targeted
+ * @param array $existing_neg "adgroup|term" negatives already present
+ */
+ public function search_terms( array $terms, array $existing_exact, array $existing_neg, string $harvest_campaign, string $harvest_ad_group ): array {
+ $t = (float) $this->cfg['target_acos'] / 100;
+ $out = array();
+ $harvested = array();
+ foreach ( $terms as $r ) {
+ $term = strtolower( trim( (string) $r['search_term'] ) );
+ if ( '' === $term ) {
+ continue;
+ }
+ $orders = (int) $r['orders'];
+ $clicks = (int) $r['clicks'];
+ $cost = (float) $r['cost'];
+ $sales = (float) $r['sales'];
+ $acos = $sales > 0 ? $cost / $sales : null;
+ $is_asin = 1 === preg_match( '/^b0[a-z0-9]{8}$/', $term );
+ $neg_key = $r['ad_group_id'] . '|' . $term;
+
+ if ( $orders >= (int) $this->cfg['harvest_min_orders'] && null !== $acos && $acos <= $t * 1.2
+ && ! isset( $existing_exact[ $term ] ) && ! isset( $harvested[ $term ] ) ) {
+ $harvested[ $term ] = true;
+ $cpc = $clicks > 0 ? $cost / $clicks : (float) $this->cfg['min_bid'];
+ $bid = round( max( (float) $this->cfg['min_bid'], min( (float) $this->cfg['max_bid'], $cpc * 1.1 ) ), 2 );
+ $out[] = array(
+ 'kind' => $is_asin ? 'harvest_asin' : 'harvest',
+ 'entity_id' => $term,
+ 'campaign_id' => '' !== $harvest_campaign ? $harvest_campaign : (string) $r['campaign_id'],
+ 'ad_group_id' => '' !== $harvest_ad_group ? $harvest_ad_group : (string) $r['ad_group_id'],
+ 'label' => $term,
+ 'old_value' => '',
+ 'new_value' => $is_asin ? 'ASIN target @ ' . number_format( $bid, 2 ) : 'EXACT @ ' . number_format( $bid, 2 ),
+ 'reason' => sprintf( '%d orders · ACoS %.1f%% · from "%s" (%s)', $orders, $acos * 100, $r['keyword_text'], $r['match_type'] ),
+ 'payload' => array(
+ 'term' => $term, 'bid' => $bid, 'asin' => $is_asin,
+ 'source_campaign_id' => (string) $r['campaign_id'], 'source_ad_group_id' => (string) $r['ad_group_id'],
+ ),
+ );
+ continue;
+ }
+
+ if ( 0 === $orders && $clicks >= (int) $this->cfg['neg_min_clicks'] && ! isset( $existing_neg[ $neg_key ] ) && ! $is_asin ) {
+ $out[] = array(
+ 'kind' => 'negative',
+ 'entity_id' => $term,
+ 'campaign_id' => (string) $r['campaign_id'],
+ 'ad_group_id' => (string) $r['ad_group_id'],
+ 'label' => $term,
+ 'old_value' => '',
+ 'new_value' => 'NEGATIVE_EXACT',
+ 'reason' => sprintf( '%d clicks, 0 orders, wasted %s', $clicks, self::m( $cost ) ),
+ 'payload' => array( 'term' => $term ),
+ );
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * @param array $campaigns
+ */
+ public function budgets( array $campaigns ): array {
+ $t = (float) $this->cfg['target_acos'] / 100;
+ $out = array();
+ foreach ( $campaigns as $c ) {
+ if ( 'ENABLED' !== strtoupper( (string) $c['state'] ) || $c['daily_budget'] <= 0 || $c['days'] <= 0 ) {
+ continue;
+ }
+ $avg = $c['cost'] / $c['days'];
+ $util = $avg / $c['daily_budget'];
+ $acos = $c['sales'] > 0 ? $c['cost'] / $c['sales'] : null;
+ $new = null;
+ $why = '';
+ if ( $util >= 0.9 && null !== $acos && $acos < $t ) {
+ $new = $c['daily_budget'] * 1.2;
+ $why = sprintf( 'Budget-capped (%.0f%% used/day) while profitable (ACoS %.1f%%)', $util * 100, $acos * 100 );
+ } elseif ( $util >= 0.9 && ( null === $acos || $acos > $t * 1.5 ) ) {
+ $new = $c['daily_budget'] * 0.85;
+ $why = null === $acos
+ ? sprintf( 'Budget-capped (%.0f%%) with zero sales', $util * 100 )
+ : sprintf( 'Budget-capped (%.0f%%) at ACoS %.1f%% (> 1.5× target)', $util * 100, $acos * 100 );
+ }
+ if ( null === $new ) {
+ continue;
+ }
+ $new = max( 1.0, round( $new, 2 ) );
+ $out[] = array(
+ 'kind' => 'budget',
+ 'entity_id' => (string) $c['campaign_id'],
+ 'campaign_id' => (string) $c['campaign_id'],
+ 'ad_group_id' => '',
+ 'label' => (string) $c['name'],
+ 'old_value' => number_format( (float) $c['daily_budget'], 2, '.', '' ),
+ 'new_value' => number_format( $new, 2, '.', '' ),
+ 'reason' => $why,
+ 'payload' => array(),
+ );
+ }
+ return $out;
+ }
+
+ private function action( string $kind, array $r, string $old, string $new, string $why ): array {
+ return array(
+ 'kind' => $kind,
+ 'entity_id' => (string) $r['target_id'],
+ 'campaign_id' => (string) $r['campaign_id'],
+ 'ad_group_id' => (string) $r['ad_group_id'],
+ 'label' => (string) $r['label'],
+ 'old_value' => $old,
+ 'new_value' => $new,
+ 'reason' => $why,
+ 'payload' => array( 'target_kind' => (string) $r['kind'] ),
+ );
+ }
+
+ private static function m( float $v ): string {
+ return number_format( $v, 2 );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Demo/DemoSeeder.php b/sg-commerce-pro/src/Suite/Demo/DemoSeeder.php
new file mode 100644
index 0000000..cd7f534
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Demo/DemoSeeder.php
@@ -0,0 +1,612 @@
+ counts per table */
+ public function seed( string $market = 'US' ): array {
+ if ( function_exists( 'set_time_limit' ) ) {
+ @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ }
+ $this->clear();
+ mt_srand( 7777 );
+ $this->market = $market;
+ $this->today = SuiteTime::today( $market );
+ $catalog = $this->catalog();
+ $counts = array();
+
+ $counts['products'] = $this->products( $catalog );
+ $counts['orders'] = $this->orders_and_finance( $catalog );
+ $counts['account'] = $this->account_fees();
+ $counts['ads'] = $this->ads();
+ $counts['traffic'] = $this->traffic( $catalog );
+ $counts['keywords'] = $this->keywords( $catalog );
+ $counts['monitor'] = $this->monitor( $catalog );
+ $counts['ledger'] = $this->ledger( $catalog );
+ $counts['health'] = $this->inventory_health( $catalog );
+ $counts['feedback'] = $this->feedback();
+ $counts['listings'] = $this->listings( $catalog );
+ $counts['reviews'] = $this->solicitations();
+ $counts['expenses'] = $this->expenses();
+
+ $max_case = (int) Db::var( 'SELECT COALESCE(MAX(id),0) FROM {t:reimb_cases}' );
+ $this->auditor->scan();
+ Db::exec( 'UPDATE {t:reimb_cases} SET is_demo = 1 WHERE id > %d', array( $max_case ) );
+ $counts['reimb_cases'] = (int) Db::var( 'SELECT COUNT(*) FROM {t:reimb_cases} WHERE is_demo = 1' );
+
+ $counts['alerts'] = $this->alerts();
+
+ // Build a PPC plan so the Optimizer has reviewable recommendations straight away.
+ $counts['ppc_actions'] = array_sum( $this->ads->optimize( $this->today ) );
+
+ update_option( self::OPTION, 1, false );
+ return $counts;
+ }
+
+ public function clear(): void {
+ global $wpdb;
+ foreach ( SuiteSchema::TABLES as $t ) {
+ if ( in_array( $t, array( 'reports', 'sku_costs' ), true ) ) {
+ continue;
+ }
+ Db::exec( "DELETE FROM {t:{$t}} WHERE is_demo = 1" );
+ }
+ Db::exec( 'DELETE FROM {t:sku_costs} WHERE sku LIKE %s', array( 'DEMO-%' ) );
+ $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}sg_products WHERE sku LIKE %s", 'DEMO-%' ) ); // phpcs:ignore
+ if ( $ids ) {
+ $in = implode( ',', array_map( 'intval', $ids ) );
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}sg_price_history WHERE product_id IN ({$in})" ); // phpcs:ignore
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}sg_products WHERE id IN ({$in})" ); // phpcs:ignore
+ }
+ $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}sg_alerts WHERE context LIKE %s", '%' . $wpdb->esc_like( '"demo":true' ) . '%' ) ); // phpcs:ignore
+ delete_option( self::OPTION );
+ }
+
+ private function alerts(): int {
+ $ctx = array( 'demo' => true, 'market' => $this->market );
+ AlertDispatcher::create( 'critical', 'suite_hijacker', 'New seller on your ASIN B0DEMO0006: A3HIJACK9DEMO (FBM @ 4.49)', 'A seller that was not on this listing before is now offering it. If you are brand-registered, verify authenticity and file a Report a Violation case; consider a test buy.', $ctx );
+ AlertDispatcher::create( 'warning', 'suite_buybox', 'Buy Box lost on B0DEMO0006 to A3HIJACK9DEMO', 'Winning price now 4.49.', $ctx );
+ AlertDispatcher::create( 'warning', 'suite_feedback', 'Negative seller feedback (1★) on a recent order', 'Never received my order.', $ctx );
+ AlertDispatcher::create( 'warning', 'suite_listing', 'Listing content changed: DEMO-P1-STRAWBERRY (B0DEMO0005)', 'Title, bullets, description or images differ from the last audit. If you did not make this change, check for a catalog contribution from Amazon or another seller.', $ctx );
+ AlertDispatcher::create( 'info', 'suite_competitor', 'Competitor B0COMP0002 dropped price 18% (15.99 → 13.11)', '', $ctx );
+ return 5;
+ }
+
+ /* ------------------------------------------------------------------ */
+
+ private function catalog(): array {
+ $flavors = array( 'Spearmint', 'Peppermint', 'Cinnamon', 'Watermelon', 'Strawberry', 'Blueberry', 'Lemon', 'Cool Mint' );
+ $out = array();
+ $i = 1;
+ foreach ( $flavors as $f ) {
+ $slug = strtoupper( str_replace( ' ', '', $f ) );
+ $out[] = array( 'sku' => "DEMO-P1-{$slug}", 'asin' => sprintf( 'B0DEMO%04d', $i++ ), 'title' => "7 SEVEN Sugar-Free Gum, {$f}, 50 Pieces (1 Bottle)", 'price' => 4.99, 'cost' => 1.10, 'fba' => 3.22, 'demand' => 'Spearmint' === $f ? 9.0 : ( in_array( $f, array( 'Peppermint', 'Watermelon' ), true ) ? 5.5 : 2.6 ), 'pack' => 'P1' );
+ }
+ foreach ( $flavors as $f ) {
+ $slug = strtoupper( str_replace( ' ', '', $f ) );
+ $out[] = array( 'sku' => "DEMO-P4-{$slug}", 'asin' => sprintf( 'B0DEMO%04d', $i++ ), 'title' => "7 SEVEN Sugar-Free Gum, {$f}, 50 Pieces (Pack of 4)", 'price' => 16.99, 'cost' => 4.20, 'fba' => 4.02, 'demand' => 'Spearmint' === $f ? 6.5 : ( in_array( $f, array( 'Peppermint', 'Cinnamon' ), true ) ? 3.4 : 1.4 ), 'pack' => 'P4' );
+ }
+ foreach ( array( 'Fruit Variety', 'Mint Variety', 'Ultimate Variety' ) as $f ) {
+ $slug = strtoupper( str_replace( ' ', '', $f ) );
+ $out[] = array( 'sku' => "DEMO-P9-{$slug}", 'asin' => sprintf( 'B0DEMO%04d', $i++ ), 'title' => "7 SEVEN Sugar-Free Gum {$f} Pack, 9 Bottles x 50 Pieces", 'price' => 34.99, 'cost' => 9.50, 'fba' => 5.34, 'demand' => 'Ultimate Variety' === $f ? 3.8 : 1.7, 'pack' => 'P9' );
+ }
+ return $out;
+ }
+
+ private function products( array $catalog ): int {
+ global $wpdb;
+ $stock_profile = array( 0 => 640, 1 => 35, 2 => 0, 3 => 410, 4 => 2600, 8 => 88, 16 => 140 );
+ foreach ( $catalog as $i => $p ) {
+ $fulfillable = $stock_profile[ $i ] ?? (int) ( $p['demand'] * mt_rand( 35, 120 ) );
+ $wpdb->insert( "{$wpdb->prefix}sg_products", array(
+ 'market' => $this->market, 'sku' => $p['sku'], 'asin' => $p['asin'], 'product_name' => $p['title'],
+ 'fulfillable_qty' => $fulfillable, 'reserved_qty' => (int) ( $fulfillable * 0.06 ), 'inbound_qty' => 1 === $i ? 600 : ( 0 === $i % 5 ? 240 : 0 ),
+ 'buybox_price' => $p['price'], 'buybox_currency' => 'USD', 'buybox_is_mine' => 5 === $i ? 0 : 1, 'lowest_price' => $p['price'],
+ 'my_price' => $p['price'], 'cost_price' => $p['cost'], 'last_sync_at' => Db::now(),
+ ) );
+ $pid = (int) $wpdb->insert_id;
+ if ( 2 === $i ) {
+ // Out of stock for the last 9 days — drives the lost-sales and OOS-aware velocity logic.
+ for ( $d = 0; $d < 9; $d++ ) {
+ $wpdb->insert( "{$wpdb->prefix}sg_price_history", array( 'product_id' => $pid, 'recorded_at' => gmdate( 'Y-m-d 12:00:00', time() - $d * DAY_IN_SECONDS ), 'my_price' => $p['price'], 'fulfillable_qty' => 0, 'currency' => 'USD', 'source' => 'demo' ) );
+ }
+ }
+ Db::upsert( 'sku_costs', array(
+ 'market' => $this->market, 'sku' => $p['sku'], 'unit_cost' => $p['cost'], 'inbound_per_unit' => round( $p['cost'] * 0.12, 2 ),
+ 'prep_per_unit' => 'P1' === $p['pack'] ? 0.10 : 0.25, 'other_per_unit' => 0.0, 'lead_time_days' => 'P9' === $p['pack'] ? 45 : 35,
+ 'moq' => 'P1' === $p['pack'] ? 500 : 200, 'case_pack' => 'P1' === $p['pack'] ? 48 : ( 'P4' === $p['pack'] ? 24 : 12 ),
+ 'supplier' => 'Seven Gum Factory', 'updated_at' => Db::now(),
+ ) );
+ }
+ return count( $catalog );
+ }
+
+ private function orders_and_finance( array $catalog ): int {
+ $orders = array();
+ $items = array();
+ $fin = array();
+ $returns = array();
+ $n = 0;
+ for ( $back = self::DAYS; $back >= 0; $back-- ) {
+ $date = SuiteTime::shift( $this->today, -$back );
+ $dow = (int) ( new \DateTimeImmutable( $date ) )->format( 'N' );
+ $season = 1 + 0.25 * sin( ( self::DAYS - $back ) / 9 ) + ( $dow >= 6 ? 0.12 : 0 ) + ( self::DAYS - $back ) * 0.004;
+ foreach ( $catalog as $i => $p ) {
+ if ( 2 === $i && $back < 9 ) {
+ continue; // out of stock
+ }
+ $units = $this->poisson( $p['demand'] * $season * ( 0 === $back ? 0.45 : 1 ) );
+ while ( $units > 0 ) {
+ $qty = $units >= 2 && mt_rand( 1, 10 ) <= 2 ? 2 : 1;
+ $units -= $qty;
+ $oid = sprintf( '1%02d-%07d-%07d', mt_rand( 10, 99 ), ++$this->seq, mt_rand( 1000000, 9999999 ) );
+ $hour = mt_rand( 0, 23 );
+ $purchase_local = new \DateTimeImmutable( sprintf( '%s %02d:%02d:00', $date, $hour, mt_rand( 0, 59 ) ), SuiteTime::tz( $this->market ) );
+ $purchase = $purchase_local->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+ $status = 0 === $back ? ( mt_rand( 1, 3 ) === 1 ? 'Pending' : 'Unshipped' ) : ( mt_rand( 1, 100 ) === 1 ? 'Canceled' : 'Shipped' );
+ $promo = mt_rand( 1, 100 ) <= 8 ? round( $p['price'] * $qty * 0.15, 2 ) : 0.0;
+ $sales = round( $p['price'] * $qty, 2 );
+ $deliv = gmdate( 'Y-m-d H:i:s', strtotime( $purchase . ' +' . mt_rand( 2, 4 ) . ' days' ) );
+ $orders[] = array(
+ 'market' => $this->market, 'amazon_order_id' => $oid, 'purchase_date' => $purchase, 'local_date' => $date,
+ 'last_update' => $purchase, 'status' => $status, 'fulfillment_channel' => 'AFN', 'sales_channel' => 'Amazon.com',
+ 'order_total' => $sales - $promo, 'currency' => 'USD', 'units' => $qty, 'is_business' => mt_rand( 1, 30 ) === 1 ? 1 : 0,
+ 'is_prime' => mt_rand( 1, 10 ) <= 8 ? 1 : 0, 'ship_country' => 'US', 'earliest_delivery' => $deliv, 'latest_delivery' => $deliv,
+ 'items_synced' => 1, 'is_demo' => 1,
+ );
+ $items[] = array(
+ 'market' => $this->market, 'amazon_order_id' => $oid, 'order_item_id' => 'demo-' . $this->seq, 'local_date' => $date,
+ 'sku' => $p['sku'], 'asin' => $p['asin'], 'title' => $p['title'], 'qty' => $qty, 'item_price' => $sales,
+ 'item_tax' => round( $sales * 0.0725, 2 ), 'promo_discount' => $promo, 'status' => $status, 'is_demo' => 1,
+ );
+ $n++;
+ // Settlement lags shipment by ~2 days.
+ if ( 'Shipped' === $status && $back >= 2 ) {
+ $posted = gmdate( 'Y-m-d H:i:s', strtotime( $purchase . ' +' . mt_rand( 1, 2 ) . ' days' ) );
+ $referral = round( $sales * ( $p['price'] <= 15 ? 0.08 : 0.15 ), 2 );
+ $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Principal', 'revenue', $sales, $qty );
+ $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Tax', 'tax', round( $sales * 0.0725, 2 ), 0 );
+ $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Withheld:Tax', 'tax', -round( $sales * 0.0725, 2 ), 0 );
+ $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Commission', 'fee', -$referral, 0 );
+ $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'FBAPerUnitFulfillmentFee', 'fee', -round( ( $back < 25 && 4 === $i ? $p['fba'] * 1.32 : $p['fba'] ) * $qty, 2 ), 0 );
+ if ( $promo > 0 ) {
+ $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Promotion:PromotionMetaDataDefinitionValue', 'promo', -$promo, 0 );
+ }
+ // ~2.5 % refunded 5–20 days later.
+ if ( mt_rand( 1, 1000 ) <= 25 && $back > 6 ) {
+ $rposted = gmdate( 'Y-m-d H:i:s', strtotime( $posted . ' +' . mt_rand( 5, 20 ) . ' days' ) );
+ if ( $rposted <= gmdate( 'Y-m-d H:i:s' ) ) {
+ $fin[] = $this->fin( $rposted, 'Refund', $oid, $p['sku'], 'Principal', 'refund', -$sales, -$qty );
+ $fin[] = $this->fin( $rposted, 'Refund', $oid, $p['sku'], 'Commission', 'fee', round( $referral * 0.8, 2 ), 0 );
+ $fin[] = $this->fin( $rposted, 'Refund', $oid, $p['sku'], 'RefundCommission', 'fee', -round( min( 5, $referral * 0.2 ), 2 ), 0 );
+ if ( mt_rand( 1, 10 ) <= 7 ) {
+ $returns[] = array(
+ 'row_hash' => sha1( 'demo-ret-' . $oid ), 'market' => $this->market, 'return_date' => substr( $rposted, 0, 10 ),
+ 'amazon_order_id' => $oid, 'sku' => $p['sku'], 'asin' => $p['asin'], 'fnsku' => 'X00' . substr( $p['asin'], -7 ),
+ 'qty' => $qty, 'fulfillment_center' => array( 'PHX7', 'ONT8', 'MDW2', 'BFI4' )[ mt_rand( 0, 3 ) ],
+ 'disposition' => mt_rand( 1, 10 ) <= 6 ? 'SELLABLE' : 'CUSTOMER_DAMAGED',
+ 'reason' => array( 'NOT_AS_DESCRIBED', 'UNWANTED_ITEM', 'DEFECTIVE', 'ORDERED_WRONG_ITEM', 'TASTE' )[ mt_rand( 0, 4 ) ],
+ 'status' => 'Unit returned to inventory', 'comments' => array( '', 'flavor too strong', 'arrived opened', 'bought by mistake', 'lost freshness' )[ mt_rand( 0, 4 ) ],
+ 'is_demo' => 1,
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ $this->bulk( 'orders', $orders );
+ $this->bulk( 'order_items', $items );
+ $this->bulk( 'fin_events', $fin );
+ $this->bulk( 'returns', $returns );
+ return $n;
+ }
+
+ private function account_fees(): int {
+ $fin = array();
+ for ( $m = 0; $m < 3; $m++ ) {
+ $first = ( new \DateTimeImmutable( $this->today ) )->modify( "first day of -{$m} month" );
+ if ( $first->format( 'Y-m-d' ) > $this->today ) {
+ continue;
+ }
+ $fin[] = $this->fin( $first->format( 'Y-m-d 08:00:00' ), 'ServiceFee', '', '', 'Subscription', 'fee', -39.99, 0 );
+ $storage = $first->modify( '+6 days' );
+ if ( $storage->format( 'Y-m-d' ) <= $this->today ) {
+ $fin[] = $this->fin( $storage->format( 'Y-m-d 08:00:00' ), 'ServiceFee', '', '', 'FBAStorageFee', 'fee', -round( mt_rand( 9000, 16000 ) / 100, 2 ), 0 );
+ }
+ $reimb = $first->modify( '+14 days' );
+ if ( $reimb->format( 'Y-m-d' ) <= $this->today ) {
+ $fin[] = $this->fin( $reimb->format( 'Y-m-d 08:00:00' ), 'Adjustment', '', 'DEMO-P4-SPEARMINT', 'WAREHOUSE_LOST', 'reimbursement', 22.40, 2 );
+ }
+ }
+ $this->bulk( 'fin_events', $fin );
+ return count( $fin );
+ }
+
+ private function ads(): int {
+ $campaigns = array(
+ array( 'C-AUTO', 'SP | Auto | All Gum', 'AUTO', 60 ),
+ array( 'C-EXACT', 'SP | Exact | Hero Keywords', 'MANUAL', 45 ),
+ array( 'C-PHRASE', 'SP | Phrase | Sugar Free Gum', 'MANUAL', 30 ),
+ array( 'C-BROAD', 'SP | Broad | Discovery', 'MANUAL', 25 ),
+ array( 'C-PAT', 'SP | PAT | Competitor ASINs', 'MANUAL', 20 ),
+ array( 'C-BRAND', 'SP | Exact | Brand Defense', 'MANUAL', 2.5 ),
+ );
+ $now = Db::now();
+ // Brand defense is profitable and starved for budget → triggers the "raise budget" rule.
+ foreach ( $campaigns as [ $id, $name, $type, $budget ] ) {
+ Db::upsert( 'ads_campaigns', array( 'profile_id' => 'DEMO', 'campaign_id' => $id, 'name' => $name, 'state' => 'ENABLED', 'targeting_type' => $type, 'daily_budget' => (float) $budget, 'bidding_strategy' => 'LEGACY_FOR_SALES', 'start_date' => SuiteTime::shift( $this->today, -120 ), 'updated_at' => $now, 'is_demo' => 1 ) );
+ }
+ // keyword, campaign, match, bid, ctr, cvr, cpc-factor
+ $kws = array(
+ array( 'sugar free gum', 'C-EXACT', 'EXACT', 1.45, 0.009, 0.14 ),
+ array( 'spearmint gum', 'C-EXACT', 'EXACT', 1.10, 0.012, 0.17 ),
+ array( 'chewing gum', 'C-EXACT', 'EXACT', 1.80, 0.006, 0.07 ),
+ array( 'xylitol gum', 'C-EXACT', 'EXACT', 0.95, 0.010, 0.15 ),
+ array( 'gum bulk', 'C-EXACT', 'EXACT', 1.25, 0.007, 0.05 ),
+ array( 'mint gum', 'C-EXACT', 'EXACT', 0.85, 0.011, 0.13 ),
+ array( 'cinnamon gum', 'C-EXACT', 'EXACT', 0.70, 0.010, 0.11 ),
+ array( 'sugar free gum', 'C-PHRASE', 'PHRASE', 1.05, 0.006, 0.09 ),
+ array( 'gum variety pack', 'C-PHRASE', 'PHRASE', 0.90, 0.008, 0.10 ),
+ array( 'fruit gum', 'C-PHRASE', 'PHRASE', 0.75, 0.007, 0.06 ),
+ array( 'gum', 'C-BROAD', 'BROAD', 0.65, 0.004, 0.04 ),
+ array( 'breath freshener', 'C-BROAD', 'BROAD', 0.60, 0.003, 0.02 ),
+ array( 'healthy snacks', 'C-BROAD', 'BROAD', 0.55, 0.002, 0.00 ),
+ array( '7 seven gum', 'C-BRAND', 'EXACT', 0.45, 0.045, 0.32 ),
+ array( 'seven gum', 'C-BRAND', 'EXACT', 0.40, 0.040, 0.28 ),
+ );
+ $targets = array();
+ foreach ( $kws as $k => [ $text, $camp, $match, $bid ] ) {
+ $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'K' . ( 1000 + $k ), 'kind' => 'keyword', 'campaign_id' => $camp, 'ad_group_id' => 'AG-' . $camp, 'keyword_text' => $text, 'match_type' => $match, 'state' => 'ENABLED', 'bid' => $bid, 'updated_at' => $now, 'is_demo' => 1 );
+ }
+ $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2001', 'kind' => 'target', 'campaign_id' => 'C-AUTO', 'ad_group_id' => 'AG-C-AUTO', 'keyword_text' => 'close-match', 'match_type' => 'AUTO', 'state' => 'ENABLED', 'bid' => 0.85, 'updated_at' => $now, 'is_demo' => 1 );
+ $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2002', 'kind' => 'target', 'campaign_id' => 'C-AUTO', 'ad_group_id' => 'AG-C-AUTO', 'keyword_text' => 'loose-match', 'match_type' => 'AUTO', 'state' => 'ENABLED', 'bid' => 0.70, 'updated_at' => $now, 'is_demo' => 1 );
+ $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2003', 'kind' => 'target', 'campaign_id' => 'C-PAT', 'ad_group_id' => 'AG-C-PAT', 'keyword_text' => 'asin=B0COMP0001', 'match_type' => 'MANUAL', 'state' => 'ENABLED', 'bid' => 0.95, 'updated_at' => $now, 'is_demo' => 1 );
+ $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2004', 'kind' => 'target', 'campaign_id' => 'C-PAT', 'ad_group_id' => 'AG-C-PAT', 'keyword_text' => 'asin=B0COMP0002', 'match_type' => 'MANUAL', 'state' => 'ENABLED', 'bid' => 0.80, 'updated_at' => $now, 'is_demo' => 1 );
+ $this->bulk( 'ads_targets', $targets );
+
+ $perf = array_merge( $kws, array(
+ array( 'close-match', 'C-AUTO', 'AUTO', 0.85, 0.005, 0.10, 'T2001' ),
+ array( 'loose-match', 'C-AUTO', 'AUTO', 0.70, 0.003, 0.05, 'T2002' ),
+ array( 'asin=B0COMP0001', 'C-PAT', 'MANUAL', 0.95, 0.004, 0.08, 'T2003' ),
+ array( 'asin=B0COMP0002', 'C-PAT', 'MANUAL', 0.80, 0.003, 0.03, 'T2004' ),
+ ) );
+ $daily = array();
+ $camp_day = array();
+ for ( $back = 60; $back >= 2; $back-- ) {
+ $date = SuiteTime::shift( $this->today, -$back );
+ foreach ( $perf as $k => $row ) {
+ [ $text, $camp, $match, $bid, $ctr, $cvr ] = $row;
+ $tid = $row[6] ?? 'K' . ( 1000 + $k );
+ $impr = (int) ( mt_rand( 300, 1600 ) * ( 'C-BROAD' === $camp ? 1.8 : 1 ) * ( 'C-BRAND' === $camp ? 0.15 : 1 ) );
+ $clk = $this->poisson( $impr * $ctr );
+ $cost = round( $clk * $bid * mt_rand( 70, 95 ) / 100, 2 );
+ $ord = 0;
+ for ( $c = 0; $c < $clk; $c++ ) {
+ $ord += mt_rand( 1, 1000 ) <= (int) ( $cvr * 1000 ) ? 1 : 0;
+ }
+ $sales = round( $ord * array( 4.99, 16.99, 16.99, 34.99 )[ mt_rand( 0, 3 ) ], 2 );
+ $daily[] = array( 'profile_id' => 'DEMO', 'report_date' => $date, 'level' => 'target', 'campaign_id' => $camp, 'ad_group_id' => 'AG-' . $camp, 'target_id' => $tid, 'keyword_text' => $text, 'match_type' => $match, 'impressions' => $impr, 'clicks' => $clk, 'cost' => $cost, 'sales' => $sales, 'orders' => $ord, 'units' => $ord, 'is_demo' => 1 );
+ $key = $date . '|' . $camp;
+ $camp_day[ $key ] ??= array( 'profile_id' => 'DEMO', 'report_date' => $date, 'level' => 'campaign', 'campaign_id' => $camp, 'ad_group_id' => '', 'target_id' => '', 'keyword_text' => '', 'match_type' => '', 'impressions' => 0, 'clicks' => 0, 'cost' => 0.0, 'sales' => 0.0, 'orders' => 0, 'units' => 0, 'is_demo' => 1 );
+ foreach ( array( 'impressions', 'clicks', 'cost', 'sales', 'orders', 'units' ) as $m ) {
+ $camp_day[ $key ][ $m ] += $daily[ count( $daily ) - 1 ][ $m ];
+ }
+ }
+ }
+ // Make the exact campaign budget-capped & profitable so the budget rule fires.
+ foreach ( $camp_day as &$cd ) {
+ $cd['keyword_text'] = '';
+ if ( 'C-EXACT' === $cd['campaign_id'] ) {
+ $cd['cost'] = max( $cd['cost'], 44.0 );
+ }
+ $cd['cost'] = round( $cd['cost'], 2 );
+ }
+ unset( $cd );
+ $this->bulk( 'ads_daily', array_merge( $daily, array_values( $camp_day ) ) );
+
+ $terms = array(
+ array( 'sugar free spearmint gum', 'C-AUTO', 'close-match', 9, 2.1 ),
+ array( 'xylitol chewing gum bulk', 'C-AUTO', 'close-match', 6, 1.6 ),
+ array( 'gum for kids', 'C-BROAD', 'gum', 0, 0 ),
+ array( 'nicotine gum', 'C-BROAD', 'gum', 0, 0 ),
+ array( 'bubble gum machine', 'C-BROAD', 'gum', 0, 0 ),
+ array( 'cinnamon sugar free gum', 'C-PHRASE', 'sugar free gum', 5, 1.4 ),
+ array( 'b0comp0003', 'C-AUTO', 'loose-match', 4, 1.2 ),
+ array( 'keto gum', 'C-BROAD', 'gum', 3, 0.9 ),
+ array( 'fresh breath gum long lasting', 'C-AUTO', 'loose-match', 4, 1.0 ),
+ array( 'gum variety pack sugar free', 'C-PHRASE', 'gum variety pack', 7, 1.8 ),
+ array( 'cbd gum', 'C-BROAD', 'gum', 0, 0 ),
+ array( 'healthy snacks for adults', 'C-BROAD', 'healthy snacks', 0, 0 ),
+ );
+ $st = array();
+ for ( $back = 30; $back >= 2; $back-- ) {
+ $date = SuiteTime::shift( $this->today, -$back );
+ foreach ( $terms as $ti => [ $term, $camp, $kw, $orders, $w ] ) {
+ $clk = max( 0, (int) round( ( $orders > 0 ? 1.2 : 0.8 ) + mt_rand( -1, 1 ) ) );
+ $o = $orders > 0 && mt_rand( 1, 30 ) <= $orders ? 1 : 0;
+ $st[] = array( 'profile_id' => 'DEMO', 'row_hash' => sha1( "demo-st-{$date}-{$ti}" ), 'report_date' => $date, 'campaign_id' => $camp, 'ad_group_id' => 'AG-' . $camp, 'target_id' => 'X', 'keyword_text' => $kw, 'match_type' => 'C-AUTO' === $camp ? 'TARGETING_EXPRESSION_PREDEFINED' : ( 'C-PHRASE' === $camp ? 'PHRASE' : 'BROAD' ), 'search_term' => $term, 'impressions' => mt_rand( 40, 300 ), 'clicks' => $clk, 'cost' => round( $clk * 0.78, 2 ), 'sales' => round( $o * 16.99, 2 ), 'orders' => $o, 'is_demo' => 1 );
+ }
+ }
+ $this->bulk( 'ads_search_terms', $st );
+ return count( $daily ) + count( $st );
+ }
+
+ private function traffic( array $catalog ): int {
+ $rows = array();
+ $units = array();
+ foreach ( Db::rows( 'SELECT local_date, asin, sku, SUM(qty) AS u, SUM(item_price) AS s, COUNT(*) AS n FROM {t:order_items} WHERE is_demo = 1 AND local_date < %s GROUP BY local_date, asin, sku', array( $this->today ) ) as $r ) {
+ $units[ $r['local_date'] ][] = $r;
+ }
+ foreach ( $units as $date => $list ) {
+ $tot = array( 'sessions' => 0, 'page_views' => 0, 'units' => 0, 'order_items' => 0, 'ordered_sales' => 0.0 );
+ foreach ( $list as $r ) {
+ $sessions = (int) round( (int) $r['u'] / ( mt_rand( 9, 16 ) / 100 ) );
+ $row = array( 'market' => $this->market, 'report_date' => $date, 'asin' => $r['asin'], 'sku' => $r['sku'], 'sessions' => $sessions, 'page_views' => (int) round( $sessions * 1.35 ), 'buy_box_pct' => 'B0DEMO0006' === $r['asin'] ? 71.0 : (float) mt_rand( 94, 100 ), 'units' => (int) $r['u'], 'order_items' => (int) $r['n'], 'ordered_sales' => (float) $r['s'], 'is_demo' => 1 );
+ $rows[] = $row;
+ foreach ( $tot as $k => $v ) {
+ $tot[ $k ] += $row[ $k ];
+ }
+ }
+ $rows[] = array( 'market' => $this->market, 'report_date' => $date, 'asin' => '', 'sku' => '', 'buy_box_pct' => 96.5, 'is_demo' => 1 ) + $tot;
+ }
+ $this->bulk( 'traffic_daily', $rows );
+ return count( $rows );
+ }
+
+ private function keywords( array $catalog ): int {
+ $hero = array( $catalog[0]['asin'], $catalog[8]['asin'], $catalog[18]['asin'] );
+ $kw = array(
+ array( 'sugar free gum', 1, 410000 ), array( 'spearmint gum', 1, 52000 ), array( 'chewing gum', 1, 380000 ),
+ array( 'xylitol gum', 2, 61000 ), array( 'gum bulk', 2, 88000 ), array( 'mint gum', 2, 47000 ),
+ array( 'gum variety pack', 2, 23000 ), array( 'cinnamon gum', 3, 31000 ), array( 'fruit gum', 3, 19000 ),
+ array( 'keto gum', 3, 9000 ), array( 'long lasting gum', 3, 15000 ), array( 'seven gum', 1, 2100 ),
+ );
+ $rows = array();
+ $metrics = array();
+ $sat = new \DateTimeImmutable( 'last saturday' );
+ foreach ( $kw as $ki => [ $k, $prio, $vol ] ) {
+ $asin = $hero[ $ki % 3 ];
+ $rows[] = array( 'market' => $this->market, 'asin' => $asin, 'keyword' => $k, 'priority' => $prio, 'source' => 'demo', 'created_at' => Db::now(), 'is_demo' => 1 );
+ for ( $w = 7; $w >= 0; $w-- ) {
+ $end = $sat->modify( '-' . ( 7 * $w ) . ' days' );
+ $start = $end->modify( '-6 days' );
+ $weekly = (int) ( $vol / 4.3 * ( 1 + mt_rand( -10, 10 ) / 100 ) );
+ $share = max( 0.2, ( 'seven gum' === $k ? 72 : ( 12 - $ki * 0.7 ) ) + ( 7 - $w ) * 0.35 + mt_rand( -10, 10 ) / 10 ) / 100;
+ $imp_t = $weekly * 18;
+ $clk_t = (int) ( $weekly * 0.9 );
+ $cart_t = (int) ( $clk_t * 0.16 );
+ $pur_t = (int) ( $clk_t * 0.08 );
+ $metrics[] = array(
+ 'market' => $this->market, 'asin' => $asin, 'keyword' => $k, 'period_start' => $start->format( 'Y-m-d' ), 'period_end' => $end->format( 'Y-m-d' ),
+ 'query_score' => $ki + 1, 'query_volume' => $weekly, 'impressions_total' => $imp_t, 'impressions_asin' => (int) ( $imp_t * $share ),
+ 'clicks_total' => $clk_t, 'clicks_asin' => (int) ( $clk_t * $share * 0.9 ), 'cart_adds_total' => $cart_t, 'cart_adds_asin' => (int) ( $cart_t * $share ),
+ 'purchases_total' => $pur_t, 'purchases_asin' => (int) ( $pur_t * $share * 1.1 ), 'is_demo' => 1,
+ );
+ }
+ }
+ $this->bulk( 'keywords', $rows );
+ $this->bulk( 'keyword_metrics', $metrics );
+ return count( $rows );
+ }
+
+ private function monitor( array $catalog ): int {
+ $snaps = array();
+ $sellers = array();
+ $competitors = array( 'B0COMP0001' => 5.49, 'B0COMP0002' => 15.99, 'B0COMP0003' => 29.99, 'B0COMP0004' => 4.29 );
+ for ( $back = 60; $back >= 0; $back-- ) {
+ $ts = gmdate( 'Y-m-d 10:00:00', strtotime( $this->today . " -{$back} days" ) );
+ foreach ( $catalog as $i => $p ) {
+ $hijacked = 5 === $i && $back <= 3;
+ $bsr = (int) ( 9000 / max( 0.4, $p['demand'] ) * ( 1 + mt_rand( -15, 15 ) / 100 ) * ( 2 === $i && $back < 9 ? 3 + ( 9 - $back ) * 0.5 : 1 ) );
+ $snaps[] = array( 'market' => $this->market, 'asin' => $p['asin'], 'captured_at' => $ts, 'is_mine' => 1, 'my_price' => $p['price'], 'buybox_price' => $hijacked ? round( $p['price'] - 0.50, 2 ) : $p['price'], 'buybox_seller' => $hijacked ? 'A3HIJACK9DEMO' : 'ME', 'buybox_is_mine' => $hijacked ? 0 : 1, 'lowest_price' => $hijacked ? round( $p['price'] - 0.50, 2 ) : $p['price'], 'offer_count' => $hijacked ? 2 : 1, 'fba_offer_count' => 1, 'bsr' => $bsr, 'bsr_category' => 'grocery_display_on_website', 'is_demo' => 1 );
+ }
+ foreach ( array_keys( $competitors ) as $ci => $asin ) {
+ $price = $competitors[ $asin ] * ( 'B0COMP0002' === $asin && $back < 5 ? 0.82 : 1 ) * ( 1 + mt_rand( -3, 3 ) / 100 );
+ $snaps[] = array( 'market' => $this->market, 'asin' => $asin, 'captured_at' => $ts, 'is_mine' => 0, 'my_price' => null, 'buybox_price' => round( $price, 2 ), 'buybox_seller' => 'ACOMPETITOR' . $ci, 'buybox_is_mine' => 0, 'lowest_price' => round( $price * 0.97, 2 ), 'offer_count' => mt_rand( 2, 9 ), 'fba_offer_count' => mt_rand( 1, 4 ), 'bsr' => mt_rand( 800, 25000 ), 'bsr_category' => 'grocery_display_on_website', 'is_demo' => 1 );
+ }
+ }
+ $now = Db::now();
+ foreach ( $catalog as $i => $p ) {
+ $sellers[] = array( 'market' => $this->market, 'asin' => $p['asin'], 'seller_id' => 'ADEMOSELLERME', 'is_me' => 1, 'is_fba' => 1, 'last_price' => $p['price'], 'first_seen' => gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS ), 'last_seen' => $now, 'active' => 1, 'is_demo' => 1 );
+ if ( 5 === $i ) {
+ $sellers[] = array( 'market' => $this->market, 'asin' => $p['asin'], 'seller_id' => 'A3HIJACK9DEMO', 'is_me' => 0, 'is_fba' => 0, 'last_price' => round( $p['price'] - 0.5, 2 ), 'first_seen' => gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ), 'last_seen' => $now, 'active' => 1, 'is_demo' => 1 );
+ }
+ }
+ $this->bulk( 'asin_snapshots', $snaps );
+ $this->bulk( 'offer_sellers', $sellers );
+ return count( $snaps );
+ }
+
+ private function ledger( array $catalog ): int {
+ $rows = array();
+ $reimb = array();
+ foreach ( array( 0, 3, 8, 11, 18 ) as $k => $i ) {
+ $p = $catalog[ $i ];
+ $fnsku = 'X00' . substr( $p['asin'], -7 );
+ $d = SuiteTime::shift( $this->today, -( 40 + $k * 9 ) );
+ $lost = 2 + $k;
+ $rows[] = array( 'row_hash' => sha1( "demo-led-m-{$i}" ), 'market' => $this->market, 'event_date' => $d, 'fnsku' => $fnsku, 'sku' => $p['sku'], 'asin' => $p['asin'], 'event_type' => 'Adjustments', 'reference_id' => 'DEMO' . $i, 'qty' => -$lost, 'fulfillment_center' => 'PHX7', 'disposition' => 'SELLABLE', 'reason' => 'M', 'is_demo' => 1 );
+ if ( 0 === $k % 2 ) {
+ $rows[] = array( 'row_hash' => sha1( "demo-led-f-{$i}" ), 'market' => $this->market, 'event_date' => SuiteTime::shift( $d, 6 ), 'fnsku' => $fnsku, 'sku' => $p['sku'], 'asin' => $p['asin'], 'event_type' => 'Adjustments', 'reference_id' => 'DEMOF' . $i, 'qty' => 1, 'fulfillment_center' => 'PHX7', 'disposition' => 'SELLABLE', 'reason' => 'F', 'is_demo' => 1 );
+ }
+ if ( $k >= 2 ) {
+ $rows[] = array( 'row_hash' => sha1( "demo-led-e-{$i}" ), 'market' => $this->market, 'event_date' => SuiteTime::shift( $d, 3 ), 'fnsku' => $fnsku, 'sku' => $p['sku'], 'asin' => $p['asin'], 'event_type' => 'Adjustments', 'reference_id' => 'DEMOE' . $i, 'qty' => -( $k ), 'fulfillment_center' => 'ONT8', 'disposition' => 'WAREHOUSE_DAMAGED', 'reason' => 'E', 'is_demo' => 1 );
+ }
+ if ( 1 === $k ) {
+ $reimb[] = array( 'reimbursement_id' => 'DEMOR' . $i, 'market' => $this->market, 'approval_date' => SuiteTime::shift( $d, 20 ), 'case_id' => '', 'amazon_order_id' => '', 'reason' => 'Lost_Warehouse', 'sku' => $p['sku'], 'fnsku' => $fnsku, 'asin' => $p['asin'], 'amount' => round( $p['price'] * 0.7 * 2, 2 ), 'currency' => 'USD', 'qty_cash' => 2, 'qty_inventory' => 0, 'is_demo' => 1 );
+ }
+ }
+ $this->bulk( 'ledger', $rows );
+ $this->bulk( 'reimbursements', $reimb );
+ return count( $rows );
+ }
+
+ private function inventory_health( array $catalog ): int {
+ $rows = array();
+ $stock = array();
+ global $wpdb;
+ foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT sku, fulfillable_qty FROM {$wpdb->prefix}sg_products WHERE sku LIKE %s", 'DEMO-%' ), ARRAY_A ) as $r ) { // phpcs:ignore
+ $stock[ $r['sku'] ] = (int) $r['fulfillable_qty'];
+ }
+ foreach ( $catalog as $p ) {
+ $a = $stock[ $p['sku'] ] ?? 0;
+ $old = 'DEMO-P1-STRAWBERRY' === $p['sku'] ? (int) ( $a * 0.55 ) : (int) ( $a * mt_rand( 0, 12 ) / 100 );
+ $rows[] = array( 'market' => $this->market, 'snapshot_date' => $this->today, 'sku' => $p['sku'], 'asin' => $p['asin'], 'available' => $a, 'age_0_90' => $a - $old, 'age_91_180' => (int) ( $old * 0.6 ), 'age_181_270' => (int) ( $old * 0.3 ), 'age_271_365' => (int) ( $old * 0.1 ), 'age_365_plus' => 0, 'units_t30' => (int) ( $p['demand'] * 30 ), 'sell_through' => round( $p['demand'] * 30 / max( 1, $a ), 2 ), 'days_of_supply' => $p['demand'] > 0 ? (int) ( $a / $p['demand'] ) : null, 'est_storage_cost' => round( $a * 0.012, 2 ), 'recommended_action' => $old > 300 ? 'Create removal order or run a promotion' : 'No action required', 'is_demo' => 1 );
+ }
+ $this->bulk( 'inventory_health', $rows );
+ return count( $rows );
+ }
+
+ private function feedback(): int {
+ $rows = array();
+ $comments = array( 5 => 'Fast shipping, great gum!', 4 => 'Good product, arrived quickly.', 3 => 'OK but box was dented.', 2 => 'Bottle arrived opened.', 1 => 'Never received my order.' );
+ $all = Db::rows( "SELECT amazon_order_id, local_date FROM {t:orders} WHERE is_demo = 1 AND status = 'Shipped'" );
+ $orders = array();
+ for ( $i = 0; $i < 40 && $all; $i++ ) {
+ $orders[] = $all[ mt_rand( 0, count( $all ) - 1 ) ];
+ }
+ $orders = array_values( array_unique( $orders, SORT_REGULAR ) );
+ foreach ( $orders as $i => $o ) {
+ $rating = $i < 3 ? array( 2, 1, 3 )[ $i ] : ( mt_rand( 1, 10 ) <= 8 ? 5 : 4 );
+ $rows[] = array( 'row_hash' => sha1( 'demo-fb-' . $o['amazon_order_id'] ), 'market' => $this->market, 'feedback_date' => SuiteTime::shift( (string) $o['local_date'], 6 ), 'rating' => $rating, 'comments' => $comments[ $rating ], 'amazon_order_id' => $o['amazon_order_id'], 'is_demo' => 1 );
+ }
+ $this->bulk( 'feedback', $rows );
+ return count( $rows );
+ }
+
+ private function listings( array $catalog ): int {
+ $n = 0;
+ foreach ( $catalog as $i => $p ) {
+ $content = array(
+ 'asin' => $p['asin'],
+ 'title' => 0 === $i % 6 ? str_replace( 'Sugar-Free Gum', 'Sugar Free Gum!! Best Seller Gum Gum', $p['title'] ) : $p['title'] . ' | Xylitol, Aspartame-Free, Long-Lasting Flavor, Vegan & Keto Friendly',
+ 'brand' => '7 SEVEN',
+ 'bullets' => array_slice( array(
+ 'LONG-LASTING FLAVOR — Our slow-release flavor beads keep every piece fresh for up to 45 minutes, so one piece covers your whole commute or meeting.',
+ 'SWEETENED WITH XYLITOL — Zero sugar and no aspartame. Xylitol is a natural sweetener that helps fight cavities and keeps your breath clean.',
+ 'CLEAN INGREDIENTS — Vegan, keto-friendly, gluten-free and non-GMO. Nothing artificial you can not pronounce.',
+ 'RESEALABLE BOTTLE — The travel bottle fits every car cup holder and keeps 50 pieces fresh and protected in your bag or desk drawer.',
+ '7 SEVEN PROMISE — Designed for people who want a modern, clean chew. Questions? Our team answers every message within 24 hours.',
+ ), 0, 3 === $i % 4 ? 3 : 5 ),
+ 'description' => 2 === $i % 5 ? '' : str_repeat( '7 SEVEN gum is crafted for a clean, confident chew with long-lasting flavor and xylitol sweetness. ', 12 ),
+ 'backend' => 1 === $i % 4 ? '' : 'xylitol gum aspartame free keto vegan breath freshener office travel bottle sugarless candy mints',
+ 'images' => array_fill( 0, 3 === $i % 3 ? 4 : ( 0 === $i % 3 ? 5 : 7 ), 'https://m.media-amazon.com/images/I/demo.jpg' ),
+ 'status' => 7 === $i ? array( 'DISCOVERABLE' ) : array( 'BUYABLE', 'DISCOVERABLE' ),
+ 'issues' => 7 === $i ? array( array( 'code' => '90220', 'severity' => 'ERROR', 'message' => "'item_package_weight' is required but not supplied." ) ) : array(),
+ 'product_type'=> 'CHEWING_GUM',
+ );
+ $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE asin = %s', array( $p['asin'] ) ), 'keyword' );
+ ListingAuditor::store( $this->market, $p['sku'], $content, ListingAuditor::score( $content, $kw ), true );
+ $n++;
+ }
+ return $n;
+ }
+
+ private function solicitations(): int {
+ $rows = array();
+ foreach ( Db::rows( "SELECT amazon_order_id, purchase_date FROM {t:orders} WHERE is_demo = 1 AND status = 'Shipped' AND local_date < %s ORDER BY purchase_date", array( SuiteTime::shift( $this->today, -12 ) ) ) as $i => $o ) {
+ $rows[] = array( 'market' => $this->market, 'amazon_order_id' => $o['amazon_order_id'], 'status' => 0 === $i % 17 ? 'ineligible' : 'sent', 'message' => 0 === $i % 17 ? 'Already requested in Seller Central.' : '', 'attempted_at' => gmdate( 'Y-m-d H:i:s', strtotime( $o['purchase_date'] . ' +10 days' ) ), 'is_demo' => 1 );
+ }
+ $this->bulk( 'solicitations', $rows );
+ return count( $rows );
+ }
+
+ private function expenses(): int {
+ $rows = array(
+ array( 'market' => '', 'label' => 'Helium-style tools & software', 'category' => 'software', 'amount' => 97.0, 'recurrence' => 'monthly', 'start_date' => SuiteTime::shift( $this->today, -200 ), 'end_date' => null, 'sku' => '', 'created_at' => Db::now(), 'is_demo' => 1 ),
+ array( 'market' => '', 'label' => 'Virtual assistant', 'category' => 'staff', 'amount' => 600.0, 'recurrence' => 'monthly', 'start_date' => SuiteTime::shift( $this->today, -200 ), 'end_date' => null, 'sku' => '', 'created_at' => Db::now(), 'is_demo' => 1 ),
+ array( 'market' => '', 'label' => 'Product photography (P9 relaunch)', 'category' => 'marketing', 'amount' => 450.0, 'recurrence' => 'once', 'start_date' => SuiteTime::shift( $this->today, -21 ), 'end_date' => null, 'sku' => '', 'created_at' => Db::now(), 'is_demo' => 1 ),
+ );
+ $this->bulk( 'expenses', $rows );
+ return count( $rows );
+ }
+
+ /* ------------------------------------------------------------------ */
+
+ private function fin( string $posted, string $type, string $order, string $sku, string $charge, string $cat, float $amount, int $qty ): array {
+ static $n = 0;
+ return array(
+ 'market' => $this->market, 'event_hash' => sha1( 'demo-fin-' . ( ++$n ) . '-' . $order . $charge ), 'posted_date' => $posted,
+ 'local_date' => SuiteTime::local_date( $posted, $this->market ), 'event_type' => $type, 'amazon_order_id' => $order, 'sku' => $sku,
+ 'charge_type' => $charge, 'category' => $cat, 'amount' => round( $amount, 4 ), 'currency' => 'USD', 'qty' => $qty, 'is_demo' => 1,
+ );
+ }
+
+ private function poisson( float $lambda ): int {
+ if ( $lambda <= 0 ) {
+ return 0;
+ }
+ $l = exp( -$lambda );
+ $k = 0;
+ $p = 1.0;
+ do {
+ $k++;
+ $p *= mt_rand() / mt_getrandmax();
+ } while ( $p > $l && $k < 500 );
+ return $k - 1;
+ }
+
+ /** Multi-row INSERT in chunks (seeding thousands of rows one-by-one is too slow on shared hosting). */
+ private function bulk( string $short, array $rows ): void {
+ global $wpdb;
+ if ( ! $rows ) {
+ return;
+ }
+ $table = SuiteSchema::table( $short );
+ $cols = array_keys( $rows[0] );
+ foreach ( array_chunk( $rows, 200 ) as $chunk ) {
+ $values = array();
+ foreach ( $chunk as $r ) {
+ $cells = array();
+ foreach ( $cols as $c ) {
+ $v = $r[ $c ] ?? null;
+ $cells[] = null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : $wpdb->prepare( '%s', is_float( $v ) ? Db::num( $v ) : (string) $v ) );
+ }
+ $values[] = '(' . implode( ',', $cells ) . ')';
+ }
+ $wpdb->query( "INSERT IGNORE INTO {$table} (`" . implode( '`,`', $cols ) . '`) VALUES ' . implode( ',', $values ) ); // phpcs:ignore
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Finance/FinanceSync.php b/sg-commerce-pro/src/Suite/Finance/FinanceSync.php
new file mode 100644
index 0000000..793f420
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Finance/FinanceSync.php
@@ -0,0 +1,163 @@
+ 0, 'lines' => 0, 'windows' => 0 );
+ if ( ! $this->client->has_credentials() ) {
+ return $stats;
+ }
+ foreach ( $this->region_anchor_markets() as $region => $market ) {
+ $stats['regions']++;
+ $pages = 0;
+ $until = time() - 5 * MINUTE_IN_SECONDS;
+ while ( $pages < self::MAX_PAGES ) {
+ $cursor = (int) get_option( self::CURSOR_OPTION . $region, 0 );
+ if ( $cursor <= 0 ) {
+ $cursor = time() - $this->settings->int( 'suite_backfill_days' ) * DAY_IN_SECONDS;
+ }
+ if ( $cursor >= $until - 60 ) {
+ break;
+ }
+ $end = min( $until, $cursor + self::WINDOW_DAYS * DAY_IN_SECONDS );
+ [ $lines, $used, $complete ] = $this->fetch_window( $market, $cursor, $end, self::MAX_PAGES - $pages );
+ $pages += $used;
+ $stats['lines'] += $lines;
+ if ( ! $complete ) {
+ break; // Page budget exhausted mid-window; retry the window next tick (idempotent).
+ }
+ update_option( self::CURSOR_OPTION . $region, $end, false );
+ $stats['windows']++;
+ }
+ }
+ return $stats;
+ }
+
+ /** Reset cursors (forces a full re-backfill). */
+ public static function reset_cursors(): void {
+ foreach ( array( 'na', 'eu', 'fe' ) as $r ) {
+ delete_option( self::CURSOR_OPTION . $r );
+ }
+ }
+
+ /** @return array{0:int,1:int,2:bool} lines written, pages used, window complete */
+ private function fetch_window( string $market, int $from, int $to, int $page_budget ): array {
+ $query = array(
+ 'PostedAfter' => gmdate( 'Y-m-d\TH:i:s\Z', $from ),
+ 'PostedBefore' => gmdate( 'Y-m-d\TH:i:s\Z', $to ),
+ 'MaxResultsPerPage' => 100,
+ );
+ $written = 0;
+ $pages = 0;
+ $seen = array();
+ do {
+ $res = $this->client->request( 'GET', $market, '/finances/v0/financialEvents', $query, null, 'finances.events' );
+ $payload = (array) ( $res['payload'] ?? array() );
+ $lines = FinancialEventFlattener::flatten( (array) ( $payload['FinancialEvents'] ?? array() ), $query['PostedBefore'] );
+ $written += $this->store( $lines, $market, $seen );
+ $pages++;
+ $next = (string) ( $payload['NextToken'] ?? '' );
+ $query = '' !== $next ? array( 'NextToken' => $next ) : array();
+ } while ( '' !== ( $next ?? '' ) && $pages < $page_budget );
+
+ return array( $written, $pages, '' === ( $next ?? '' ) );
+ }
+
+ /**
+ * @param array $seen base-hash occurrence counter (window-scoped)
+ */
+ public function store( array $lines, string $default_market, array &$seen, bool $demo = false ): int {
+ $n = 0;
+ foreach ( $lines as $line ) {
+ $market = $this->market_from_name( $line['marketplace'] ) ?: $default_market;
+ $base = FinancialEventFlattener::hash( $line, 0 );
+ $seen[ $base ] = ( $seen[ $base ] ?? -1 ) + 1;
+ $hash = 0 === $seen[ $base ] ? $base : FinancialEventFlattener::hash( $line, $seen[ $base ] );
+ $posted = SuiteTime::to_mysql( $line['posted'] ) ?? Db::now();
+ $ok = Db::upsert( 'fin_events', array(
+ 'market' => $market,
+ 'event_hash' => $hash,
+ 'posted_date' => $posted,
+ 'local_date' => SuiteTime::local_date( $posted, $market ),
+ 'event_type' => $line['event_type'],
+ 'amazon_order_id' => $line['order_id'],
+ 'sku' => $line['sku'],
+ 'charge_type' => $line['charge_type'],
+ 'category' => $line['category'],
+ 'amount' => (float) $line['amount'],
+ 'currency' => $line['currency'],
+ 'qty' => (int) $line['qty'],
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ if ( $ok ) {
+ $n++;
+ }
+ }
+ return $n;
+ }
+
+ /** "Amazon.com" → US, "Amazon.co.uk" → UK, "Amazon.ae" → AE. */
+ public function market_from_name( string $name ): string {
+ $name = strtolower( trim( $name ) );
+ if ( '' === $name || ! str_starts_with( $name, 'amazon.' ) ) {
+ return '';
+ }
+ $tld = substr( $name, 7 );
+ foreach ( $this->marketplaces->all() as $code => $m ) {
+ if ( ( $m['tld'] ?? '' ) === $tld ) {
+ return (string) $code;
+ }
+ }
+ return '';
+ }
+
+ /** One enabled market per SP-API region (finance events are region-wide). */
+ private function region_anchor_markets(): array {
+ $out = array();
+ $primary = $this->marketplaces->primary();
+ $codes = array_unique( array_merge( array( $primary ), $this->marketplaces->enabled_codes() ) );
+ foreach ( $codes as $code ) {
+ $mp = $this->marketplaces->get( $code );
+ if ( $mp && ! isset( $out[ $mp['region'] ] ) ) {
+ $out[ $mp['region'] ] = $code;
+ }
+ }
+ return $out;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Finance/FinancialEventFlattener.php b/sg-commerce-pro/src/Suite/Finance/FinancialEventFlattener.php
new file mode 100644
index 0000000..141ed18
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Finance/FinancialEventFlattener.php
@@ -0,0 +1,272 @@
+
+ */
+ public static function flatten( array $events, string $fallback_posted ): array {
+ $out = array();
+
+ foreach ( (array) ( $events['ShipmentEventList'] ?? array() ) as $ev ) {
+ self::shipment_like( $out, $ev, 'Shipment', $fallback_posted, false );
+ }
+ foreach ( (array) ( $events['RefundEventList'] ?? array() ) as $ev ) {
+ self::shipment_like( $out, $ev, 'Refund', $fallback_posted, true );
+ }
+ foreach ( (array) ( $events['GuaranteeClaimEventList'] ?? array() ) as $ev ) {
+ self::shipment_like( $out, $ev, 'GuaranteeClaim', $fallback_posted, true );
+ }
+ foreach ( (array) ( $events['ChargebackEventList'] ?? array() ) as $ev ) {
+ self::shipment_like( $out, $ev, 'Chargeback', $fallback_posted, true );
+ }
+
+ foreach ( (array) ( $events['ServiceFeeEventList'] ?? array() ) as $ev ) {
+ foreach ( (array) ( $ev['FeeList'] ?? array() ) as $fee ) {
+ [ $amt, $cur ] = self::amount( $fee['FeeAmount'] ?? null );
+ if ( 0.0 === $amt ) {
+ continue;
+ }
+ $out[] = self::line(
+ (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'ServiceFee',
+ (string) ( $ev['AmazonOrderId'] ?? '' ), (string) ( $ev['SellerSKU'] ?? '' ),
+ (string) ( $fee['FeeType'] ?? ( $ev['FeeReason'] ?? 'ServiceFee' ) ), 'fee', $amt, $cur, 0, ''
+ );
+ }
+ }
+
+ foreach ( (array) ( $events['AdjustmentEventList'] ?? array() ) as $ev ) {
+ $type = (string) ( $ev['AdjustmentType'] ?? 'Adjustment' );
+ $cat = self::adjustment_category( $type );
+ $posted = (string) ( $ev['PostedDate'] ?? $fallback_posted );
+ $items = (array) ( $ev['AdjustmentItemList'] ?? array() );
+ if ( $items ) {
+ foreach ( $items as $it ) {
+ [ $amt, $cur ] = self::amount( $it['TotalAmount'] ?? null );
+ if ( 0.0 === $amt ) {
+ continue;
+ }
+ $out[] = self::line( $posted, 'Adjustment', '', (string) ( $it['SellerSKU'] ?? '' ), $type, $cat, $amt, $cur, (int) ( $it['Quantity'] ?? 0 ), '' );
+ }
+ } else {
+ [ $amt, $cur ] = self::amount( $ev['AdjustmentAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( $posted, 'Adjustment', '', '', $type, $cat, $amt, $cur, 0, '' );
+ }
+ }
+ }
+
+ foreach ( (array) ( $events['ProductAdsPaymentEventList'] ?? array() ) as $ev ) {
+ [ $amt, $cur ] = self::amount( $ev['transactionValue'] ?? null );
+ if ( 0.0 === $amt ) {
+ continue;
+ }
+ // Amazon reports ad charges as positive transactionValue for type "Charge".
+ $signed = 'Refund' === ( $ev['transactionType'] ?? '' ) ? abs( $amt ) : -abs( $amt );
+ $out[] = self::line( (string) ( $ev['postedDate'] ?? $fallback_posted ), 'ProductAdsPayment', '', '', 'AdsInvoice:' . (string) ( $ev['invoiceId'] ?? '' ), 'ads', $signed, $cur, 0, '' );
+ }
+
+ foreach ( (array) ( $events['SellerDealPaymentEventList'] ?? array() ) as $ev ) {
+ [ $amt, $cur ] = self::amount( $ev['totalAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( (string) ( $ev['postedDate'] ?? $fallback_posted ), 'SellerDealPayment', '', '', 'DealFee:' . (string) ( $ev['dealId'] ?? '' ), 'promo', $amt, $cur, 0, '' );
+ }
+ }
+
+ foreach ( (array) ( $events['CouponPaymentEventList'] ?? array() ) as $ev ) {
+ [ $amt, $cur ] = self::amount( $ev['TotalAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'CouponPayment', '', '', 'Coupon:' . (string) ( $ev['CouponId'] ?? '' ), 'promo', $amt, $cur, 0, '' );
+ }
+ }
+
+ foreach ( (array) ( $events['SAFETReimbursementEventList'] ?? array() ) as $ev ) {
+ [ $amt, $cur ] = self::amount( $ev['ReimbursedAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'SAFETReimbursement', '', '', 'SAFE-T:' . (string) ( $ev['SAFETClaimId'] ?? '' ), 'reimbursement', $amt, $cur, 0, '' );
+ }
+ }
+
+ foreach ( (array) ( $events['RetrochargeEventList'] ?? array() ) as $ev ) {
+ foreach ( array( 'BaseTax', 'ShippingTax' ) as $k ) {
+ [ $amt, $cur ] = self::amount( $ev[ $k ] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'Retrocharge', (string) ( $ev['AmazonOrderId'] ?? '' ), '', 'Retrocharge:' . $k, 'tax', $amt, $cur, 0, '' );
+ }
+ }
+ }
+
+ foreach ( (array) ( $events['FBALiquidationEventList'] ?? array() ) as $ev ) {
+ foreach ( array( 'LiquidationProceedsAmount' => 'other', 'LiquidationFeeAmount' => 'fee' ) as $k => $cat ) {
+ [ $amt, $cur ] = self::amount( $ev[ $k ] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'FBALiquidation', '', '', $k, $cat, $amt, $cur, 0, '' );
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ /** Deterministic hash so re-fetching a window is idempotent. */
+ public static function hash( array $line, int $ordinal ): string {
+ return sha1( implode( '|', array(
+ $line['posted'], $line['event_type'], $line['order_id'], $line['sku'],
+ $line['charge_type'], number_format( $line['amount'], 4, '.', '' ), $line['currency'], $ordinal,
+ ) ) );
+ }
+
+ public static function adjustment_category( string $type ): string {
+ // Amazon mixes styles: "FBAInventoryReimbursement", "WAREHOUSE_DAMAGE", "MISSING_FROM_INBOUND".
+ $t = (string) preg_replace( '/[^a-z]/', '', strtolower( $type ) );
+ if ( str_contains( $t, 'reimbursement' ) || str_contains( $t, 'warehousedamage' ) || str_contains( $t, 'warehouselost' )
+ || str_contains( $t, 'missingfrominbound' ) || str_contains( $t, 'compensatedclawback' ) || str_contains( $t, 'freereplacementrefund' ) ) {
+ return 'reimbursement';
+ }
+ if ( str_contains( $t, 'storage' ) || str_contains( $t, 'fee' ) || str_contains( $t, 'postage' ) ) {
+ return 'fee';
+ }
+ return 'adjustment';
+ }
+
+ public static function charge_category( string $charge_type, bool $is_refund ): string {
+ if ( in_array( $charge_type, self::TAX_TYPES, true ) || str_contains( $charge_type, 'Tax' ) || str_contains( $charge_type, 'VAT' ) ) {
+ return 'tax';
+ }
+ return $is_refund ? 'refund' : 'revenue';
+ }
+
+ private static function shipment_like( array &$out, array $ev, string $type, string $fallback, bool $is_refund ): void {
+ $posted = (string) ( $ev['PostedDate'] ?? $fallback );
+ $order_id = (string) ( $ev['AmazonOrderId'] ?? '' );
+ $mkt = (string) ( $ev['MarketplaceName'] ?? '' );
+ $items = $is_refund
+ ? (array) ( $ev['ShipmentItemAdjustmentList'] ?? array() )
+ : (array) ( $ev['ShipmentItemList'] ?? array() );
+
+ // Order-level charges/fees (rare: e.g. shipping on multi-item orders).
+ foreach ( (array) ( $ev['OrderChargeList'] ?? $ev['OrderChargeAdjustmentList'] ?? array() ) as $ch ) {
+ [ $amt, $cur ] = self::amount( $ch['ChargeAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $ct = (string) ( $ch['ChargeType'] ?? '' );
+ $out[] = self::line( $posted, $type, $order_id, '', $ct, self::charge_category( $ct, $is_refund ), $amt, $cur, 0, $mkt );
+ }
+ }
+ foreach ( (array) ( $ev['ShipmentFeeList'] ?? $ev['ShipmentFeeAdjustmentList'] ?? array() ) as $fee ) {
+ [ $amt, $cur ] = self::amount( $fee['FeeAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( $posted, $type, $order_id, '', (string) ( $fee['FeeType'] ?? '' ), 'fee', $amt, $cur, 0, $mkt );
+ }
+ }
+
+ foreach ( $items as $it ) {
+ $sku = (string) ( $it['SellerSKU'] ?? '' );
+ $qty = (int) ( $it['QuantityShipped'] ?? 0 );
+ $first = true;
+
+ $charges = $is_refund ? ( $it['ItemChargeAdjustmentList'] ?? array() ) : ( $it['ItemChargeList'] ?? array() );
+ foreach ( (array) $charges as $ch ) {
+ [ $amt, $cur ] = self::amount( $ch['ChargeAmount'] ?? null );
+ if ( 0.0 === $amt ) {
+ continue;
+ }
+ $ct = (string) ( $ch['ChargeType'] ?? '' );
+ $cat = self::charge_category( $ct, $is_refund );
+ // Attribute the unit count to the Principal line only, so SUM(qty) == units.
+ $line_qty = ( 'Principal' === $ct && $first ) ? ( $is_refund ? -abs( $qty ) : $qty ) : 0;
+ if ( 'Principal' === $ct ) {
+ $first = false;
+ }
+ $out[] = self::line( $posted, $type, $order_id, $sku, $ct, $cat, $amt, $cur, $line_qty, $mkt );
+ }
+
+ $fees = $is_refund ? ( $it['ItemFeeAdjustmentList'] ?? array() ) : ( $it['ItemFeeList'] ?? array() );
+ foreach ( (array) $fees as $fee ) {
+ [ $amt, $cur ] = self::amount( $fee['FeeAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( $posted, $type, $order_id, $sku, (string) ( $fee['FeeType'] ?? '' ), 'fee', $amt, $cur, 0, $mkt );
+ }
+ }
+
+ $promos = $is_refund ? ( $it['PromotionAdjustmentList'] ?? array() ) : ( $it['PromotionList'] ?? array() );
+ foreach ( (array) $promos as $p ) {
+ [ $amt, $cur ] = self::amount( $p['PromotionAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( $posted, $type, $order_id, $sku, 'Promotion:' . (string) ( $p['PromotionType'] ?? '' ), $is_refund ? 'refund' : 'promo', $amt, $cur, 0, $mkt );
+ }
+ }
+
+ foreach ( (array) ( $it['ItemTaxWithheldList'] ?? array() ) as $tw ) {
+ foreach ( (array) ( $tw['TaxesWithheld'] ?? array() ) as $ch ) {
+ [ $amt, $cur ] = self::amount( $ch['ChargeAmount'] ?? null );
+ if ( 0.0 !== $amt ) {
+ $out[] = self::line( $posted, $type, $order_id, $sku, 'Withheld:' . (string) ( $ch['ChargeType'] ?? '' ), 'tax', $amt, $cur, 0, $mkt );
+ }
+ }
+ }
+ }
+ }
+
+ /** @return array{0: float, 1: string} */
+ private static function amount( mixed $money ): array {
+ if ( ! is_array( $money ) ) {
+ return array( 0.0, '' );
+ }
+ $v = $money['CurrencyAmount'] ?? $money['Amount'] ?? $money['amount'] ?? $money['currencyAmount'] ?? 0;
+ $c = $money['CurrencyCode'] ?? $money['currencyCode'] ?? '';
+ return array( round( (float) $v, 4 ), (string) $c );
+ }
+
+ private static function line( string $posted, string $type, string $order, string $sku, string $charge, string $cat, float $amt, string $cur, int $qty, string $mkt ): array {
+ return array(
+ 'posted' => $posted,
+ 'event_type' => $type,
+ 'order_id' => $order,
+ 'sku' => $sku,
+ 'charge_type' => substr( $charge, 0, 64 ),
+ 'category' => $cat,
+ 'amount' => $amt,
+ 'currency' => $cur,
+ 'qty' => $qty,
+ 'marketplace' => $mkt,
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Inventory/RestockPlanner.php b/sg-commerce-pro/src/Suite/Inventory/RestockPlanner.php
new file mode 100644
index 0000000..2fe5570
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Inventory/RestockPlanner.php
@@ -0,0 +1,207 @@
+get_results( $wpdb->prepare(
+ "SELECT sku, asin, product_name, fulfillable_qty, reserved_qty, inbound_qty, my_price, buybox_price
+ FROM {$wpdb->prefix}sg_products WHERE market = %s", $market
+ ), ARRAY_A ) ?: array(); // phpcs:ignore
+
+ $sales = array();
+ foreach ( array( 7, 30, 90 ) as $w ) {
+ $rows = Db::rows(
+ "SELECT sku, SUM(qty) AS units, COUNT(DISTINCT local_date) AS active_days FROM {t:order_items}
+ WHERE market = %s AND local_date BETWEEN %s AND %s AND status <> 'Canceled' GROUP BY sku",
+ array( $market, SuiteTime::shift( $today, -$w ), SuiteTime::shift( $today, -1 ) )
+ );
+ foreach ( $rows as $r ) {
+ $sales[ (string) $r['sku'] ][ $w ] = (int) $r['units'];
+ }
+ }
+ // Days each SKU was out of stock in the last 30 days, from price-history snapshots.
+ $oos = array();
+ $oos_rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT p.sku, COUNT(DISTINCT DATE(h.recorded_at)) AS d FROM {$wpdb->prefix}sg_price_history h
+ INNER JOIN {$wpdb->prefix}sg_products p ON p.id = h.product_id
+ WHERE p.market = %s AND h.fulfillable_qty = 0 AND h.recorded_at >= %s GROUP BY p.sku",
+ $market, gmdate( 'Y-m-d', strtotime( $today . ' -30 days' ) )
+ ), ARRAY_A ) ?: array(); // phpcs:ignore
+ foreach ( $oos_rows as $r ) {
+ $oos[ (string) $r['sku'] ] = (int) $r['d'];
+ }
+
+ $inputs = array();
+ foreach ( $stock as $s ) {
+ $sku = (string) $s['sku'];
+ $cost = $this->costs->row( $market, $sku );
+ $inputs[] = array(
+ 'sku' => $sku,
+ 'asin' => (string) $s['asin'],
+ 'title' => (string) $s['product_name'],
+ 'fulfillable' => (int) $s['fulfillable_qty'],
+ 'reserved' => (int) $s['reserved_qty'],
+ 'inbound' => (int) $s['inbound_qty'],
+ 'units_7' => $sales[ $sku ][7] ?? 0,
+ 'units_30' => $sales[ $sku ][30] ?? 0,
+ 'units_90' => $sales[ $sku ][90] ?? 0,
+ 'oos_days_30' => $oos[ $sku ] ?? 0,
+ 'lead_time' => ( $cost['lead_time_days'] ?? 0 ) ?: $this->settings->int( 'suite_lead_time_days' ),
+ 'moq' => (int) ( $cost['moq'] ?? 0 ),
+ 'case_pack' => (int) ( $cost['case_pack'] ?? 0 ),
+ 'unit_cost' => $this->costs->landed( $market, $sku ),
+ 'price' => (float) ( $s['my_price'] ?? $s['buybox_price'] ?? 0 ),
+ );
+ }
+ return self::plan( $inputs, $today, array(
+ 'safety_days' => $this->settings->int( 'suite_safety_days' ),
+ 'target_cover' => $this->settings->int( 'suite_target_cover_days' ),
+ 'overstock_days' => $this->settings->int( 'suite_overstock_days' ),
+ ) );
+ }
+
+ public static function plan( array $inputs, string $today, array $cfg ): array {
+ $cfg += array( 'safety_days' => 14, 'target_cover' => 60, 'overstock_days' => 180 );
+ $rows = array();
+ $summary = array( 'skus' => 0, 'reorder' => 0, 'out_of_stock' => 0, 'overstock' => 0, 'order_units' => 0, 'order_value' => 0.0, 'lost_sales_per_day' => 0.0 );
+
+ foreach ( $inputs as $in ) {
+ $v7 = $in['units_7'] / 7;
+ $v30 = $in['units_30'] / max( 1, 30 - min( 29, (int) ( $in['oos_days_30'] ?? 0 ) ) );
+ $v90 = $in['units_90'] / 90;
+ $velocity = round( 0.5 * $v7 + 0.3 * $v30 + 0.2 * $v90, 3 );
+ $trend = $v30 > 0 ? round( ( $v7 - $v30 ) / $v30 * 100, 1 ) : null;
+
+ $fulfillable = max( 0, (int) $in['fulfillable'] );
+ $supply = $fulfillable + max( 0, (int) $in['reserved'] ) + max( 0, (int) $in['inbound'] );
+ $lead = max( 1, (int) $in['lead_time'] );
+ $safety = (int) $cfg['safety_days'];
+
+ $cover = $velocity > 0 ? $supply / $velocity : null;
+ $days_fulfillable = $velocity > 0 ? $fulfillable / $velocity : null;
+ $reorder_point = (int) ceil( $velocity * ( $lead + $safety ) );
+ $need = $velocity * ( $lead + (int) $cfg['target_cover'] ) - $supply;
+ $qty = $need > 0 ? (int) ceil( $need ) : 0;
+ if ( $qty > 0 && $in['case_pack'] > 0 ) {
+ $qty = (int) ( ceil( $qty / $in['case_pack'] ) * $in['case_pack'] );
+ }
+ if ( $qty > 0 && $in['moq'] > 0 ) {
+ $qty = max( $qty, (int) $in['moq'] );
+ }
+
+ $stockout = null !== $days_fulfillable ? SuiteTime::shift( $today, (int) floor( $days_fulfillable ) ) : null;
+ $reorder_by = null !== $cover ? SuiteTime::shift( $today, (int) floor( $cover ) - $lead - $safety ) : null;
+
+ if ( $velocity <= 0 ) {
+ $status = $supply > 0 ? 'no_sales' : 'out_of_stock';
+ $qty = 0;
+ } elseif ( 0 === $fulfillable && 0 === $supply ) {
+ $status = 'out_of_stock';
+ } elseif ( $cover <= $lead ) {
+ // Runs out before (or exactly when) a PO placed today would arrive.
+ $status = 'critical';
+ } elseif ( $supply <= $reorder_point ) {
+ $status = 'reorder_now';
+ } elseif ( $cover < $lead + $safety + 14 ) {
+ $status = 'reorder_soon';
+ } elseif ( $cover > (int) $cfg['overstock_days'] ) {
+ $status = 'overstock';
+ } else {
+ $status = 'healthy';
+ }
+ if ( 0 === $fulfillable && $velocity > 0 && 'out_of_stock' !== $status ) {
+ $status = 'out_of_stock';
+ }
+ // Total supply looks fine but sellable units run out before inbound stock is likely
+ // to be received (FBA check-in typically takes 1–2 weeks) → a stock-out gap.
+ if ( in_array( $status, array( 'healthy', 'reorder_soon', 'overstock' ), true ) && null !== $days_fulfillable
+ && $days_fulfillable < 10 && (int) $in['inbound'] > 0 ) {
+ $status = 'stockout_risk';
+ }
+
+ $lost = 'out_of_stock' === $status ? $velocity * (float) $in['price'] : 0.0;
+ $rows[] = array(
+ 'sku' => $in['sku'],
+ 'asin' => $in['asin'],
+ 'title' => $in['title'],
+ 'fulfillable' => $fulfillable,
+ 'reserved' => (int) $in['reserved'],
+ 'inbound' => (int) $in['inbound'],
+ 'supply' => $supply,
+ 'units_7' => (int) $in['units_7'],
+ 'units_30' => (int) $in['units_30'],
+ 'units_90' => (int) $in['units_90'],
+ 'velocity' => $velocity,
+ 'trend_pct' => $trend,
+ 'days_of_cover' => null !== $cover ? (int) floor( $cover ) : null,
+ 'stockout_date' => $stockout,
+ 'reorder_by' => $reorder_by,
+ 'reorder_point' => $reorder_point,
+ 'lead_time' => $lead,
+ 'order_qty' => $qty,
+ 'order_value' => round( $qty * (float) $in['unit_cost'], 2 ),
+ 'status' => $status,
+ 'lost_sales_day' => round( $lost, 2 ),
+ );
+ $summary['skus']++;
+ if ( in_array( $status, array( 'critical', 'reorder_now', 'stockout_risk' ), true ) || ( 'out_of_stock' === $status && $velocity > 0 ) ) {
+ $summary['reorder']++;
+ }
+ if ( 'out_of_stock' === $status ) {
+ $summary['out_of_stock']++;
+ }
+ if ( 'overstock' === $status ) {
+ $summary['overstock']++;
+ }
+ $summary['order_units'] += $qty;
+ $summary['order_value'] += $qty * (float) $in['unit_cost'];
+ $summary['lost_sales_per_day'] += $lost;
+ }
+ $rank = array( 'out_of_stock' => 0, 'critical' => 1, 'stockout_risk' => 2, 'reorder_now' => 3, 'reorder_soon' => 4, 'overstock' => 5, 'healthy' => 6, 'no_sales' => 7 );
+ usort( $rows, static fn( $a, $b ) => array( $rank[ $a['status'] ], $a['days_of_cover'] ?? PHP_INT_MAX ) <=> array( $rank[ $b['status'] ], $b['days_of_cover'] ?? PHP_INT_MAX ) );
+ $summary['order_value'] = round( $summary['order_value'], 2 );
+ $summary['lost_sales_per_day'] = round( $summary['lost_sales_per_day'], 2 );
+ return array( 'today' => $today, 'summary' => $summary, 'rows' => $rows );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Keywords/KeywordService.php b/sg-commerce-pro/src/Suite/Keywords/KeywordService.php
new file mode 100644
index 0000000..b624ca9
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Keywords/KeywordService.php
@@ -0,0 +1,164 @@
+ $market, 'asin' => strtoupper( $asin ), 'keyword' => $kw, 'priority' => max( 1, min( 3, $priority ) ),
+ 'source' => $source, 'created_at' => Db::now(), 'is_demo' => 0,
+ ), array( 'created_at', 'source' ) ) ) {
+ $n++;
+ }
+ }
+ return $n;
+ }
+
+ public function delete( int $id ): bool {
+ return Db::exec( 'DELETE FROM {t:keywords} WHERE id = %d', array( $id ) ) > 0;
+ }
+
+ /** Tracked keywords with latest + previous week SQP metrics. */
+ public function tracked( string $market, string $asin = '' ): array {
+ $args = array( $market );
+ $where = 'k.market = %s';
+ if ( '' !== $asin ) {
+ $where .= ' AND k.asin = %s';
+ $args[] = $asin;
+ }
+ $rows = Db::rows( "SELECT k.* FROM {t:keywords} k WHERE {$where} ORDER BY k.priority ASC, k.keyword ASC", $args );
+ if ( ! $rows ) {
+ return array();
+ }
+ $weeks = array_column( Db::rows( 'SELECT DISTINCT period_start FROM {t:keyword_metrics} WHERE market = %s ORDER BY period_start DESC LIMIT 8', array( $market ) ), 'period_start' );
+ $metrics = array();
+ if ( $weeks ) {
+ foreach ( Db::rows( 'SELECT * FROM {t:keyword_metrics} WHERE market = %s AND period_start IN (' . Db::in( $weeks ) . ')', array_merge( array( $market ), $weeks ) ) as $m ) {
+ $metrics[ $m['asin'] . '|' . $m['keyword'] ][ $m['period_start'] ] = $m;
+ }
+ }
+ foreach ( $rows as &$r ) {
+ $hist = $metrics[ $r['asin'] . '|' . $r['keyword'] ] ?? array();
+ $cur = isset( $weeks[0] ) ? ( $hist[ $weeks[0] ] ?? null ) : null;
+ $prev = isset( $weeks[1] ) ? ( $hist[ $weeks[1] ] ?? null ) : null;
+ $r['volume'] = null !== $cur ? (int) $cur['query_volume'] : null;
+ $r['impr_share'] = self::share( $cur, 'impressions' );
+ $r['click_share'] = self::share( $cur, 'clicks' );
+ $r['purchase_share'] = self::share( $cur, 'purchases' );
+ $r['purchase_share_prev'] = self::share( $prev, 'purchases' );
+ $r['ctr'] = null !== $cur && (int) $cur['impressions_asin'] > 0 ? round( (int) $cur['clicks_asin'] / (int) $cur['impressions_asin'] * 100, 2 ) : null;
+ $r['cvr'] = null !== $cur && (int) $cur['clicks_asin'] > 0 ? round( (int) $cur['purchases_asin'] / (int) $cur['clicks_asin'] * 100, 2 ) : null;
+ $r['trend'] = array();
+ foreach ( array_reverse( $weeks ) as $w ) {
+ $r['trend'][] = self::share( $hist[ $w ] ?? null, 'impressions' ) ?? 0;
+ }
+ $r['week'] = $weeks[0] ?? null;
+ }
+ return $rows;
+ }
+
+ /** Every query Brand Analytics returned for the ASIN (not only tracked ones). */
+ public function sqp_queries( string $market, string $asin ): array {
+ $week = (string) Db::var( 'SELECT MAX(period_start) FROM {t:keyword_metrics} WHERE market = %s AND asin = %s', array( $market, $asin ) );
+ if ( '' === $week ) {
+ return array();
+ }
+ $rows = Db::rows(
+ 'SELECT * FROM {t:keyword_metrics} WHERE market = %s AND asin = %s AND period_start = %s ORDER BY query_volume DESC LIMIT 200',
+ array( $market, $asin, $week )
+ );
+ foreach ( $rows as &$r ) {
+ $r['impr_share'] = self::share( $r, 'impressions' );
+ $r['click_share'] = self::share( $r, 'clicks' );
+ $r['purchase_share'] = self::share( $r, 'purchases' );
+ }
+ return $rows;
+ }
+
+ /** Keyword ideas for ASINs via Ads API, merged with converting search terms. */
+ public function research( array $asins, string $locale = 'en_US' ): array {
+ $ideas = array();
+ if ( $this->ads->is_ready() ) {
+ $res = $this->ads->keyword_recommendations( $asins, $locale );
+ $list = $res['keywordTargetList'] ?? $res['recommendations'] ?? $res['keywords'] ?? array();
+ foreach ( (array) $list as $k ) {
+ $kw = mb_strtolower( (string) ( $k['keyword'] ?? $k['keywordText'] ?? '' ) );
+ if ( '' === $kw ) {
+ continue;
+ }
+ $bid = null;
+ foreach ( (array) ( $k['bidInfo'] ?? array() ) as $b ) {
+ if ( 'EXACT' === ( $b['matchType'] ?? '' ) ) {
+ $bid = $b['suggestedBid']['rangeMedian'] ?? $b['bid'] ?? null;
+ }
+ }
+ $ideas[ $kw ] = array(
+ 'keyword' => $kw,
+ 'source' => 'amazon_ads',
+ // Recommendation bids arrive in minor units (cents) for most locales; normalise to currency.
+ 'suggested_bid' => null !== $bid ? round( (float) $bid / ( (float) $bid >= 10 ? 100 : 1 ), 2 ) : null,
+ 'impression_rank'=> $k['searchTermImpressionRank'] ?? null,
+ 'impression_share' => $k['searchTermImpressionShare'] ?? null,
+ 'orders' => null,
+ 'acos' => null,
+ );
+ }
+ }
+ $terms = Db::rows(
+ "SELECT search_term, SUM(clicks) AS clicks, SUM(orders) AS orders, SUM(cost) AS cost, SUM(sales) AS sales
+ FROM {t:ads_search_terms} WHERE report_date >= %s GROUP BY search_term HAVING SUM(orders) > 0 ORDER BY SUM(orders) DESC LIMIT 100",
+ array( gmdate( 'Y-m-d', time() - 60 * DAY_IN_SECONDS ) )
+ );
+ foreach ( $terms as $t ) {
+ $kw = mb_strtolower( (string) $t['search_term'] );
+ $acos = (float) $t['sales'] > 0 ? round( (float) $t['cost'] / (float) $t['sales'] * 100, 1 ) : null;
+ $ideas[ $kw ] = array_merge( $ideas[ $kw ] ?? array( 'keyword' => $kw, 'suggested_bid' => null, 'impression_rank' => null, 'impression_share' => null ), array(
+ 'source' => isset( $ideas[ $kw ] ) ? 'both' : 'your_ads',
+ 'orders' => (int) $t['orders'],
+ 'acos' => $acos,
+ ) );
+ }
+ return array_values( $ideas );
+ }
+
+ private static function share( ?array $m, string $what ): ?float {
+ if ( null === $m ) {
+ return null;
+ }
+ $total = (int) $m[ $what . '_total' ];
+ return $total > 0 ? round( (int) $m[ $what . '_asin' ] / $total * 100, 2 ) : null;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Listings/ListingAuditor.php b/sg-commerce-pro/src/Suite/Listings/ListingAuditor.php
new file mode 100644
index 0000000..37ac09a
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Listings/ListingAuditor.php
@@ -0,0 +1,335 @@
+settings->seller_id();
+ if ( '' === $seller ) {
+ throw new \RuntimeException( 'Enter your Seller ID (Merchant Token) in Amazon Suite → Settings to audit listings.' );
+ }
+ $mp = $this->client->marketplace_info( $market );
+ $res = $this->client->request(
+ 'GET', $market,
+ '/listings/2021-08-01/items/' . rawurlencode( $seller ) . '/' . rawurlencode( $sku ),
+ array( 'marketplaceIds' => (string) ( $mp['id'] ?? '' ), 'includedData' => 'summaries,attributes,issues' ),
+ null, 'listings.get'
+ );
+ $content = self::extract( $res );
+ $result = self::score( $content, $keywords );
+ self::store( $market, $sku, $content, $result );
+ return $result + array( 'content' => $content );
+ }
+
+ public static function store( string $market, string $sku, array $content, array $result, bool $demo = false ): void {
+ $hash = sha1( wp_json_encode( array( $content['title'], $content['bullets'], $content['description'], $content['images'] ) ) );
+ $prev = Db::row( 'SELECT content_hash FROM {t:listing_audits} WHERE market = %s AND sku = %s', array( $market, $sku ) );
+ if ( ! $demo && null !== $prev && '' !== (string) $prev['content_hash'] && $prev['content_hash'] !== $hash ) {
+ SuiteAlerts::raise(
+ 'warning', 'suite_listing',
+ sprintf( 'Listing content changed: %s (%s)', $sku, $content['asin'] ),
+ 'Title, bullets, description or images differ from the last audit. If you did not make this change, check for a catalog contribution from Amazon or another seller.',
+ array( 'market' => $market, 'sku' => $sku )
+ );
+ }
+ Db::upsert( 'listing_audits', array(
+ 'market' => $market,
+ 'sku' => $sku,
+ 'asin' => (string) $content['asin'],
+ 'score' => (int) $result['score'],
+ 'status' => (string) $result['status'],
+ 'content_hash' => $hash,
+ 'findings' => wp_json_encode( $result['findings'] ),
+ 'snapshot' => wp_json_encode( $content ),
+ 'audited_at' => Db::now(),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ }
+
+ /** Normalise a Listings API response into plain content fields. */
+ public static function extract( array $res ): array {
+ $attr = (array) ( $res['attributes'] ?? array() );
+ $sum = (array) ( $res['summaries'][0] ?? array() );
+ $val = static fn( string $k ): string => (string) ( $attr[ $k ][0]['value'] ?? '' );
+ $bullets = array();
+ foreach ( (array) ( $attr['bullet_point'] ?? array() ) as $b ) {
+ if ( '' !== trim( (string) ( $b['value'] ?? '' ) ) ) {
+ $bullets[] = (string) $b['value'];
+ }
+ }
+ $images = array();
+ if ( ! empty( $attr['main_product_image_locator'][0]['media_location'] ) ) {
+ $images[] = (string) $attr['main_product_image_locator'][0]['media_location'];
+ } elseif ( ! empty( $sum['mainImage']['link'] ) ) {
+ $images[] = (string) $sum['mainImage']['link'];
+ }
+ for ( $i = 1; $i <= 8; $i++ ) {
+ $loc = $attr[ 'other_product_image_locator_' . $i ][0]['media_location'] ?? '';
+ if ( '' !== $loc ) {
+ $images[] = (string) $loc;
+ }
+ }
+ $issues = array();
+ foreach ( (array) ( $res['issues'] ?? array() ) as $is ) {
+ $issues[] = array(
+ 'code' => (string) ( $is['code'] ?? '' ),
+ 'severity' => strtoupper( (string) ( $is['severity'] ?? 'INFO' ) ),
+ 'message' => (string) ( $is['message'] ?? '' ),
+ );
+ }
+ return array(
+ 'asin' => (string) ( $sum['asin'] ?? '' ),
+ 'title' => '' !== $val( 'item_name' ) ? $val( 'item_name' ) : (string) ( $sum['itemName'] ?? '' ),
+ 'brand' => $val( 'brand' ),
+ 'bullets' => $bullets,
+ 'description' => $val( 'product_description' ),
+ 'backend' => $val( 'generic_keyword' ),
+ 'images' => $images,
+ 'status' => array_values( array_map( 'strval', (array) ( $sum['status'] ?? array() ) ) ),
+ 'issues' => $issues,
+ 'product_type'=> (string) ( $sum['productType'] ?? '' ),
+ );
+ }
+
+ /**
+ * @param array $c extract() output
+ * @param string[] $keywords tracked keywords for coverage scoring
+ * @return array{score:int, grade:string, status:string, findings:array, sections:array, keyword_coverage:?array}
+ */
+ public static function score( array $c, array $keywords = array() ): array {
+ $f = array();
+ $sec = array( 'title' => 25, 'bullets' => 25, 'description' => 10, 'backend' => 10, 'images' => 20, 'health' => 10 );
+ $hit = static function ( string $section, int $pts, string $sev, string $msg ) use ( &$sec, &$f ): void {
+ $sec[ $section ] = max( 0, $sec[ $section ] - $pts );
+ $f[] = array( 'section' => $section, 'severity' => $sev, 'message' => $msg );
+ };
+
+ // Title.
+ $title = trim( (string) $c['title'] );
+ $tlen = mb_strlen( $title );
+ if ( '' === $title ) {
+ $hit( 'title', 25, 'error', 'Title is missing.' );
+ } else {
+ if ( $tlen > 200 ) {
+ $hit( 'title', 10, 'error', "Title is {$tlen} characters — Amazon's limit is 200; longer titles get suppressed." );
+ } elseif ( $tlen < 80 ) {
+ $hit( 'title', 6, 'warning', "Title is only {$tlen} characters. 80–200 lets you cover brand + product + key features + size/count." );
+ }
+ $bad = array_values( array_filter( self::BANNED_CHARS, static fn( $ch ) => str_contains( $title, $ch ) ) );
+ if ( $bad ) {
+ $hit( 'title', 5, 'error', 'Title contains decorative characters not allowed by Amazon policy: ' . implode( ' ', $bad ) );
+ }
+ $repeats = self::repeated_words( $title );
+ if ( $repeats ) {
+ $hit( 'title', 5, 'error', 'Words used more than twice in title (policy violation): ' . implode( ', ', $repeats ) );
+ }
+ if ( '' !== $c['brand'] && ! str_starts_with( mb_strtolower( $title ), mb_strtolower( (string) $c['brand'] ) ) ) {
+ $hit( 'title', 3, 'warning', 'Title should start with the brand name ("' . $c['brand'] . '").' );
+ }
+ if ( preg_match( '/\b[A-Z]{4,}\b.*\b[A-Z]{4,}\b.*\b[A-Z]{4,}\b/', $title ) ) {
+ $hit( 'title', 3, 'warning', 'Avoid ALL-CAPS words in the title.' );
+ }
+ $claims = self::claims( $title );
+ if ( $claims ) {
+ $hit( 'title', 5, 'error', 'Prohibited promotional/medical claims in title: ' . implode( ', ', $claims ) );
+ }
+ }
+
+ // Bullets.
+ $bullets = (array) $c['bullets'];
+ $nb = count( $bullets );
+ if ( 0 === $nb ) {
+ $hit( 'bullets', 25, 'error', 'No bullet points.' );
+ } else {
+ if ( $nb < 5 ) {
+ $hit( 'bullets', ( 5 - $nb ) * 4, 'warning', "Only {$nb} of 5 bullet points used." );
+ }
+ foreach ( $bullets as $i => $b ) {
+ $len = mb_strlen( $b );
+ $n = $i + 1;
+ if ( $len < 60 ) {
+ $hit( 'bullets', 2, 'warning', "Bullet {$n} is short ({$len} chars) — aim for 150–250 to explain a benefit." );
+ } elseif ( $len > 500 ) {
+ $hit( 'bullets', 2, 'warning', "Bullet {$n} is {$len} chars; many categories truncate above 500 bytes." );
+ }
+ if ( preg_match( '/[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]/u', $b ) ) {
+ $hit( 'bullets', 3, 'error', "Bullet {$n} contains emoji/special symbols (prohibited)." );
+ }
+ $claims = self::claims( $b );
+ if ( $claims ) {
+ $hit( 'bullets', 3, 'error', "Bullet {$n} has prohibited claims: " . implode( ', ', $claims ) );
+ }
+ }
+ }
+
+ // Description.
+ $dlen = mb_strlen( self::plain( (string) $c['description'] ) );
+ if ( 0 === $dlen ) {
+ $hit( 'description', 10, 'warning', 'No product description (A+ Content replaces it on the page, but the text is still indexed).' );
+ } elseif ( $dlen < 500 ) {
+ $hit( 'description', 5, 'warning', "Description is {$dlen} characters — 1000–2000 performs better." );
+ } elseif ( $dlen > 2000 ) {
+ $hit( 'description', 4, 'error', "Description is {$dlen} characters; the limit is 2000." );
+ }
+
+ // Backend search terms.
+ $backend = trim( (string) $c['backend'] );
+ $bytes = strlen( $backend );
+ if ( '' === $backend ) {
+ $hit( 'backend', 10, 'warning', 'Backend search terms (generic_keyword) are empty — free indexing left on the table.' );
+ } else {
+ if ( $bytes > 249 ) {
+ $hit( 'backend', 8, 'error', "Backend search terms are {$bytes} bytes; anything over 249 bytes is NOT indexed at all." );
+ } elseif ( $bytes < 150 ) {
+ $hit( 'backend', 3, 'warning', "Backend search terms use only {$bytes}/249 bytes." );
+ }
+ if ( preg_match( '/\bB0[A-Z0-9]{8}\b/i', $backend ) ) {
+ $hit( 'backend', 4, 'error', 'Backend search terms must not contain ASINs.' );
+ }
+ $dupes = array_intersect( self::words( $backend ), self::words( $title ) );
+ if ( count( $dupes ) >= 3 ) {
+ $hit( 'backend', 2, 'info', 'Backend terms repeat title words (' . implode( ', ', array_slice( array_unique( $dupes ), 0, 6 ) ) . ') — those bytes are wasted.' );
+ }
+ }
+
+ // Images.
+ $ni = count( (array) $c['images'] );
+ if ( 0 === $ni ) {
+ $hit( 'images', 20, 'error', 'No images.' );
+ } elseif ( $ni < 7 ) {
+ $hit( 'images', ( 7 - $ni ) * 3, 'warning', "{$ni} images — use all 7+ slots (main, infographics, lifestyle, size, packaging)." );
+ }
+
+ // Health.
+ $errors = array_filter( (array) $c['issues'], static fn( $i ) => 'ERROR' === $i['severity'] );
+ $warns = array_filter( (array) $c['issues'], static fn( $i ) => 'WARNING' === $i['severity'] );
+ foreach ( $errors as $i ) {
+ $hit( 'health', 5, 'error', 'Amazon issue ' . $i['code'] . ': ' . $i['message'] );
+ }
+ foreach ( $warns as $i ) {
+ $hit( 'health', 2, 'warning', 'Amazon warning ' . $i['code'] . ': ' . $i['message'] );
+ }
+ $status_list = (array) $c['status'];
+ $buyable = in_array( 'BUYABLE', $status_list, true );
+ if ( $status_list && ! $buyable ) {
+ $hit( 'health', 10, 'error', 'Listing is NOT buyable (inactive or suppressed).' );
+ }
+
+ // Keyword coverage.
+ $coverage = null;
+ if ( $keywords ) {
+ $haystack = mb_strtolower( $title . ' ' . implode( ' ', $bullets ) . ' ' . $backend . ' ' . $c['description'] );
+ $found = array();
+ $missing = array();
+ foreach ( $keywords as $kw ) {
+ $kw = mb_strtolower( trim( (string) $kw ) );
+ if ( '' === $kw ) {
+ continue;
+ }
+ $all = true;
+ foreach ( preg_split( '/\s+/', $kw ) as $w ) {
+ if ( ! str_contains( $haystack, $w ) ) {
+ $all = false;
+ break;
+ }
+ }
+ $all ? $found[] = $kw : $missing[] = $kw;
+ }
+ $total = count( $found ) + count( $missing );
+ $coverage = array(
+ 'pct' => $total > 0 ? (int) round( count( $found ) / $total * 100 ) : 0,
+ 'found' => $found,
+ 'missing' => $missing,
+ );
+ if ( $missing ) {
+ $f[] = array( 'section' => 'keywords', 'severity' => 'info', 'message' => 'Tracked keywords not present anywhere in the listing: ' . implode( ', ', array_slice( $missing, 0, 10 ) ) );
+ }
+ }
+
+ $score = array_sum( $sec );
+ return array(
+ 'score' => $score,
+ 'grade' => $score >= 90 ? 'A' : ( $score >= 80 ? 'B' : ( $score >= 65 ? 'C' : ( $score >= 50 ? 'D' : 'F' ) ) ),
+ 'status' => ! $status_list ? 'unknown' : ( $buyable ? 'active' : 'suppressed' ),
+ 'findings' => $f,
+ 'sections' => $sec,
+ 'keyword_coverage' => $coverage,
+ );
+ }
+
+ /** @return string[] */
+ public static function repeated_words( string $text ): array {
+ $counts = array_count_values( self::words( $text ) );
+ return array_keys( array_filter( $counts, static fn( $n ) => $n > 2 ) );
+ }
+
+ /** @return string[] lower-case content words */
+ public static function words( string $text ): array {
+ $parts = preg_split( '/[^\p{L}\p{N}]+/u', mb_strtolower( $text ) ) ?: array();
+ return array_values( array_filter( $parts, static fn( $w ) => '' !== $w && mb_strlen( $w ) > 1 && ! in_array( $w, self::STOP_WORDS, true ) && ! is_numeric( $w ) ) );
+ }
+
+ /** Strip HTML without WordPress (keeps the scorer unit-testable). */
+ public static function plain( string $s ): string {
+ $s = (string) preg_replace( '#<(script|style)[^>]*?>.*?\\1>#si', '', $s );
+ return trim( (string) preg_replace( '/\s+/', ' ', strip_tags( $s ) ) );
+ }
+
+ /** @return string[] */
+ public static function claims( string $text ): array {
+ $t = ' ' . mb_strtolower( $text ) . ' ';
+ $out = array();
+ foreach ( self::CLAIMS as $claim ) {
+ if ( preg_match( '/(^|[^\p{L}])' . preg_quote( $claim, '/' ) . '([^\p{L}]|$)/u', $t ) ) {
+ $out[] = $claim;
+ }
+ }
+ return $out;
+ }
+}
+
diff --git a/sg-commerce-pro/src/Suite/Monitor/ListingMonitor.php b/sg-commerce-pro/src/Suite/Monitor/ListingMonitor.php
new file mode 100644
index 0000000..75ebaf6
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Monitor/ListingMonitor.php
@@ -0,0 +1,266 @@
+ */
+ public function watchlist(): array {
+ global $wpdb;
+ $out = array();
+ $rows = $wpdb->get_results( "SELECT DISTINCT market, asin FROM {$wpdb->prefix}sg_products WHERE asin <> ''", ARRAY_A ) ?: array(); // phpcs:ignore
+ foreach ( $rows as $r ) {
+ $out[ $r['market'] . '|' . $r['asin'] ] = array( 'market' => (string) $r['market'], 'asin' => (string) $r['asin'], 'mine' => true );
+ }
+ $comp = $wpdb->get_results( "SELECT DISTINCT market, asin FROM {$wpdb->prefix}sg_competitors", ARRAY_A ) ?: array(); // phpcs:ignore
+ foreach ( $comp as $r ) {
+ $out[ $r['market'] . '|' . $r['asin'] ] ??= array( 'market' => (string) $r['market'], 'asin' => (string) $r['asin'], 'mine' => false );
+ }
+ $primary = $this->marketplaces->primary();
+ foreach ( preg_split( '/[\s,]+/', strtoupper( $this->settings->string( 'suite_monitor_extra_asins' ) ) ) ?: array() as $a ) {
+ if ( 1 === preg_match( '/^B0[A-Z0-9]{8}$/', $a ) ) {
+ $out[ $primary . '|' . $a ] ??= array( 'market' => $primary, 'asin' => $a, 'mine' => false );
+ }
+ }
+ return array_values( $out );
+ }
+
+ /** @return array{checked:int, alerts:int} */
+ public function run(): array {
+ $stats = array( 'checked' => 0, 'alerts' => 0 );
+ if ( ! $this->client->has_credentials() ) {
+ return $stats;
+ }
+ $list = $this->watchlist();
+ if ( ! $list ) {
+ return $stats;
+ }
+ $cursor = (int) get_option( self::CURSOR, 0 ) % count( $list );
+ $batch = array_slice( array_merge( array_slice( $list, $cursor ), array_slice( $list, 0, $cursor ) ), 0, self::BATCH );
+ foreach ( $batch as $item ) {
+ try {
+ $mp = $this->marketplaces->get( $item['market'] );
+ $res = $this->client->request(
+ 'GET', $item['market'],
+ '/products/pricing/v0/items/' . rawurlencode( $item['asin'] ) . '/offers',
+ array( 'MarketplaceId' => (string) ( $mp['id'] ?? '' ), 'ItemCondition' => 'New' ),
+ null, 'pricing.itemOffers'
+ );
+ $stats['alerts'] += $this->process( $item['market'], $item['asin'], $item['mine'], (array) ( $res['payload'] ?? array() ) );
+ $stats['checked']++;
+ } catch ( \Throwable $e ) {
+ $this->logger->warning( 'Suite monitor fetch failed', array( 'asin' => $item['asin'], 'error' => $e->getMessage() ) );
+ if ( str_contains( $e->getMessage(), 'Rate limit' ) || str_contains( $e->getMessage(), '429' ) ) {
+ break;
+ }
+ }
+ }
+ update_option( self::CURSOR, ( $cursor + count( $batch ) ) % max( 1, count( $list ) ), false );
+ return $stats;
+ }
+
+ /**
+ * Process one getItemOffers payload. Returns number of alerts raised.
+ */
+ public function process( string $market, string $asin, bool $mine, array $payload, bool $demo = false, ?string $now = null ): int {
+ $now = $now ?? Db::now();
+ $me = $this->settings->seller_id();
+ $offers = (array) ( $payload['Offers'] ?? array() );
+ $summary = (array) ( $payload['Summary'] ?? array() );
+
+ $bb_price = null;
+ $bb_seller = '';
+ $my_price = null;
+ $lowest = null;
+ $fba = 0;
+ $sellers = array();
+ foreach ( $offers as $o ) {
+ $price = (float) ( $o['ListingPrice']['Amount'] ?? 0 ) + (float) ( $o['Shipping']['Amount'] ?? 0 );
+ $seller = (string) ( $o['SellerId'] ?? '' );
+ $is_me = ! empty( $o['MyOffer'] ) || ( '' !== $me && $seller === $me );
+ if ( ! empty( $o['IsBuyBoxWinner'] ) ) {
+ $bb_price = $price;
+ $bb_seller = $is_me ? ( '' !== $me ? $me : 'ME' ) : $seller;
+ }
+ if ( $is_me ) {
+ $my_price = $price;
+ }
+ if ( ! empty( $o['IsFulfilledByAmazon'] ) ) {
+ $fba++;
+ }
+ $lowest = null === $lowest ? $price : min( $lowest, $price );
+ if ( '' !== $seller ) {
+ $sellers[ $seller ] = array( 'is_me' => $is_me, 'fba' => ! empty( $o['IsFulfilledByAmazon'] ), 'price' => $price );
+ }
+ }
+ if ( null === $bb_price && ! empty( $summary['BuyBoxPrices'][0] ) ) {
+ $bp = $summary['BuyBoxPrices'][0];
+ $bb_price = (float) ( $bp['LandedPrice']['Amount'] ?? $bp['ListingPrice']['Amount'] ?? 0 ) ?: null;
+ }
+ $offer_count = 0;
+ foreach ( (array) ( $summary['NumberOfOffers'] ?? array() ) as $n ) {
+ $offer_count += (int) ( $n['OfferCount'] ?? 0 );
+ }
+ $offer_count = max( $offer_count, count( $offers ) );
+ $bsr = null;
+ $bsr_cat = '';
+ foreach ( (array) ( $summary['SalesRankings'] ?? array() ) as $rank ) {
+ $r = (int) ( $rank['Rank'] ?? 0 );
+ $cat = (string) ( $rank['ProductCategoryId'] ?? '' );
+ if ( $r <= 0 ) {
+ continue;
+ }
+ // The top-level display-group rank is the BSR shown on the detail page.
+ if ( str_contains( $cat, '_display_on_website' ) ) {
+ $bsr = $r;
+ $bsr_cat = $cat;
+ break;
+ }
+ if ( null === $bsr ) {
+ $bsr = $r;
+ $bsr_cat = $cat;
+ }
+ }
+ $bb_is_mine = '' !== $bb_seller && ( $bb_seller === $me || 'ME' === $bb_seller );
+
+ $prev = Db::row(
+ 'SELECT * FROM {t:asin_snapshots} WHERE market = %s AND asin = %s ORDER BY captured_at DESC LIMIT 1',
+ array( $market, $asin )
+ );
+ Db::insert( 'asin_snapshots', array(
+ 'market' => $market, 'asin' => $asin, 'captured_at' => $now, 'is_mine' => $mine ? 1 : 0,
+ 'my_price' => $my_price, 'buybox_price' => $bb_price, 'buybox_seller' => $bb_seller, 'buybox_is_mine' => $bb_is_mine ? 1 : 0,
+ 'lowest_price' => $lowest, 'offer_count' => $offer_count, 'fba_offer_count' => $fba, 'bsr' => $bsr,
+ 'bsr_category' => mb_substr( $bsr_cat, 0, 128 ), 'is_demo' => $demo ? 1 : 0,
+ ) );
+
+ $alerts = 0;
+ // Seller tracking + hijacker detection.
+ $known = array();
+ foreach ( Db::rows( 'SELECT seller_id FROM {t:offer_sellers} WHERE market = %s AND asin = %s', array( $market, $asin ) ) as $k ) {
+ $known[ (string) $k['seller_id'] ] = true;
+ }
+ $first_scan = ! $known;
+ foreach ( $sellers as $sid => $s ) {
+ Db::upsert( 'offer_sellers', array(
+ 'market' => $market, 'asin' => $asin, 'seller_id' => $sid, 'is_me' => $s['is_me'] ? 1 : 0,
+ 'is_fba' => $s['fba'] ? 1 : 0, 'last_price' => $s['price'], 'first_seen' => $now, 'last_seen' => $now,
+ 'active' => 1, 'is_demo' => $demo ? 1 : 0,
+ ), array( 'first_seen' ) );
+ if ( $mine && ! $s['is_me'] && ! isset( $known[ $sid ] ) && ! $first_scan && ! $demo ) {
+ SuiteAlerts::raise(
+ 'critical', 'suite_hijacker',
+ sprintf( 'New seller on your ASIN %s: %s (%s @ %s)', $asin, $sid, $s['fba'] ? 'FBA' : 'FBM', number_format( $s['price'], 2 ) ),
+ 'A seller that was not on this listing before is now offering it. If you are brand-registered, verify authenticity and file a Report a Violation case; consider a test buy.',
+ array( 'market' => $market, 'asin' => $asin, 'seller' => $sid )
+ );
+ $alerts++;
+ }
+ }
+ if ( $sellers ) {
+ Db::exec(
+ 'UPDATE {t:offer_sellers} SET active = 0 WHERE market = %s AND asin = %s AND seller_id NOT IN (' . Db::in( array_keys( $sellers ) ) . ')',
+ array_merge( array( $market, $asin ), array_keys( $sellers ) )
+ );
+ }
+
+ if ( $mine && null !== $prev && ! $demo ) {
+ if ( (int) $prev['buybox_is_mine'] && ! $bb_is_mine ) {
+ SuiteAlerts::raise(
+ 'warning', 'suite_buybox',
+ '' === $bb_seller ? "Buy Box suppressed on {$asin}" : "Buy Box lost on {$asin} to {$bb_seller}",
+ '' === $bb_seller ? 'No offer is winning the Buy Box — usually a price well above the reference price. Check your price vs. other channels.' : sprintf( 'Winning price now %s.', null !== $bb_price ? number_format( $bb_price, 2 ) : '?' ),
+ array( 'market' => $market, 'asin' => $asin )
+ );
+ $alerts++;
+ }
+ }
+ if ( ! $mine && null !== $prev && null !== $prev['lowest_price'] && null !== $lowest && ! $demo ) {
+ $drop = ( (float) $prev['lowest_price'] - $lowest ) / max( 0.01, (float) $prev['lowest_price'] ) * 100;
+ if ( $drop >= $this->settings->float( 'suite_monitor_price_drop_pct' ) ) {
+ SuiteAlerts::raise(
+ 'info', 'suite_competitor',
+ sprintf( 'Competitor %s dropped price %.0f%% (%s → %s)', $asin, $drop, number_format( (float) $prev['lowest_price'], 2 ), number_format( $lowest, 2 ) ),
+ '', array( 'market' => $market, 'asin' => $asin )
+ );
+ $alerts++;
+ }
+ }
+ return $alerts;
+ }
+
+ /** Latest snapshot per watched ASIN + seller counts. */
+ public function board(): array {
+ $rows = Db::rows(
+ 'SELECT s.* FROM {t:asin_snapshots} s
+ INNER JOIN (SELECT market, asin, MAX(captured_at) AS m FROM {t:asin_snapshots} GROUP BY market, asin) x
+ ON x.market = s.market AND x.asin = s.asin AND x.m = s.captured_at
+ ORDER BY s.is_mine DESC, s.asin'
+ );
+ $sellers = array();
+ foreach ( Db::rows( 'SELECT market, asin, COUNT(*) AS n, SUM(CASE WHEN is_me = 0 THEN 1 ELSE 0 END) AS others FROM {t:offer_sellers} WHERE active = 1 GROUP BY market, asin' ) as $r ) {
+ $sellers[ $r['market'] . '|' . $r['asin'] ] = $r;
+ }
+ foreach ( $rows as &$r ) {
+ $k = $r['market'] . '|' . $r['asin'];
+ $r['active_sellers'] = (int) ( $sellers[ $k ]['n'] ?? 0 );
+ $r['other_sellers'] = (int) ( $sellers[ $k ]['others'] ?? 0 );
+ }
+ return $rows;
+ }
+
+ public function history( string $market, string $asin, int $days = 90 ): array {
+ return Db::rows(
+ 'SELECT captured_at, my_price, buybox_price, lowest_price, offer_count, bsr, buybox_is_mine FROM {t:asin_snapshots}
+ WHERE market = %s AND asin = %s AND captured_at >= %s ORDER BY captured_at ASC',
+ array( $market, $asin, gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) )
+ );
+ }
+
+ public function sellers( string $market, string $asin ): array {
+ return Db::rows( 'SELECT * FROM {t:offer_sellers} WHERE market = %s AND asin = %s ORDER BY active DESC, last_seen DESC', array( $market, $asin ) );
+ }
+
+ public function prune( int $days = 365 ): int {
+ return Db::exec( 'DELETE FROM {t:asin_snapshots} WHERE captured_at < %s', array( gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Orders/OrdersSync.php b/sg-commerce-pro/src/Suite/Orders/OrdersSync.php
new file mode 100644
index 0000000..2c7b80d
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Orders/OrdersSync.php
@@ -0,0 +1,269 @@
+ 0, 'items' => 0 );
+ if ( ! $this->client->has_credentials() ) {
+ return $stats;
+ }
+ foreach ( $this->marketplaces->enabled_codes() as $market ) {
+ try {
+ $stats['orders'] += $this->sync_market( $market );
+ } catch ( \Throwable $e ) {
+ $this->logger->warning( 'Suite orders sync failed', array( 'market' => $market, 'error' => $e->getMessage() ) );
+ }
+ }
+ $stats['items'] = $this->sync_pending_items( max( 1, $this->settings->int( 'suite_order_items_per_run' ) ) );
+ return $stats;
+ }
+
+ public function sync_market( string $market ): int {
+ $mp = $this->marketplaces->get( $market );
+ if ( ! $mp ) {
+ return 0;
+ }
+ $cursor = (string) get_option( self::CURSOR_OPTION . $market, '' );
+ if ( '' === $cursor ) {
+ // First run: the report backfill covers history; API covers the last 2 days.
+ $cursor = gmdate( 'Y-m-d\TH:i:s\Z', time() - 2 * DAY_IN_SECONDS );
+ }
+ $query = array(
+ 'MarketplaceIds' => $mp['id'],
+ 'LastUpdatedAfter' => $cursor,
+ 'MaxResultsPerPage' => 100,
+ );
+ $count = 0;
+ $pages = 0;
+ $latest = $cursor;
+ do {
+ $res = $this->client->request( 'GET', $market, '/orders/v0/orders', $query, null, 'orders.list' );
+ $payload = (array) ( $res['payload'] ?? array() );
+ foreach ( (array) ( $payload['Orders'] ?? array() ) as $o ) {
+ if ( $this->store_api_order( $o, $market ) ) {
+ $count++;
+ }
+ $lu = (string) ( $o['LastUpdateDate'] ?? '' );
+ if ( '' !== $lu && strcmp( $lu, $latest ) > 0 ) {
+ $latest = $lu;
+ }
+ }
+ $next = (string) ( $payload['NextToken'] ?? '' );
+ $query = array( 'MarketplaceIds' => $mp['id'], 'NextToken' => $next );
+ $pages++;
+ } while ( '' !== $next && $pages < self::MAX_PAGES );
+
+ update_option( self::CURSOR_OPTION . $market, $latest, false );
+ return $count;
+ }
+
+ public function store_api_order( array $o, string $market, bool $demo = false ): bool {
+ $id = (string) ( $o['AmazonOrderId'] ?? '' );
+ if ( '' === $id ) {
+ return false;
+ }
+ $purchase = SuiteTime::to_mysql( (string) ( $o['PurchaseDate'] ?? '' ) ) ?? Db::now();
+ $code = $this->marketplaces->code_by_id( (string) ( $o['MarketplaceId'] ?? '' ) ) ?: $market;
+ $units = (int) ( $o['NumberOfItemsShipped'] ?? 0 ) + (int) ( $o['NumberOfItemsUnshipped'] ?? 0 );
+ $row = array(
+ 'market' => $code,
+ 'amazon_order_id' => $id,
+ 'purchase_date' => $purchase,
+ 'local_date' => SuiteTime::local_date( $purchase, $code ),
+ 'last_update' => SuiteTime::to_mysql( (string) ( $o['LastUpdateDate'] ?? '' ) ),
+ 'status' => (string) ( $o['OrderStatus'] ?? '' ),
+ 'fulfillment_channel' => (string) ( $o['FulfillmentChannel'] ?? '' ),
+ 'sales_channel' => (string) ( $o['SalesChannel'] ?? '' ),
+ 'order_total' => (float) ( $o['OrderTotal']['Amount'] ?? 0 ),
+ 'currency' => (string) ( $o['OrderTotal']['CurrencyCode'] ?? '' ),
+ 'units' => $units,
+ 'is_business' => ! empty( $o['IsBusinessOrder'] ) ? 1 : 0,
+ 'is_prime' => ! empty( $o['IsPrime'] ) ? 1 : 0,
+ 'ship_country' => substr( (string) ( $o['ShippingAddress']['CountryCode'] ?? '' ), 0, 2 ),
+ 'earliest_delivery' => SuiteTime::to_mysql( (string) ( $o['EarliestDeliveryDate'] ?? '' ) ),
+ 'latest_delivery' => SuiteTime::to_mysql( (string) ( $o['LatestDeliveryDate'] ?? '' ) ),
+ 'is_demo' => $demo ? 1 : 0,
+ );
+ $ok = Db::upsert( 'orders', $row, array( 'items_synced' ) );
+ // Status changes (e.g. Pending → Shipped, → Canceled) must cascade to items.
+ Db::exec( 'UPDATE {t:order_items} SET status = %s WHERE amazon_order_id = %s', array( $row['status'], $id ) );
+ return $ok;
+ }
+
+ /** Fetch items for orders that don't have them yet. */
+ public function sync_pending_items( int $limit ): int {
+ $orders = Db::rows(
+ "SELECT amazon_order_id, market, local_date, status FROM {t:orders}
+ WHERE items_synced = 0 AND is_demo = 0 AND status <> 'Canceled'
+ ORDER BY purchase_date DESC LIMIT %d",
+ array( $limit )
+ );
+ $n = 0;
+ foreach ( $orders as $o ) {
+ try {
+ $res = $this->client->request( 'GET', (string) $o['market'], '/orders/v0/orders/' . rawurlencode( (string) $o['amazon_order_id'] ) . '/orderItems', array(), null, 'orders.items' );
+ $items = (array) ( $res['payload']['OrderItems'] ?? array() );
+ if ( $items ) {
+ // Real OrderItemIds supersede report-derived placeholder rows.
+ Db::exec( 'DELETE FROM {t:order_items} WHERE amazon_order_id = %s AND order_item_id LIKE %s', array( $o['amazon_order_id'], 'ff-%' ) );
+ }
+ foreach ( $items as $it ) {
+ $this->store_item( (string) $o['amazon_order_id'], (string) $o['market'], (string) $o['local_date'], (string) $o['status'], $it );
+ $n++;
+ }
+ // Only mark synced once priced (Pending orders return no ItemPrice).
+ $priced = 'Pending' !== $o['status'];
+ Db::exec( 'UPDATE {t:orders} SET items_synced = %d WHERE amazon_order_id = %s', array( $priced ? 1 : 0, $o['amazon_order_id'] ) );
+ } catch ( \Throwable $e ) {
+ $this->logger->warning( 'Suite order items fetch failed', array( 'order' => $o['amazon_order_id'], 'error' => $e->getMessage() ) );
+ break; // Likely throttled — resume next tick.
+ }
+ }
+ return $n;
+ }
+
+ public function store_item( string $order_id, string $market, string $local_date, string $status, array $it, bool $demo = false ): bool {
+ return Db::upsert( 'order_items', array(
+ 'market' => $market,
+ 'amazon_order_id' => $order_id,
+ 'order_item_id' => (string) ( $it['OrderItemId'] ?? md5( $order_id . ( $it['SellerSKU'] ?? '' ) ) ),
+ 'local_date' => $local_date,
+ 'sku' => (string) ( $it['SellerSKU'] ?? '' ),
+ 'asin' => (string) ( $it['ASIN'] ?? '' ),
+ 'title' => mb_substr( (string) ( $it['Title'] ?? '' ), 0, 255 ),
+ 'qty' => (int) ( $it['QuantityOrdered'] ?? 0 ),
+ 'item_price' => (float) ( $it['ItemPrice']['Amount'] ?? 0 ),
+ 'item_tax' => (float) ( $it['ItemTax']['Amount'] ?? 0 ),
+ 'promo_discount' => (float) ( $it['PromotionDiscount']['Amount'] ?? 0 ),
+ 'status' => $status,
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ }
+
+ /**
+ * Ingest GET_FLAT_FILE_ALL_ORDERS_DATA_BY_ORDER_DATE_GENERAL (TSV).
+ * One row per order item; PII columns are ignored.
+ */
+ public function ingest_flat_file( string $raw, string $market ): int {
+ $rows = ReportParser::tsv( $raw );
+ $orders = array();
+ $n = 0;
+ $api_owned = array(); // order id => true when the Orders API already stored real items.
+ foreach ( $rows as $r ) {
+ $id = (string) ( $r['amazon-order-id'] ?? '' );
+ if ( '' === $id ) {
+ continue;
+ }
+ if ( ! isset( $api_owned[ $id ] ) ) {
+ $api_owned[ $id ] = (int) Db::var(
+ 'SELECT COUNT(*) FROM {t:order_items} WHERE amazon_order_id = %s AND order_item_id NOT LIKE %s',
+ array( $id, 'ff-%' )
+ ) > 0;
+ }
+ $sales_channel = (string) ( $r['sales-channel'] ?? '' );
+ if ( '' !== $sales_channel && ! str_starts_with( strtolower( $sales_channel ), 'amazon.' ) ) {
+ continue; // Non-Amazon (MCF) orders are not marketplace sales.
+ }
+ $purchase = SuiteTime::to_mysql( (string) ( $r['purchase-date'] ?? '' ) ) ?? Db::now();
+ $local = SuiteTime::local_date( $purchase, $market );
+ $status = (string) ( $r['order-status'] ?? '' );
+ $price = ReportParser::money( $r['item-price'] ?? 0 );
+ $qty = (int) ( $r['quantity'] ?? 0 );
+ if ( ! isset( $orders[ $id ] ) ) {
+ $orders[ $id ] = array(
+ 'market' => $market,
+ 'amazon_order_id' => $id,
+ 'purchase_date' => $purchase,
+ 'local_date' => $local,
+ 'last_update' => SuiteTime::to_mysql( (string) ( $r['last-updated-date'] ?? '' ) ),
+ 'status' => $status,
+ 'fulfillment_channel' => 'Amazon' === ( $r['fulfillment-channel'] ?? '' ) ? 'AFN' : 'MFN',
+ 'sales_channel' => $sales_channel,
+ 'order_total' => 0.0,
+ 'currency' => (string) ( $r['currency'] ?? '' ),
+ 'units' => 0,
+ 'is_business' => 'true' === strtolower( (string) ( $r['is-business-order'] ?? '' ) ) ? 1 : 0,
+ 'is_prime' => 0,
+ 'ship_country' => substr( (string) ( $r['ship-country'] ?? '' ), 0, 2 ),
+ 'items_synced' => 1,
+ 'is_demo' => 0,
+ );
+ }
+ $orders[ $id ]['order_total'] += $price + ReportParser::money( $r['shipping-price'] ?? 0 );
+ $orders[ $id ]['units'] += $qty;
+ if ( $api_owned[ $id ] ) {
+ $n++;
+ continue;
+ }
+ Db::upsert( 'order_items', array(
+ 'market' => $market,
+ 'amazon_order_id' => $id,
+ 'order_item_id' => 'ff-' . md5( $id . '|' . ( $r['sku'] ?? '' ) . '|' . ( $r['asin'] ?? '' ) ),
+ 'local_date' => $local,
+ 'sku' => (string) ( $r['sku'] ?? '' ),
+ 'asin' => (string) ( $r['asin'] ?? '' ),
+ 'title' => mb_substr( (string) ( $r['product-name'] ?? '' ), 0, 255 ),
+ 'qty' => $qty,
+ 'item_price' => $price,
+ 'item_tax' => ReportParser::money( $r['item-tax'] ?? 0 ),
+ 'promo_discount' => abs( ReportParser::money( $r['item-promotion-discount'] ?? 0 ) ),
+ 'status' => $status,
+ 'is_demo' => 0,
+ ) );
+ $n++;
+ }
+ foreach ( $orders as $row ) {
+ // API-sourced rows (with real OrderItemIds) win; only insert when absent.
+ $exists = (int) Db::var( 'SELECT COUNT(*) FROM {t:orders} WHERE amazon_order_id = %s', array( $row['amazon_order_id'] ) );
+ if ( 0 === $exists ) {
+ Db::upsert( 'orders', $row );
+ } else {
+ Db::exec( 'UPDATE {t:orders} SET status = %s WHERE amazon_order_id = %s', array( $row['status'], $row['amazon_order_id'] ) );
+ }
+ }
+ return $n;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Profit/CostBook.php b/sg-commerce-pro/src/Suite/Profit/CostBook.php
new file mode 100644
index 0000000..78e1136
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Profit/CostBook.php
@@ -0,0 +1,122 @@
+> market => sku => row */
+ private array $cache = array();
+
+ public function __construct( private SuiteSettings $settings ) {}
+
+ public function landed( string $market, string $sku ): float {
+ $row = $this->row( $market, $sku );
+ return null === $row ? $this->settings->float( 'suite_default_unit_cost' ) : (float) $row['landed'];
+ }
+
+ public function has_cost( string $market, string $sku ): bool {
+ $row = $this->row( $market, $sku );
+ return null !== $row && (float) $row['landed'] > 0;
+ }
+
+ /** @return array{landed:float, unit_cost:float, inbound_per_unit:float, prep_per_unit:float, other_per_unit:float, lead_time_days:int, moq:int, case_pack:int, supplier:string}|null */
+ public function row( string $market, string $sku ): ?array {
+ $this->load( $market );
+ return $this->cache[ $market ][ $sku ] ?? null;
+ }
+
+ /** Every SKU known to the suite (products, order items, cost rows) with its cost row. */
+ public function all( string $market ): array {
+ $this->load( $market );
+ global $wpdb;
+ $skus = Db::rows(
+ "SELECT sku, MAX(asin) AS asin, MAX(title) AS title FROM (
+ SELECT sku, asin, product_name AS title FROM {$wpdb->prefix}sg_products WHERE market = %s
+ UNION ALL
+ SELECT sku, asin, title FROM {t:order_items} WHERE market = %s AND sku <> ''
+ ) x GROUP BY sku ORDER BY sku",
+ array( $market, $market )
+ );
+ $out = array();
+ foreach ( $skus as $s ) {
+ $c = $this->cache[ $market ][ $s['sku'] ] ?? null;
+ $out[] = array(
+ 'sku' => (string) $s['sku'],
+ 'asin' => (string) $s['asin'],
+ 'title' => (string) $s['title'],
+ 'unit_cost' => $c['unit_cost'] ?? 0.0,
+ 'inbound_per_unit' => $c['inbound_per_unit'] ?? 0.0,
+ 'prep_per_unit' => $c['prep_per_unit'] ?? 0.0,
+ 'other_per_unit' => $c['other_per_unit'] ?? 0.0,
+ 'landed' => $c['landed'] ?? 0.0,
+ 'lead_time_days' => $c['lead_time_days'] ?? 0,
+ 'moq' => $c['moq'] ?? 0,
+ 'case_pack' => $c['case_pack'] ?? 0,
+ 'supplier' => $c['supplier'] ?? '',
+ 'source' => $c['source'] ?? 'none',
+ );
+ }
+ return $out;
+ }
+
+ public function save( string $market, string $sku, array $in ): bool {
+ $ok = Db::upsert( 'sku_costs', array(
+ 'market' => $market,
+ 'sku' => mb_substr( $sku, 0, 64 ),
+ 'unit_cost' => max( 0.0, (float) ( $in['unit_cost'] ?? 0 ) ),
+ 'inbound_per_unit' => max( 0.0, (float) ( $in['inbound_per_unit'] ?? 0 ) ),
+ 'prep_per_unit' => max( 0.0, (float) ( $in['prep_per_unit'] ?? 0 ) ),
+ 'other_per_unit' => max( 0.0, (float) ( $in['other_per_unit'] ?? 0 ) ),
+ 'lead_time_days' => max( 0, (int) ( $in['lead_time_days'] ?? 0 ) ),
+ 'moq' => max( 0, (int) ( $in['moq'] ?? 0 ) ),
+ 'case_pack' => max( 0, (int) ( $in['case_pack'] ?? 0 ) ),
+ 'supplier' => mb_substr( sanitize_text_field( (string) ( $in['supplier'] ?? '' ) ), 0, 128 ),
+ 'updated_at' => Db::now(),
+ ) );
+ unset( $this->cache[ $market ] );
+ return $ok;
+ }
+
+ private function load( string $market ): void {
+ if ( isset( $this->cache[ $market ] ) ) {
+ return;
+ }
+ global $wpdb;
+ $map = array();
+ // Core product cost_price as a fallback layer.
+ $core = $wpdb->get_results( $wpdb->prepare( "SELECT sku, cost_price FROM {$wpdb->prefix}sg_products WHERE market = %s AND cost_price IS NOT NULL", $market ), ARRAY_A ); // phpcs:ignore
+ foreach ( (array) $core as $r ) {
+ $map[ (string) $r['sku'] ] = array(
+ 'landed' => (float) $r['cost_price'], 'unit_cost' => (float) $r['cost_price'], 'inbound_per_unit' => 0.0,
+ 'prep_per_unit' => 0.0, 'other_per_unit' => 0.0, 'lead_time_days' => 0, 'moq' => 0, 'case_pack' => 0,
+ 'supplier' => '', 'source' => 'product',
+ );
+ }
+ foreach ( Db::rows( 'SELECT * FROM {t:sku_costs} WHERE market = %s', array( $market ) ) as $r ) {
+ $landed = (float) $r['unit_cost'] + (float) $r['inbound_per_unit'] + (float) $r['prep_per_unit'] + (float) $r['other_per_unit'];
+ $map[ (string) $r['sku'] ] = array(
+ 'landed' => $landed, 'unit_cost' => (float) $r['unit_cost'], 'inbound_per_unit' => (float) $r['inbound_per_unit'],
+ 'prep_per_unit' => (float) $r['prep_per_unit'], 'other_per_unit' => (float) $r['other_per_unit'],
+ 'lead_time_days' => (int) $r['lead_time_days'], 'moq' => (int) $r['moq'], 'case_pack' => (int) $r['case_pack'],
+ 'supplier' => (string) $r['supplier'], 'source' => 'suite',
+ );
+ }
+ $this->cache[ $market ] = $map;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Profit/ProfitEngine.php b/sg-commerce-pro/src/Suite/Profit/ProfitEngine.php
new file mode 100644
index 0000000..1ba3818
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Profit/ProfitEngine.php
@@ -0,0 +1,424 @@
+marketplaces->get( $market );
+ $currency = (string) ( $mp['currency'] ?? 'USD' );
+ $days = array();
+ for ( $d = $from; $d <= $to; $d = SuiteTime::shift( $d, 1 ) ) {
+ $days[ $d ] = self::blank_day();
+ if ( count( $days ) > 400 ) {
+ break;
+ }
+ }
+ $skus = array();
+
+ // 1. Sales by order date.
+ $items = Db::rows(
+ "SELECT local_date, sku, MAX(asin) AS asin, MAX(title) AS title, SUM(qty) AS units, SUM(item_price) AS sales,
+ SUM(promo_discount) AS promo, SUM(item_tax) AS tax, COUNT(DISTINCT amazon_order_id) AS orders
+ FROM {t:order_items}
+ WHERE market = %s AND local_date BETWEEN %s AND %s AND status <> 'Canceled'
+ GROUP BY local_date, sku",
+ array( $market, $from, $to )
+ );
+ foreach ( $items as $r ) {
+ $d = (string) $r['local_date'];
+ $sku = (string) $r['sku'];
+ $u = (int) $r['units'];
+ $cogs = $u * $this->costs->landed( $market, $sku );
+ $this->add( $days, $skus, $d, $sku, array(
+ 'units' => $u, 'sales' => (float) $r['sales'], 'promo' => -abs( (float) $r['promo'] ),
+ 'tax_collected' => (float) $r['tax'], 'cogs' => -$cogs,
+ ), $r );
+ }
+ $order_count = Db::rows(
+ "SELECT local_date, COUNT(*) AS n FROM {t:orders} WHERE market = %s AND local_date BETWEEN %s AND %s AND status <> 'Canceled' GROUP BY local_date",
+ array( $market, $from, $to )
+ );
+ foreach ( $order_count as $r ) {
+ if ( isset( $days[ $r['local_date'] ] ) ) {
+ $days[ $r['local_date'] ]['orders'] = (int) $r['n'];
+ }
+ }
+
+ // 2. Settled fees for orders placed in range.
+ $settled = Db::rows(
+ "SELECT o.local_date, f.sku, f.charge_type, SUM(f.amount) AS amt
+ FROM {t:fin_events} f INNER JOIN {t:orders} o ON o.amazon_order_id = f.amazon_order_id
+ WHERE o.market = %s AND o.local_date BETWEEN %s AND %s AND f.event_type = 'Shipment' AND f.category = 'fee'
+ GROUP BY o.local_date, f.sku, f.charge_type",
+ array( $market, $from, $to )
+ );
+ $fee_types = array();
+ foreach ( $settled as $r ) {
+ $this->add( $days, $skus, (string) $r['local_date'], (string) $r['sku'], array( 'amazon_fees' => (float) $r['amt'] ) );
+ $fee_types[ (string) $r['charge_type'] ] = ( $fee_types[ (string) $r['charge_type'] ] ?? 0 ) + (float) $r['amt'];
+ }
+
+ // 3. Estimated fees for unsettled orders.
+ $unit_fee = $this->fee_per_unit( $market, $to );
+ $unsettled = Db::rows(
+ "SELECT oi.local_date, oi.sku, SUM(oi.qty) AS units, SUM(oi.item_price) AS sales
+ FROM {t:order_items} oi
+ LEFT JOIN (SELECT DISTINCT amazon_order_id FROM {t:fin_events} WHERE event_type = 'Shipment') s ON s.amazon_order_id = oi.amazon_order_id
+ WHERE oi.market = %s AND oi.local_date BETWEEN %s AND %s AND oi.status <> 'Canceled' AND s.amazon_order_id IS NULL
+ GROUP BY oi.local_date, oi.sku",
+ array( $market, $from, $to )
+ );
+ $estimated_total = 0.0;
+ foreach ( $unsettled as $r ) {
+ $sku = (string) $r['sku'];
+ if ( isset( $unit_fee['sku'][ $sku ] ) ) {
+ $est = -$unit_fee['sku'][ $sku ] * (int) $r['units'];
+ } else {
+ $est = -$unit_fee['ratio'] * (float) $r['sales'];
+ }
+ $estimated_total += $est;
+ $this->add( $days, $skus, (string) $r['local_date'], $sku, array( 'amazon_fees' => $est, 'fees_estimated' => $est ) );
+ }
+ if ( 0.0 !== $estimated_total ) {
+ $fee_types['Estimated (unsettled orders)'] = $estimated_total;
+ }
+
+ // 4. Posted-date items: refunds, reimbursements, account fees, adjustments, ads invoices.
+ $posted = Db::rows(
+ "SELECT local_date, sku, category, event_type, charge_type, SUM(amount) AS amt, SUM(qty) AS qty
+ FROM {t:fin_events}
+ WHERE market = %s AND local_date BETWEEN %s AND %s AND (event_type <> 'Shipment' OR category NOT IN ('revenue','fee','promo','tax'))
+ GROUP BY local_date, sku, category, event_type, charge_type",
+ array( $market, $from, $to )
+ );
+ $ads_invoice = array();
+ foreach ( $posted as $r ) {
+ $d = (string) $r['local_date'];
+ $sku = (string) $r['sku'];
+ $amt = (float) $r['amt'];
+ switch ( $r['category'] ) {
+ case 'refund':
+ $this->add( $days, $skus, $d, $sku, array( 'refunds' => $amt, 'refund_units' => 'Principal' === $r['charge_type'] ? abs( (int) $r['qty'] ) : 0 ) );
+ break;
+ case 'fee':
+ // Fees on refunds (refund commission, restocking) belong to the SKU; others are account-level.
+ $key = ( 'Refund' === $r['event_type'] && '' !== $sku ) ? 'amazon_fees' : 'account_fees';
+ $this->add( $days, $skus, $d, $key === 'amazon_fees' ? $sku : '', array( $key => $amt ) );
+ $fee_types[ (string) $r['charge_type'] ] = ( $fee_types[ (string) $r['charge_type'] ] ?? 0 ) + $amt;
+ break;
+ case 'reimbursement':
+ $this->add( $days, $skus, $d, $sku, array( 'reimbursements' => $amt ) );
+ break;
+ case 'ads':
+ $ads_invoice[ $d ] = ( $ads_invoice[ $d ] ?? 0 ) + $amt;
+ break;
+ case 'tax':
+ break;
+ case 'promo':
+ $this->add( $days, $skus, $d, '', array( 'promo' => $amt ) );
+ break;
+ default:
+ $this->add( $days, $skus, $d, $sku, array( 'adjustments' => $amt ) );
+ }
+ }
+
+ // 5. Ads: Ads API spend if present, else invoices.
+ $ads = Db::rows(
+ "SELECT report_date, SUM(cost) AS cost, SUM(sales) AS sales FROM {t:ads_daily}
+ WHERE level = 'campaign' AND report_date BETWEEN %s AND %s GROUP BY report_date",
+ array( $from, $to )
+ );
+ $ads_sales = 0.0;
+ if ( $ads ) {
+ foreach ( $ads as $r ) {
+ if ( isset( $days[ $r['report_date'] ] ) ) {
+ $days[ $r['report_date'] ]['ads'] -= (float) $r['cost'];
+ }
+ $ads_sales += (float) $r['sales'];
+ }
+ } else {
+ foreach ( $ads_invoice as $d => $amt ) {
+ if ( isset( $days[ $d ] ) ) {
+ $days[ $d ]['ads'] += $amt;
+ }
+ }
+ }
+
+ // 6. COGS add-back for sellable returns.
+ $returns = Db::rows(
+ "SELECT return_date, sku, SUM(qty) AS qty FROM {t:returns}
+ WHERE market = %s AND return_date BETWEEN %s AND %s AND disposition = 'SELLABLE' GROUP BY return_date, sku",
+ array( $market, $from, $to )
+ );
+ foreach ( $returns as $r ) {
+ $this->add( $days, $skus, (string) $r['return_date'], (string) $r['sku'], array( 'cogs' => (int) $r['qty'] * $this->costs->landed( $market, (string) $r['sku'] ) ) );
+ }
+
+ // 7. Custom expenses.
+ foreach ( $this->expenses( $market, $from, $to ) as $d => $amt ) {
+ if ( isset( $days[ $d ] ) ) {
+ $days[ $d ]['expenses'] -= $amt;
+ }
+ }
+
+ // Roll up.
+ $include_tax = $this->settings->bool( 'suite_include_tax' );
+ $totals = self::blank_day();
+ foreach ( $days as $d => &$row ) {
+ $row['net_profit'] = self::net( $row, $include_tax );
+ foreach ( $row as $k => $v ) {
+ $totals[ $k ] += $v;
+ }
+ }
+ unset( $row );
+ $totals = self::ratios( $totals );
+ $totals['ads_sales'] = $ads_sales;
+ $totals['acos_pct'] = $ads_sales > 0 ? round( -$totals['ads'] / $ads_sales * 100, 2 ) : null;
+ $totals['tacos_pct'] = $totals['sales'] > 0 ? round( -$totals['ads'] / $totals['sales'] * 100, 2 ) : null;
+
+ $out = array(
+ 'market' => $market,
+ 'currency' => $currency,
+ 'from' => $from,
+ 'to' => $to,
+ 'totals' => $totals,
+ );
+ if ( ! $detail ) {
+ return $out;
+ }
+
+ // Allocate ad spend to SKUs by sales share (labelled "allocated").
+ $total_sales = max( 0.0001, array_sum( array_column( $skus, 'sales' ) ) );
+ $sku_rows = array();
+ foreach ( $skus as $sku => $s ) {
+ if ( '' === $sku ) {
+ continue;
+ }
+ $s['ads'] = $totals['ads'] * ( $s['sales'] / $total_sales );
+ $s['net_profit'] = self::net( $s, $include_tax );
+ $s = self::ratios( $s );
+ $s['sku'] = $sku;
+ $s['has_cost'] = $this->costs->has_cost( $market, $sku );
+ $s['unit_cost'] = $this->costs->landed( $market, $sku );
+ $sku_rows[] = $s;
+ }
+ usort( $sku_rows, static fn( $a, $b ) => $b['sales'] <=> $a['sales'] );
+
+ $series = array();
+ foreach ( $days as $d => $row ) {
+ $series[] = array(
+ 'date' => $d,
+ 'sales' => round( $row['sales'], 2 ),
+ 'units' => (int) $row['units'],
+ 'orders' => (int) $row['orders'],
+ 'net_profit' => round( $row['net_profit'], 2 ),
+ 'ads' => round( -$row['ads'], 2 ),
+ 'refunds' => round( -$row['refunds'], 2 ),
+ 'fees' => round( -$row['amazon_fees'], 2 ),
+ );
+ }
+ arsort( $fee_types );
+ $fees = array();
+ foreach ( $fee_types as $type => $amt ) {
+ if ( abs( $amt ) >= 0.005 ) {
+ $fees[] = array( 'type' => $type, 'amount' => round( $amt, 2 ) );
+ }
+ }
+ usort( $fees, static fn( $a, $b ) => $a['amount'] <=> $b['amount'] );
+
+ $out['series'] = $series;
+ $out['skus'] = $sku_rows;
+ $out['fee_breakdown'] = $fees;
+ $out['missing_costs'] = array_values( array_map( static fn( $r ) => $r['sku'], array_filter( $sku_rows, static fn( $r ) => ! $r['has_cost'] && $r['units'] > 0 ) ) );
+ $out['waterfall'] = array(
+ array( 'label' => 'Sales', 'value' => $totals['sales'] ),
+ array( 'label' => 'Promotions', 'value' => $totals['promo'] ),
+ array( 'label' => 'Refunds', 'value' => $totals['refunds'] ),
+ array( 'label' => 'Amazon fees', 'value' => $totals['amazon_fees'] ),
+ array( 'label' => 'Account fees', 'value' => $totals['account_fees'] ),
+ array( 'label' => 'Advertising', 'value' => $totals['ads'] ),
+ array( 'label' => 'COGS', 'value' => $totals['cogs'] ),
+ array( 'label' => 'Reimbursements', 'value' => $totals['reimbursements'] ),
+ array( 'label' => 'Adjustments', 'value' => $totals['adjustments'] ),
+ array( 'label' => 'Expenses', 'value' => $totals['expenses'] ),
+ );
+ return $out;
+ }
+
+ /** Period-over-period comparison plus month-end forecast. */
+ public function dashboard( string $market ): array {
+ $today = SuiteTime::today( $market );
+ $ranges = array(
+ 'today' => array( $today, $today ),
+ 'yesterday' => array( SuiteTime::shift( $today, -1 ), SuiteTime::shift( $today, -1 ) ),
+ 'last_7' => array( SuiteTime::shift( $today, -6 ), $today ),
+ 'mtd' => array( substr( $today, 0, 8 ) . '01', $today ),
+ 'last_month' => array(
+ ( new \DateTimeImmutable( $today ) )->modify( 'first day of last month' )->format( 'Y-m-d' ),
+ ( new \DateTimeImmutable( $today ) )->modify( 'last day of last month' )->format( 'Y-m-d' ),
+ ),
+ );
+ $out = array();
+ foreach ( $ranges as $key => [ $a, $b ] ) {
+ $out[ $key ] = $this->pnl( $market, $a, $b, false )['totals'] + array( 'from' => $a, 'to' => $b );
+ }
+ // Forecast: month-to-date actuals (excluding partial today) + daily run-rate × remaining days.
+ // Early in the month the run-rate comes from the trailing 7 full days instead.
+ $day_of_month = (int) substr( $today, 8, 2 );
+ $days_in_month = (int) ( new \DateTimeImmutable( $today ) )->format( 't' );
+ $elapsed = $day_of_month - 1;
+ $mtd_ex_today = $elapsed > 0
+ ? $this->pnl( $market, substr( $today, 0, 8 ) . '01', SuiteTime::shift( $today, -1 ), false )['totals']
+ : self::blank_day();
+ if ( $elapsed >= 7 ) {
+ $rate = $mtd_ex_today;
+ $rate_days = $elapsed;
+ } else {
+ $rate = $this->pnl( $market, SuiteTime::shift( $today, -7 ), SuiteTime::shift( $today, -1 ), false )['totals'];
+ $rate_days = 7;
+ }
+ $remaining = $days_in_month - $elapsed;
+ $out['forecast'] = array(
+ 'sales' => round( (float) $mtd_ex_today['sales'] + (float) $rate['sales'] / $rate_days * $remaining, 2 ),
+ 'net_profit' => round( (float) $mtd_ex_today['net_profit'] + (float) $rate['net_profit'] / $rate_days * $remaining, 2 ),
+ 'units' => (int) round( (float) $mtd_ex_today['units'] + (float) $rate['units'] / $rate_days * $remaining ),
+ 'basis' => $elapsed >= 7 ? 'month-to-date run-rate' : 'trailing 7-day run-rate',
+ );
+ return $out;
+ }
+
+ /**
+ * Trailing-90-day settled fee per unit per SKU, plus a global fee/sales
+ * ratio fallback (defaults to 30 % — referral + FBA for a typical item —
+ * when the account has no settlement history yet).
+ */
+ public function fee_per_unit( string $market, string $to ): array {
+ $from = SuiteTime::shift( $to, -90 );
+ $rows = Db::rows(
+ "SELECT sku,
+ SUM(CASE WHEN category = 'fee' THEN amount ELSE 0 END) AS fees,
+ SUM(CASE WHEN category = 'revenue' AND charge_type = 'Principal' THEN qty ELSE 0 END) AS units,
+ SUM(CASE WHEN category = 'revenue' AND charge_type = 'Principal' THEN amount ELSE 0 END) AS sales
+ FROM {t:fin_events}
+ WHERE market = %s AND event_type = 'Shipment' AND local_date BETWEEN %s AND %s AND sku <> ''
+ GROUP BY sku",
+ array( $market, $from, $to )
+ );
+ $sku = array();
+ $tf = 0.0;
+ $ts = 0.0;
+ foreach ( $rows as $r ) {
+ if ( (int) $r['units'] > 0 ) {
+ $sku[ (string) $r['sku'] ] = abs( (float) $r['fees'] ) / (int) $r['units'];
+ }
+ $tf += abs( (float) $r['fees'] );
+ $ts += (float) $r['sales'];
+ }
+ return array( 'sku' => $sku, 'ratio' => $ts > 0 ? $tf / $ts : 0.30 );
+ }
+
+ /** @return array date => expense amount */
+ private function expenses( string $market, string $from, string $to ): array {
+ $rows = Db::rows(
+ "SELECT * FROM {t:expenses} WHERE (market = '' OR market = %s) AND start_date <= %s AND (end_date IS NULL OR end_date >= %s)",
+ array( $market, $to, $from )
+ );
+ $out = array();
+ foreach ( $rows as $e ) {
+ $amt = (float) $e['amount'];
+ if ( 'monthly' === $e['recurrence'] ) {
+ $per_day = $amt * 12 / 365.25;
+ $start = max( $from, (string) $e['start_date'] );
+ $end = min( $to, (string) ( $e['end_date'] ?: $to ) );
+ for ( $d = $start; $d <= $end; $d = SuiteTime::shift( $d, 1 ) ) {
+ $out[ $d ] = ( $out[ $d ] ?? 0 ) + $per_day;
+ }
+ } elseif ( $e['start_date'] >= $from && $e['start_date'] <= $to ) {
+ $out[ (string) $e['start_date'] ] = ( $out[ (string) $e['start_date'] ] ?? 0 ) + $amt;
+ }
+ }
+ return $out;
+ }
+
+ private function add( array &$days, array &$skus, string $d, string $sku, array $vals, ?array $meta = null ): void {
+ if ( ! isset( $days[ $d ] ) ) {
+ return;
+ }
+ if ( ! isset( $skus[ $sku ] ) ) {
+ $skus[ $sku ] = self::blank_day() + array( 'asin' => '', 'title' => '' );
+ }
+ if ( $meta ) {
+ $skus[ $sku ]['asin'] = $skus[ $sku ]['asin'] ?: (string) ( $meta['asin'] ?? '' );
+ $skus[ $sku ]['title'] = $skus[ $sku ]['title'] ?: (string) ( $meta['title'] ?? '' );
+ }
+ foreach ( $vals as $k => $v ) {
+ $days[ $d ][ $k ] += $v;
+ $skus[ $sku ][ $k ] += $v;
+ }
+ if ( isset( $vals['units'] ) && isset( $meta['orders'] ) ) {
+ $skus[ $sku ]['orders'] += (int) $meta['orders'];
+ }
+ }
+
+ private static function blank_day(): array {
+ return array(
+ 'orders' => 0, 'units' => 0, 'sales' => 0.0, 'promo' => 0.0, 'refunds' => 0.0, 'refund_units' => 0,
+ 'amazon_fees' => 0.0, 'fees_estimated' => 0.0, 'account_fees' => 0.0, 'ads' => 0.0, 'cogs' => 0.0,
+ 'reimbursements' => 0.0, 'adjustments' => 0.0, 'expenses' => 0.0, 'tax_collected' => 0.0, 'net_profit' => 0.0,
+ );
+ }
+
+ private static function net( array $r, bool $include_tax ): float {
+ return $r['sales'] + $r['promo'] + $r['refunds'] + $r['amazon_fees'] + $r['account_fees'] + $r['ads']
+ + $r['cogs'] + $r['reimbursements'] + $r['adjustments'] + $r['expenses']
+ + ( $include_tax ? $r['tax_collected'] : 0.0 );
+ }
+
+ private static function ratios( array $t ): array {
+ foreach ( $t as $k => $v ) {
+ if ( is_float( $v ) ) {
+ $t[ $k ] = round( $v, 2 );
+ }
+ }
+ $t['margin_pct'] = $t['sales'] > 0 ? round( $t['net_profit'] / $t['sales'] * 100, 2 ) : null;
+ $t['roi_pct'] = $t['cogs'] < 0 ? round( $t['net_profit'] / -$t['cogs'] * 100, 2 ) : null;
+ $t['avg_order_value'] = $t['orders'] > 0 ? round( $t['sales'] / $t['orders'], 2 ) : null;
+ $t['refund_rate_pct'] = $t['units'] > 0 ? round( $t['refund_units'] / $t['units'] * 100, 2 ) : null;
+ return $t;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Reimbursements/ReimbursementAuditor.php b/sg-commerce-pro/src/Suite/Reimbursements/ReimbursementAuditor.php
new file mode 100644
index 0000000..81e1fb3
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Reimbursements/ReimbursementAuditor.php
@@ -0,0 +1,299 @@
+ 45 days ago, no return received, no
+ * reimbursement on the order — Amazon must reimburse or
+ * charge the buyer back.
+ * 4. fee_overcharge FBA fulfilment fee per unit jumped > 10 % vs the SKU's
+ * prior baseline with no listing change — typical of a
+ * wrong dimension re-measure; request a re-measurement.
+ *
+ * Each finding becomes a case with a ready-to-paste Seller Central message.
+ * Findings are de-duplicated by a stable hash so re-scans never duplicate.
+ *
+ * @package SevenGum\Commerce\Suite\Reimbursements
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Suite\Reimbursements;
+
+use SevenGum\Commerce\Suite\Support\Db;
+
+defined( 'ABSPATH' ) || exit;
+
+final class ReimbursementAuditor {
+
+ public const LOST_REASONS = array( 'M' );
+ public const FOUND_REASONS = array( 'F' );
+ public const DAMAGED_REASONS = array( 'E', 'Q', '6', '7' );
+
+ /** @return array{found:int, new:int, value:float} */
+ public function scan( ?int $now = null ): array {
+ $now = $now ?? time();
+ $stats = array( 'found' => 0, 'new' => 0, 'value' => 0.0 );
+ $cases = array_merge(
+ $this->lost_and_damaged( $now ),
+ $this->refunds_without_return( $now ),
+ $this->fee_overcharges( $now )
+ );
+ foreach ( $cases as $c ) {
+ $stats['found']++;
+ $stats['value'] += $c['est_amount'];
+ $exists = Db::row( 'SELECT id, status FROM {t:reimb_cases} WHERE case_hash = %s', array( $c['case_hash'] ) );
+ if ( null === $exists ) {
+ Db::insert( 'reimb_cases', $c + array( 'status' => 'open', 'created_at' => Db::now(), 'updated_at' => Db::now() ) );
+ $stats['new']++;
+ } elseif ( 'open' === $exists['status'] ) {
+ Db::update( 'reimb_cases', array(
+ 'qty' => $c['qty'], 'est_amount' => $c['est_amount'], 'evidence' => $c['evidence'], 'updated_at' => Db::now(),
+ ), array( 'id' => (int) $exists['id'] ) );
+ }
+ }
+ // Auto-close open cases Amazon has since reimbursed on its own.
+ $this->auto_resolve();
+ $stats['value'] = round( $stats['value'], 2 );
+ return $stats;
+ }
+
+ public function lost_and_damaged( int $now ): array {
+ $cutoff = gmdate( 'Y-m-d', $now - 30 * DAY_IN_SECONDS );
+ $oldest = gmdate( 'Y-m-d', $now - 540 * DAY_IN_SECONDS ); // 18-month claim window.
+ $rows = Db::rows(
+ "SELECT market, fnsku, MAX(sku) AS sku, MAX(asin) AS asin, reason, SUM(qty) AS qty, MIN(event_date) AS first_date, MAX(event_date) AS last_date,
+ COUNT(*) AS events, MAX(fulfillment_center) AS fc
+ FROM {t:ledger}
+ WHERE event_type = 'Adjustments' AND event_date BETWEEN %s AND %s
+ GROUP BY market, fnsku, reason",
+ array( $oldest, $cutoff )
+ );
+ $by = array();
+ foreach ( $rows as $r ) {
+ $k = $r['market'] . '|' . $r['fnsku'];
+ $by[ $k ] ??= array( 'market' => $r['market'], 'fnsku' => $r['fnsku'], 'sku' => $r['sku'], 'asin' => $r['asin'], 'lost' => 0, 'found' => 0, 'damaged' => 0, 'first' => $r['first_date'], 'last' => $r['last_date'], 'fc' => $r['fc'] );
+ $q = (int) $r['qty'];
+ if ( in_array( $r['reason'], self::LOST_REASONS, true ) ) {
+ $by[ $k ]['lost'] += -$q;
+ } elseif ( in_array( $r['reason'], self::FOUND_REASONS, true ) ) {
+ $by[ $k ]['found'] += $q;
+ } elseif ( in_array( $r['reason'], self::DAMAGED_REASONS, true ) ) {
+ $by[ $k ]['damaged'] += -$q;
+ }
+ $by[ $k ]['first'] = min( $by[ $k ]['first'], $r['first_date'] );
+ $by[ $k ]['last'] = max( $by[ $k ]['last'], $r['last_date'] );
+ }
+
+ $out = array();
+ foreach ( $by as $g ) {
+ $reimbursed = Db::rows(
+ 'SELECT reason, SUM(qty_cash + qty_inventory) AS q FROM {t:reimbursements} WHERE fnsku = %s GROUP BY reason',
+ array( $g['fnsku'] )
+ );
+ $r_lost = 0;
+ $r_dmg = 0;
+ foreach ( $reimbursed as $r ) {
+ $reason = strtolower( (string) $r['reason'] );
+ if ( str_contains( $reason, 'lost' ) ) {
+ $r_lost += (int) $r['q'];
+ } elseif ( str_contains( $reason, 'damage' ) ) {
+ $r_dmg += (int) $r['q'];
+ }
+ }
+ $unit = $this->unit_value( (string) $g['sku'] );
+ $lost_open = $g['lost'] - $g['found'] - $r_lost;
+ if ( $lost_open > 0 ) {
+ $out[] = $this->make( 'lost_warehouse', $g, $lost_open, $unit, array(
+ 'misplaced_units' => $g['lost'], 'found_units' => $g['found'], 'reimbursed_units' => $r_lost,
+ 'first_event' => $g['first'], 'last_event' => $g['last'], 'fulfillment_center' => $g['fc'],
+ ) );
+ }
+ $dmg_open = $g['damaged'] - $r_dmg;
+ if ( $dmg_open > 0 ) {
+ $out[] = $this->make( 'damaged_warehouse', $g, $dmg_open, $unit, array(
+ 'damaged_units' => $g['damaged'], 'reimbursed_units' => $r_dmg,
+ 'first_event' => $g['first'], 'last_event' => $g['last'], 'fulfillment_center' => $g['fc'],
+ ) );
+ }
+ }
+ return $out;
+ }
+
+ public function refunds_without_return( int $now ): array {
+ $before = gmdate( 'Y-m-d', $now - 45 * DAY_IN_SECONDS );
+ $after = gmdate( 'Y-m-d', $now - 540 * DAY_IN_SECONDS );
+ $rows = Db::rows(
+ "SELECT f.market, f.amazon_order_id, MAX(f.sku) AS sku, SUM(f.amount) AS refunded, SUM(f.qty) AS qty, MIN(f.local_date) AS refund_date, MAX(f.currency) AS currency
+ FROM {t:fin_events} f
+ WHERE f.event_type = 'Refund' AND f.category = 'refund' AND f.charge_type = 'Principal' AND f.local_date BETWEEN %s AND %s
+ AND NOT EXISTS (SELECT 1 FROM {t:returns} r WHERE r.amazon_order_id = f.amazon_order_id)
+ AND NOT EXISTS (SELECT 1 FROM {t:reimbursements} b WHERE b.amazon_order_id = f.amazon_order_id)
+ AND EXISTS (SELECT 1 FROM {t:orders} o WHERE o.amazon_order_id = f.amazon_order_id AND o.fulfillment_channel = 'AFN')
+ GROUP BY f.market, f.amazon_order_id",
+ array( $after, $before )
+ );
+ $out = array();
+ foreach ( $rows as $r ) {
+ $amt = abs( (float) $r['refunded'] );
+ if ( $amt <= 0 ) {
+ continue;
+ }
+ $qty = max( 1, abs( (int) $r['qty'] ) );
+ $evidence = array( 'refund_date' => $r['refund_date'], 'refunded_amount' => round( $amt, 2 ), 'units' => $qty );
+ $out[] = array(
+ 'case_hash' => sha1( 'refund_no_return|' . $r['amazon_order_id'] ),
+ 'kind' => 'refund_no_return',
+ 'market' => (string) $r['market'],
+ 'sku' => (string) $r['sku'],
+ 'fnsku' => '',
+ 'asin' => '',
+ 'reference_id' => (string) $r['amazon_order_id'],
+ 'event_date' => (string) $r['refund_date'],
+ 'qty' => $qty,
+ 'est_amount' => round( $amt, 2 ),
+ 'currency' => (string) $r['currency'],
+ 'evidence' => wp_json_encode( $evidence ),
+ 'notes' => self::message( 'refund_no_return', (string) $r['sku'], '', (string) $r['amazon_order_id'], $qty, $evidence ),
+ );
+ }
+ return $out;
+ }
+
+ public function fee_overcharges( int $now ): array {
+ $recent_from = gmdate( 'Y-m-d', $now - 30 * DAY_IN_SECONDS );
+ $base_from = gmdate( 'Y-m-d', $now - 120 * DAY_IN_SECONDS );
+ $rows = Db::rows(
+ "SELECT f.market, f.sku, CASE WHEN f.local_date >= %s THEN 'recent' ELSE 'base' END AS period,
+ SUM(f.amount) AS fees, SUM(p.qty) AS units, MAX(f.currency) AS currency
+ FROM {t:fin_events} f
+ INNER JOIN (SELECT amazon_order_id, sku, SUM(qty) AS qty FROM {t:fin_events}
+ WHERE event_type = 'Shipment' AND charge_type = 'Principal' GROUP BY amazon_order_id, sku) p
+ ON p.amazon_order_id = f.amazon_order_id AND p.sku = f.sku
+ WHERE f.event_type = 'Shipment' AND f.charge_type = 'FBAPerUnitFulfillmentFee' AND f.local_date >= %s
+ GROUP BY f.market, f.sku, period",
+ array( $recent_from, $base_from )
+ );
+ $by = array();
+ foreach ( $rows as $r ) {
+ $by[ $r['market'] . '|' . $r['sku'] ][ $r['period'] ] = $r;
+ }
+ $out = array();
+ foreach ( $by as $key => $p ) {
+ if ( empty( $p['recent'] ) || empty( $p['base'] ) || (int) $p['recent']['units'] < 5 || (int) $p['base']['units'] < 5 ) {
+ continue;
+ }
+ $old = abs( (float) $p['base']['fees'] ) / (int) $p['base']['units'];
+ $new = abs( (float) $p['recent']['fees'] ) / (int) $p['recent']['units'];
+ if ( $old <= 0 || ( $new - $old ) / $old < 0.10 ) {
+ continue;
+ }
+ [ $market, $sku ] = explode( '|', $key, 2 );
+ $units = (int) $p['recent']['units'];
+ $evidence = array( 'baseline_fee_per_unit' => round( $old, 2 ), 'current_fee_per_unit' => round( $new, 2 ), 'units_affected' => $units );
+ $out[] = array(
+ 'case_hash' => sha1( 'fee_overcharge|' . $key . '|' . round( $new, 2 ) ),
+ 'kind' => 'fee_overcharge',
+ 'market' => $market,
+ 'sku' => $sku,
+ 'fnsku' => '',
+ 'asin' => '',
+ 'reference_id' => '',
+ 'event_date' => gmdate( 'Y-m-d', $now ),
+ 'qty' => $units,
+ 'est_amount' => round( ( $new - $old ) * $units, 2 ),
+ 'currency' => (string) $p['recent']['currency'],
+ 'evidence' => wp_json_encode( $evidence ),
+ 'notes' => self::message( 'fee_overcharge', $sku, '', '', $units, $evidence ),
+ );
+ }
+ return $out;
+ }
+
+ public function update_case( int $id, array $in ): bool {
+ $data = array( 'updated_at' => Db::now() );
+ if ( isset( $in['status'] ) && in_array( $in['status'], array( 'open', 'filed', 'reimbursed', 'dismissed' ), true ) ) {
+ $data['status'] = $in['status'];
+ }
+ if ( isset( $in['amazon_case_id'] ) ) {
+ $data['amazon_case_id'] = mb_substr( sanitize_text_field( (string) $in['amazon_case_id'] ), 0, 32 );
+ }
+ return Db::update( 'reimb_cases', $data, array( 'id' => $id ) ) > 0;
+ }
+
+ public function summary(): array {
+ $by = Db::rows(
+ "SELECT kind, status, COUNT(*) AS n, SUM(est_amount) AS value FROM {t:reimb_cases} GROUP BY kind, status"
+ );
+ $recovered = (float) Db::var( 'SELECT COALESCE(SUM(amount),0) FROM {t:reimbursements} WHERE approval_date >= %s', array( gmdate( 'Y-m-d', time() - 365 * DAY_IN_SECONDS ) ) );
+ return array( 'by' => $by, 'reimbursed_12m' => round( $recovered, 2 ) );
+ }
+
+ private function auto_resolve(): void {
+ Db::exec(
+ "UPDATE {t:reimb_cases} SET status = 'reimbursed', updated_at = %s
+ WHERE status IN ('open','filed') AND kind = 'refund_no_return'
+ AND reference_id IN (SELECT amazon_order_id FROM {t:reimbursements} WHERE amazon_order_id <> '')",
+ array( Db::now() )
+ );
+ }
+
+ /** Average realised sale price per unit over 180 days (Amazon reimburses at estimated sale value). */
+ private function unit_value( string $sku ): float {
+ $v = Db::row(
+ 'SELECT SUM(item_price) AS s, SUM(qty) AS q FROM {t:order_items} WHERE sku = %s AND local_date >= %s AND qty > 0',
+ array( $sku, gmdate( 'Y-m-d', time() - 180 * DAY_IN_SECONDS ) )
+ );
+ return null !== $v && (int) $v['q'] > 0 ? (float) $v['s'] / (int) $v['q'] : 0.0;
+ }
+
+ private function make( string $kind, array $g, int $qty, float $unit, array $evidence ): array {
+ $currency = (string) Db::var( 'SELECT currency FROM {t:orders} WHERE market = %s AND currency <> %s LIMIT 1', array( $g['market'], '' ) );
+ return array(
+ 'case_hash' => sha1( $kind . '|' . $g['market'] . '|' . $g['fnsku'] . '|' . $g['first'] ),
+ 'kind' => $kind,
+ 'market' => (string) $g['market'],
+ 'sku' => (string) $g['sku'],
+ 'fnsku' => (string) $g['fnsku'],
+ 'asin' => (string) $g['asin'],
+ 'reference_id' => '',
+ 'event_date' => (string) $g['last'],
+ 'qty' => $qty,
+ // Reimbursement ≈ sale price minus referral & FBA fees; 0.7 is a conservative haircut.
+ 'est_amount' => round( $qty * $unit * 0.7, 2 ),
+ 'currency' => $currency,
+ 'evidence' => wp_json_encode( $evidence ),
+ 'notes' => self::message( $kind, (string) $g['sku'], (string) $g['fnsku'], '', $qty, $evidence ),
+ );
+ }
+
+ /** Ready-to-paste Seller Central case text. */
+ public static function message( string $kind, string $sku, string $fnsku, string $order, int $qty, array $ev ): string {
+ return match ( $kind ) {
+ 'lost_warehouse' => sprintf(
+ "Hello Seller Support,\n\nOur inventory ledger shows %d unit(s) of FNSKU %s (SKU %s) adjusted as misplaced (reason code M) between %s and %s at %s. Only %d unit(s) were subsequently found and %d were reimbursed, leaving %d unit(s) unaccounted for beyond the 30-day reconciliation period.\n\nPer the FBA Inventory Reimbursement Policy, please investigate and reimburse the %d missing unit(s).\n\nThank you.",
+ (int) $ev['misplaced_units'], $fnsku, $sku, $ev['first_event'], $ev['last_event'], $ev['fulfillment_center'] ?: 'your fulfillment centers',
+ (int) $ev['found_units'], (int) $ev['reimbursed_units'], $qty, $qty
+ ),
+ 'damaged_warehouse' => sprintf(
+ "Hello Seller Support,\n\nOur inventory ledger shows %d unit(s) of FNSKU %s (SKU %s) adjusted as damaged in an Amazon fulfillment center between %s and %s. %d unit(s) have been reimbursed so far, leaving %d unit(s) unreimbursed.\n\nPer the FBA Inventory Reimbursement Policy for warehouse damage, please reimburse the remaining %d unit(s).\n\nThank you.",
+ (int) $ev['damaged_units'], $fnsku, $sku, $ev['first_event'], $ev['last_event'], (int) $ev['reimbursed_units'], $qty, $qty
+ ),
+ 'refund_no_return' => sprintf(
+ "Hello Seller Support,\n\nOrder %s (SKU %s) was refunded to the customer on %s for %s, but more than 45 days later no return has been received at a fulfillment center and no reimbursement has been issued.\n\nPer the FBA customer returns policy, please either charge the customer for the unreturned item or reimburse us for %d unit(s).\n\nThank you.",
+ $order, $sku, $ev['refund_date'], number_format( (float) $ev['refunded_amount'], 2 ), $qty
+ ),
+ 'fee_overcharge' => sprintf(
+ "Hello Seller Support,\n\nThe FBA fulfillment fee for SKU %s increased from %s to %s per unit over the last 30 days (%d units affected) without any change to the product or its packaging. This suggests an incorrect dimension/weight measurement.\n\nPlease re-measure the item and refund the fee difference for all affected units.\n\nThank you.",
+ $sku, number_format( (float) $ev['baseline_fee_per_unit'], 2 ), number_format( (float) $ev['current_fee_per_unit'], 2 ), $qty
+ ),
+ default => '',
+ };
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Reports/ReportIngestor.php b/sg-commerce-pro/src/Suite/Reports/ReportIngestor.php
new file mode 100644
index 0000000..466486a
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Reports/ReportIngestor.php
@@ -0,0 +1,293 @@
+ $this->orders->ingest_flat_file( $raw, $market ),
+ 'GET_SALES_AND_TRAFFIC_REPORT' => $this->traffic( $raw, $market, $meta, $demo ),
+ 'GET_LEDGER_DETAIL_VIEW_DATA' => $this->ledger( $raw, $market, $demo ),
+ 'GET_FBA_REIMBURSEMENTS_DATA' => $this->reimbursements( $raw, $market, $demo ),
+ 'GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA' => $this->returns( $raw, $market, $demo ),
+ 'GET_FBA_INVENTORY_PLANNING_DATA' => $this->inventory_health( $raw, $market, $demo ),
+ 'GET_SELLER_FEEDBACK_DATA' => $this->feedback( $raw, $market, $demo ),
+ 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' => $this->sqp( $raw, $market, $demo ),
+ default => count( ReportParser::tsv( $raw ) ),
+ };
+ }
+
+ public function traffic( string $raw, string $market, array $meta, bool $demo = false ): int {
+ $doc = ReportParser::json( $raw );
+ $n = 0;
+ foreach ( (array) ( $doc['salesAndTrafficByDate'] ?? array() ) as $d ) {
+ $date = substr( (string) ( $d['date'] ?? '' ), 0, 10 );
+ if ( '' === $date ) {
+ continue;
+ }
+ Db::upsert( 'traffic_daily', array(
+ 'market' => $market,
+ 'report_date' => $date,
+ 'asin' => '',
+ 'sku' => '',
+ 'sessions' => (int) ( $d['trafficByDate']['sessions'] ?? 0 ),
+ 'page_views' => (int) ( $d['trafficByDate']['pageViews'] ?? 0 ),
+ 'buy_box_pct' => (float) ( $d['trafficByDate']['buyBoxPercentage'] ?? 0 ),
+ 'units' => (int) ( $d['salesByDate']['unitsOrdered'] ?? 0 ),
+ 'order_items' => (int) ( $d['salesByDate']['totalOrderItems'] ?? 0 ),
+ 'ordered_sales' => (float) ( $d['salesByDate']['orderedProductSales']['amount'] ?? 0 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ // By-ASIN rows are aggregated over the whole report window; we request one day per report.
+ $day = substr( (string) ( $meta['data_start'] ?? ( $doc['reportSpecification']['dataStartTime'] ?? '' ) ), 0, 10 );
+ if ( '' !== $day ) {
+ foreach ( (array) ( $doc['salesAndTrafficByAsin'] ?? array() ) as $a ) {
+ $asin = (string) ( $a['childAsin'] ?? $a['parentAsin'] ?? '' );
+ if ( '' === $asin ) {
+ continue;
+ }
+ Db::upsert( 'traffic_daily', array(
+ 'market' => $market,
+ 'report_date' => $day,
+ 'asin' => $asin,
+ 'sku' => (string) ( $a['sku'] ?? '' ),
+ 'sessions' => (int) ( $a['trafficByAsin']['sessions'] ?? 0 ),
+ 'page_views' => (int) ( $a['trafficByAsin']['pageViews'] ?? 0 ),
+ 'buy_box_pct' => (float) ( $a['trafficByAsin']['buyBoxPercentage'] ?? 0 ),
+ 'units' => (int) ( $a['salesByAsin']['unitsOrdered'] ?? 0 ),
+ 'order_items' => (int) ( $a['salesByAsin']['totalOrderItems'] ?? 0 ),
+ 'ordered_sales' => (float) ( $a['salesByAsin']['orderedProductSales']['amount'] ?? 0 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ }
+ return $n;
+ }
+
+ public function ledger( string $raw, string $market, bool $demo = false ): int {
+ $n = 0;
+ foreach ( ReportParser::tsv( $raw ) as $r ) {
+ $date = self::date( $r['date'] ?? '' );
+ if ( null === $date ) {
+ continue;
+ }
+ Db::upsert( 'ledger', array(
+ 'row_hash' => sha1( wp_json_encode( array( $market, $r['date'] ?? '', $r['fnsku'] ?? '', $r['event-type'] ?? '', $r['reference-id'] ?? '', $r['quantity'] ?? '', $r['fulfillment-center'] ?? '', $r['disposition'] ?? '', $r['reason'] ?? '' ) ) ),
+ 'market' => $market,
+ 'event_date' => $date,
+ 'fnsku' => (string) ( $r['fnsku'] ?? '' ),
+ 'sku' => (string) ( $r['msku'] ?? $r['sku'] ?? '' ),
+ 'asin' => (string) ( $r['asin'] ?? '' ),
+ 'event_type' => (string) ( $r['event-type'] ?? '' ),
+ 'reference_id' => (string) ( $r['reference-id'] ?? '' ),
+ 'qty' => (int) ( $r['quantity'] ?? 0 ),
+ 'fulfillment_center' => (string) ( $r['fulfillment-center'] ?? '' ),
+ 'disposition' => (string) ( $r['disposition'] ?? '' ),
+ 'reason' => (string) ( $r['reason'] ?? '' ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ return $n;
+ }
+
+ public function reimbursements( string $raw, string $market, bool $demo = false ): int {
+ $n = 0;
+ foreach ( ReportParser::tsv( $raw ) as $r ) {
+ $id = (string) ( $r['reimbursement-id'] ?? '' );
+ if ( '' === $id ) {
+ continue;
+ }
+ Db::upsert( 'reimbursements', array(
+ 'reimbursement_id' => $id,
+ 'market' => $market,
+ 'approval_date' => self::date( $r['approval-date'] ?? '' ) ?? gmdate( 'Y-m-d' ),
+ 'case_id' => (string) ( $r['case-id'] ?? '' ),
+ 'amazon_order_id' => (string) ( $r['amazon-order-id'] ?? '' ),
+ 'reason' => (string) ( $r['reason'] ?? '' ),
+ 'sku' => (string) ( $r['sku'] ?? '' ),
+ 'fnsku' => (string) ( $r['fnsku'] ?? '' ),
+ 'asin' => (string) ( $r['asin'] ?? '' ),
+ 'amount' => ReportParser::money( $r['amount-total'] ?? 0 ),
+ 'currency' => (string) ( $r['currency-unit'] ?? '' ),
+ 'qty_cash' => (int) ( $r['quantity-reimbursed-cash'] ?? 0 ),
+ 'qty_inventory' => (int) ( $r['quantity-reimbursed-inventory'] ?? 0 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ return $n;
+ }
+
+ public function returns( string $raw, string $market, bool $demo = false ): int {
+ $n = 0;
+ foreach ( ReportParser::tsv( $raw ) as $r ) {
+ $date = self::date( $r['return-date'] ?? '' );
+ if ( null === $date ) {
+ continue;
+ }
+ Db::upsert( 'returns', array(
+ 'row_hash' => sha1( wp_json_encode( array( $market, $r['order-id'] ?? '', $r['fnsku'] ?? '', $r['license-plate-number'] ?? '', $r['return-date'] ?? '' ) ) ),
+ 'market' => $market,
+ 'return_date' => $date,
+ 'amazon_order_id' => (string) ( $r['order-id'] ?? '' ),
+ 'sku' => (string) ( $r['sku'] ?? '' ),
+ 'asin' => (string) ( $r['asin'] ?? '' ),
+ 'fnsku' => (string) ( $r['fnsku'] ?? '' ),
+ 'qty' => (int) ( $r['quantity'] ?? 0 ),
+ 'fulfillment_center' => (string) ( $r['fulfillment-center-id'] ?? '' ),
+ 'disposition' => (string) ( $r['detailed-disposition'] ?? '' ),
+ 'reason' => (string) ( $r['reason'] ?? '' ),
+ 'status' => (string) ( $r['status'] ?? '' ),
+ 'comments' => mb_substr( (string) ( $r['customer-comments'] ?? '' ), 0, 500 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ return $n;
+ }
+
+ public function inventory_health( string $raw, string $market, bool $demo = false ): int {
+ $n = 0;
+ foreach ( ReportParser::tsv( $raw ) as $r ) {
+ $sku = (string) ( $r['sku'] ?? '' );
+ if ( '' === $sku ) {
+ continue;
+ }
+ $over_365 = 0;
+ foreach ( $r as $k => $v ) {
+ if ( 1 === preg_match( '/^inv-age-(365-plus|366|4\d\d|5\d\d|6\d\d|7\d\d)/', $k ) ) {
+ $over_365 += (int) $v;
+ }
+ }
+ Db::upsert( 'inventory_health', array(
+ 'market' => $market,
+ 'snapshot_date' => self::date( $r['snapshot-date'] ?? '' ) ?? gmdate( 'Y-m-d' ),
+ 'sku' => $sku,
+ 'asin' => (string) ( $r['asin'] ?? '' ),
+ 'available' => (int) ( $r['available'] ?? 0 ),
+ 'age_0_90' => (int) ( $r['inv-age-0-to-90-days'] ?? 0 ),
+ 'age_91_180' => (int) ( $r['inv-age-91-to-180-days'] ?? 0 ),
+ 'age_181_270' => (int) ( $r['inv-age-181-to-270-days'] ?? 0 ),
+ 'age_271_365' => (int) ( $r['inv-age-271-to-365-days'] ?? 0 ),
+ 'age_365_plus' => $over_365,
+ 'units_t30' => (int) ( $r['units-shipped-t30'] ?? 0 ),
+ 'sell_through' => isset( $r['sell-through'] ) && '' !== $r['sell-through'] ? ReportParser::money( $r['sell-through'] ) : null,
+ 'days_of_supply' => isset( $r['days-of-supply'] ) && '' !== $r['days-of-supply'] ? (int) $r['days-of-supply'] : null,
+ 'est_storage_cost' => isset( $r['estimated-storage-cost-next-month'] ) && '' !== $r['estimated-storage-cost-next-month'] ? ReportParser::money( $r['estimated-storage-cost-next-month'] ) : null,
+ 'recommended_action' => mb_substr( (string) ( $r['recommended-action'] ?? '' ), 0, 64 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ return $n;
+ }
+
+ public function feedback( string $raw, string $market, bool $demo = false ): int {
+ $n = 0;
+ foreach ( ReportParser::tsv( $raw ) as $r ) {
+ $date = self::date( $r['date'] ?? '' );
+ if ( null === $date ) {
+ continue;
+ }
+ $order = (string) ( $r['order-id'] ?? '' );
+ $rating = (int) ( $r['rating'] ?? 0 );
+ $hash = sha1( $market . '|' . $order . '|' . $date . '|' . $rating );
+ $is_new = 0 === (int) Db::var( 'SELECT COUNT(*) FROM {t:feedback} WHERE row_hash = %s', array( $hash ) );
+ Db::upsert( 'feedback', array(
+ 'row_hash' => $hash,
+ 'market' => $market,
+ 'feedback_date' => $date,
+ 'rating' => $rating,
+ 'comments' => mb_substr( (string) ( $r['comments'] ?? '' ), 0, 2000 ),
+ 'amazon_order_id' => $order,
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ if ( $is_new && $rating > 0 && $rating <= 2 && ! $demo ) {
+ SuiteAlerts::raise( 'warning', 'suite_feedback', sprintf( 'Negative seller feedback (%d★) on order %s', $rating, $order ), mb_substr( (string) ( $r['comments'] ?? '' ), 0, 300 ), array( 'market' => $market ) );
+ }
+ $n++;
+ }
+ return $n;
+ }
+
+ public function sqp( string $raw, string $market, bool $demo = false ): int {
+ $doc = ReportParser::json( $raw );
+ $n = 0;
+ foreach ( (array) ( $doc['dataByAsin'] ?? array() ) as $r ) {
+ $kw = mb_substr( strtolower( (string) ( $r['searchQueryData']['searchQuery'] ?? '' ) ), 0, 190 );
+ if ( '' === $kw || empty( $r['asin'] ) ) {
+ continue;
+ }
+ Db::upsert( 'keyword_metrics', array(
+ 'market' => $market,
+ 'asin' => (string) $r['asin'],
+ 'keyword' => $kw,
+ 'period_start' => substr( (string) ( $r['startDate'] ?? '' ), 0, 10 ),
+ 'period_end' => substr( (string) ( $r['endDate'] ?? '' ), 0, 10 ),
+ 'query_score' => (int) ( $r['searchQueryData']['searchQueryScore'] ?? 0 ),
+ 'query_volume' => (int) ( $r['searchQueryData']['searchQueryVolume'] ?? 0 ),
+ 'impressions_total' => (int) ( $r['impressionData']['totalQueryImpressionCount'] ?? 0 ),
+ 'impressions_asin' => (int) ( $r['impressionData']['asinImpressionCount'] ?? 0 ),
+ 'clicks_total' => (int) ( $r['clickData']['totalClickCount'] ?? 0 ),
+ 'clicks_asin' => (int) ( $r['clickData']['asinClickCount'] ?? 0 ),
+ 'cart_adds_total' => (int) ( $r['cartAddData']['totalCartAddCount'] ?? 0 ),
+ 'cart_adds_asin' => (int) ( $r['cartAddData']['asinCartAddCount'] ?? 0 ),
+ 'purchases_total' => (int) ( $r['purchaseData']['totalPurchaseCount'] ?? 0 ),
+ 'purchases_asin' => (int) ( $r['purchaseData']['asinPurchaseCount'] ?? 0 ),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ $n++;
+ }
+ return $n;
+ }
+
+ /** Accepts ISO-8601, Y-m-d, and US m/d/Y. */
+ public static function date( string $v ): ?string {
+ $v = trim( $v );
+ if ( '' === $v ) {
+ return null;
+ }
+ if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2})/', $v, $m ) ) {
+ return "{$m[1]}-{$m[2]}-{$m[3]}";
+ }
+ if ( 1 === preg_match( '#^(\d{1,2})/(\d{1,2})/(\d{2,4})#', $v, $m ) ) {
+ $y = strlen( $m[3] ) === 2 ? '20' . $m[3] : $m[3];
+ return sprintf( '%04d-%02d-%02d', (int) $y, (int) $m[1], (int) $m[2] );
+ }
+ $ts = strtotime( $v );
+ return false === $ts ? null : gmdate( 'Y-m-d', $ts );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Reports/ReportParser.php b/sg-commerce-pro/src/Suite/Reports/ReportParser.php
new file mode 100644
index 0000000..b82d751
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Reports/ReportParser.php
@@ -0,0 +1,113 @@
+= 2 && "\x1f\x8b" === substr( $raw, 0, 2 ) ) {
+ $out = @gzdecode( $raw );
+ if ( false === $out ) {
+ throw new \RuntimeException( 'Report document could not be decompressed.' );
+ }
+ return $out;
+ }
+ return $raw;
+ }
+
+ /** Normalise encoding to UTF-8 and strip a BOM. */
+ public static function to_utf8( string $raw ): string {
+ if ( str_starts_with( $raw, "\xEF\xBB\xBF" ) ) {
+ $raw = substr( $raw, 3 );
+ }
+ if ( function_exists( 'mb_check_encoding' ) && ! mb_check_encoding( $raw, 'UTF-8' ) ) {
+ $converted = function_exists( 'mb_convert_encoding' ) ? mb_convert_encoding( $raw, 'UTF-8', 'Windows-1252' ) : false;
+ if ( is_string( $converted ) ) {
+ return $converted;
+ }
+ }
+ return $raw;
+ }
+
+ /**
+ * Parse a tab-separated flat file into associative rows keyed by a
+ * normalised header (lower-case, spaces/underscores → hyphens).
+ *
+ * @return array>
+ */
+ public static function tsv( string $raw ): array {
+ $raw = self::to_utf8( self::maybe_gunzip( $raw ) );
+ $lines = preg_split( '/\r\n|\n|\r/', $raw ) ?: array();
+ $header = null;
+ $rows = array();
+ foreach ( $lines as $line ) {
+ if ( '' === trim( $line ) ) {
+ continue;
+ }
+ $cells = explode( "\t", $line );
+ if ( null === $header ) {
+ $header = array_map( array( self::class, 'normalise_key' ), $cells );
+ continue;
+ }
+ $row = array();
+ foreach ( $header as $i => $key ) {
+ $row[ $key ] = isset( $cells[ $i ] ) ? trim( $cells[ $i ], " \"" ) : '';
+ }
+ $rows[] = $row;
+ }
+ return $rows;
+ }
+
+ /** @return array */
+ public static function json( string $raw ): array {
+ $decoded = json_decode( self::to_utf8( self::maybe_gunzip( $raw ) ), true );
+ if ( ! is_array( $decoded ) ) {
+ throw new \RuntimeException( 'Report document is not valid JSON.' );
+ }
+ return $decoded;
+ }
+
+ public static function normalise_key( string $key ): string {
+ $key = strtolower( trim( $key, " \t\"\xEF\xBB\xBF" ) );
+ return (string) preg_replace( '/[\s_]+/', '-', $key );
+ }
+
+ /** Parse money strings like "1,234.50", "$4.99", "4,99" (EU). */
+ public static function money( mixed $v ): float {
+ if ( is_int( $v ) || is_float( $v ) ) {
+ return (float) $v;
+ }
+ $s = preg_replace( '/[^\d,.\-]/', '', (string) $v ) ?? '';
+ if ( '' === $s ) {
+ return 0.0;
+ }
+ // "1.234,56" → EU format; "1,234.56" → US format; "4,99" → EU decimal.
+ if ( str_contains( $s, ',' ) && str_contains( $s, '.' ) ) {
+ $s = strrpos( $s, ',' ) > strrpos( $s, '.' )
+ ? str_replace( array( '.', ',' ), array( '', '.' ), $s )
+ : str_replace( ',', '', $s );
+ } elseif ( str_contains( $s, ',' ) ) {
+ $s = 1 === preg_match( '/,\d{1,2}$/', $s ) ? str_replace( ',', '.', $s ) : str_replace( ',', '', $s );
+ }
+ return (float) $s;
+ }
+
+ /** Parse percent strings like "12.5%" → 12.5. */
+ public static function pct( mixed $v ): float {
+ return self::money( str_replace( '%', '', (string) $v ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Reports/ReportPipeline.php b/sg-commerce-pro/src/Suite/Reports/ReportPipeline.php
new file mode 100644
index 0000000..be5744e
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Reports/ReportPipeline.php
@@ -0,0 +1,254 @@
+ [label, schedule (daily|weekly|manual), window days, options]
+ * window 0 = report takes no date range.
+ */
+ public const CATALOG = array(
+ 'GET_FLAT_FILE_ALL_ORDERS_DATA_BY_ORDER_DATE_GENERAL' => array( 'Orders (all, by order date)', 'daily', 3, array() ),
+ 'GET_SALES_AND_TRAFFIC_REPORT' => array( 'Business Report — Sales & Traffic', 'daily', 1, array( 'dateGranularity' => 'DAY', 'asinGranularity' => 'CHILD' ) ),
+ 'GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA' => array( 'FBA customer returns', 'daily', 14, array() ),
+ 'GET_FBA_INVENTORY_PLANNING_DATA' => array( 'FBA inventory health / aged inventory', 'daily', 0, array() ),
+ 'GET_SELLER_FEEDBACK_DATA' => array( 'Seller feedback', 'daily', 30, array() ),
+ 'GET_LEDGER_DETAIL_VIEW_DATA' => array( 'Inventory ledger (detail)', 'weekly', 60, array() ),
+ 'GET_FBA_REIMBURSEMENTS_DATA' => array( 'FBA reimbursements', 'weekly', 120, array() ),
+ 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' => array( 'Brand Analytics — Search Query Performance', 'weekly', 7, array( 'reportPeriod' => 'WEEK' ) ),
+ );
+
+ private const MAX_ATTEMPTS = 90;
+
+ public function __construct(
+ private ReportsClient $reports,
+ private ReportIngestor $ingestor,
+ private AdsClient $ads,
+ private AdsSync $ads_sync,
+ private Marketplaces $marketplaces,
+ private Logger $logger,
+ ) {}
+
+ /**
+ * Request an SP-API report. Dates are Y-m-d (inclusive) in UTC.
+ */
+ public function request( string $type, string $market, ?string $from = null, ?string $to = null, array $options = array() ): int {
+ $spec = self::CATALOG[ $type ] ?? array( $type, 'manual', 0, array() );
+ $options = $options + $spec[3];
+ $start = null !== $from ? $from . 'T00:00:00Z' : null;
+ $end = null !== $to ? $to . 'T23:59:59Z' : null;
+ $row = array(
+ 'report_type' => $type,
+ 'market' => $market,
+ 'source' => 'sp',
+ 'status' => 'requested',
+ 'data_start' => null !== $from ? $from . ' 00:00:00' : null,
+ 'data_end' => null !== $to ? $to . ' 23:59:59' : null,
+ 'options' => wp_json_encode( $options ),
+ 'requested_at' => Db::now(),
+ );
+ try {
+ $row['remote_id'] = $this->reports->create( $type, $market, $start, $end, $options );
+ } catch ( \Throwable $e ) {
+ $row['status'] = 'failed';
+ $row['error'] = mb_substr( $e->getMessage(), 0, 1000 );
+ }
+ return Db::insert( 'reports', $row );
+ }
+
+ /** Request every report whose schedule is due. */
+ public function request_scheduled( string $schedule, array $sqp_asins = array() ): int {
+ $n = 0;
+ foreach ( $this->marketplaces->enabled_codes() as $market ) {
+ $today = SuiteTime::today( $market );
+ foreach ( self::CATALOG as $type => [ $label, $sched, $window ] ) {
+ if ( $sched !== $schedule ) {
+ continue;
+ }
+ if ( 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' === $type ) {
+ $n += $this->request_sqp( $market, $sqp_asins[ $market ] ?? array() );
+ continue;
+ }
+ if ( 0 === $window ) {
+ $this->request( $type, $market );
+ } elseif ( 'GET_SALES_AND_TRAFFIC_REPORT' === $type ) {
+ // One report per day gives per-day per-ASIN rows; restate the last 3 days.
+ foreach ( array( 1, 2, 3 ) as $back ) {
+ $d = SuiteTime::shift( $today, -$back );
+ $this->request( $type, $market, $d, $d );
+ $n++;
+ }
+ continue;
+ } else {
+ $this->request( $type, $market, SuiteTime::shift( $today, -$window ), SuiteTime::shift( $today, -1 ) );
+ }
+ $n++;
+ }
+ }
+ return $n;
+ }
+
+ /** Backfill orders + traffic for N days (orders in 30-day chunks). */
+ public function backfill( string $market, int $days ): int {
+ $n = 0;
+ $today = SuiteTime::today( $market );
+ for ( $back = $days; $back > 0; $back -= 30 ) {
+ $from = SuiteTime::shift( $today, -$back );
+ $to = SuiteTime::shift( $today, -max( 1, $back - 29 ) );
+ $this->request( 'GET_FLAT_FILE_ALL_ORDERS_DATA_BY_ORDER_DATE_GENERAL', $market, $from, $to );
+ $n++;
+ }
+ for ( $back = min( $days, 30 ); $back >= 1; $back-- ) {
+ $d = SuiteTime::shift( $today, -$back );
+ $this->request( 'GET_SALES_AND_TRAFFIC_REPORT', $market, $d, $d );
+ $n++;
+ }
+ return $n;
+ }
+
+ /**
+ * SQP requires a full Sunday–Saturday week and ≤ 200 chars of
+ * space-separated ASINs per request.
+ */
+ public function request_sqp( string $market, array $asins ): int {
+ $asins = array_values( array_unique( array_filter( $asins ) ) );
+ if ( ! $asins ) {
+ return 0;
+ }
+ $today = new \DateTimeImmutable( SuiteTime::today( $market ) );
+ $sat = $today->modify( 'last saturday' );
+ $sun = $sat->modify( '-6 days' );
+ $n = 0;
+ $chunk = array();
+ $flush = function () use ( &$chunk, &$n, $market, $sun, $sat ): void {
+ if ( $chunk ) {
+ $this->request( 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT', $market, $sun->format( 'Y-m-d' ), $sat->format( 'Y-m-d' ), array( 'asin' => implode( ' ', $chunk ) ) );
+ $n++;
+ $chunk = array();
+ }
+ };
+ foreach ( $asins as $a ) {
+ if ( strlen( implode( ' ', array_merge( $chunk, array( $a ) ) ) ) > 200 ) {
+ $flush();
+ }
+ $chunk[] = $a;
+ }
+ $flush();
+ return $n;
+ }
+
+ /** Poll in-flight reports; ingest finished ones. */
+ public function poll( int $limit = 8 ): array {
+ $stats = array( 'checked' => 0, 'completed' => 0, 'failed' => 0, 'rows' => 0 );
+ $rows = Db::rows(
+ "SELECT * FROM {t:reports} WHERE status IN ('requested','processing') ORDER BY requested_at ASC LIMIT %d",
+ array( $limit )
+ );
+ foreach ( $rows as $r ) {
+ $stats['checked']++;
+ $id = (int) $r['id'];
+ try {
+ $result = 'ads' === $r['source'] ? $this->poll_ads( $r ) : $this->poll_sp( $r );
+ if ( null === $result ) {
+ $attempts = (int) $r['attempts'] + 1;
+ Db::update( 'reports', array(
+ 'status' => $attempts >= self::MAX_ATTEMPTS ? 'failed' : 'processing',
+ 'attempts' => $attempts,
+ 'error' => $attempts >= self::MAX_ATTEMPTS ? 'Timed out waiting for Amazon.' : null,
+ ), array( 'id' => $id ) );
+ continue;
+ }
+ [ $status, $count, $err ] = $result;
+ Db::update( 'reports', array(
+ 'status' => $status,
+ 'rows_ingested' => $count,
+ 'error' => $err,
+ 'completed_at' => Db::now(),
+ ), array( 'id' => $id ) );
+ if ( 'completed' === $status || 'empty' === $status ) {
+ $stats['completed']++;
+ $stats['rows'] += $count;
+ do_action( 'sg_suite_report_ingested', (string) $r['report_type'], (string) $r['market'], $count );
+ } else {
+ $stats['failed']++;
+ }
+ } catch ( \Throwable $e ) {
+ $stats['failed']++;
+ Db::update( 'reports', array(
+ 'status' => (int) $r['attempts'] >= 5 ? 'failed' : 'processing',
+ 'attempts' => (int) $r['attempts'] + 1,
+ 'error' => mb_substr( $e->getMessage(), 0, 1000 ),
+ ), array( 'id' => $id ) );
+ $this->logger->warning( 'Suite report poll failed', array( 'id' => $id, 'type' => $r['report_type'], 'error' => $e->getMessage() ) );
+ }
+ }
+ return $stats;
+ }
+
+ /** @return array{0:string,1:int,2:?string}|null null = still processing */
+ private function poll_sp( array $r ): ?array {
+ $st = $this->reports->get( (string) $r['remote_id'], (string) $r['market'] );
+ $status = (string) ( $st['processingStatus'] ?? '' );
+ if ( in_array( $status, array( 'IN_QUEUE', 'IN_PROGRESS', '' ), true ) ) {
+ return null;
+ }
+ if ( 'CANCELLED' === $status ) {
+ return array( 'empty', 0, 'Amazon returned no data for this range.' );
+ }
+ if ( 'FATAL' === $status ) {
+ $detail = '';
+ if ( ! empty( $st['reportDocumentId'] ) ) {
+ try {
+ $detail = mb_substr( $this->reports->download( (string) $st['reportDocumentId'], (string) $r['market'] ), 0, 500 );
+ } catch ( \Throwable ) {
+ $detail = '';
+ }
+ }
+ return array( 'failed', 0, 'FATAL' . ( '' !== $detail ? ': ' . $detail : '' ) );
+ }
+ $doc = (string) ( $st['reportDocumentId'] ?? '' );
+ Db::update( 'reports', array( 'document_id' => $doc ), array( 'id' => (int) $r['id'] ) );
+ $raw = $this->reports->download( $doc, (string) $r['market'] );
+ $count = $this->ingestor->ingest( (string) $r['report_type'], (string) $r['market'], $raw, $r );
+ return array( 'completed', $count, null );
+ }
+
+ private function poll_ads( array $r ): ?array {
+ $st = $this->ads->report_status( (string) $r['remote_id'] );
+ $status = strtoupper( (string) ( $st['status'] ?? '' ) );
+ if ( 'FAILED' === $status ) {
+ return array( 'failed', 0, (string) ( $st['failureReason'] ?? 'Ads report failed.' ) );
+ }
+ if ( 'COMPLETED' !== $status || empty( $st['url'] ) ) {
+ return null;
+ }
+ $count = $this->ads_sync->ingest( (string) $r['report_type'], $this->ads->download( (string) $st['url'] ) );
+ return array( 'completed', $count, null );
+ }
+
+ /** Housekeeping: drop report rows older than 90 days. */
+ public function prune(): int {
+ return Db::exec( 'DELETE FROM {t:reports} WHERE requested_at < %s', array( gmdate( 'Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS ) ) );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Reports/ReportsClient.php b/sg-commerce-pro/src/Suite/Reports/ReportsClient.php
new file mode 100644
index 0000000..7bfcfbf
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Reports/ReportsClient.php
@@ -0,0 +1,80 @@
+client->marketplace_info( $market );
+ $body = array(
+ 'reportType' => $type,
+ 'marketplaceIds' => array( (string) ( $mp['id'] ?? '' ) ),
+ );
+ if ( null !== $start ) {
+ $body['dataStartTime'] = $start;
+ }
+ if ( null !== $end ) {
+ $body['dataEndTime'] = $end;
+ }
+ if ( $options ) {
+ $body['reportOptions'] = array_map( 'strval', $options );
+ }
+ $res = $this->client->request( 'POST', $market, self::BASE . '/reports', array(), $body, 'reports.create' );
+ $id = (string) ( $res['reportId'] ?? '' );
+ if ( '' === $id ) {
+ throw new \RuntimeException( 'createReport returned no reportId.' );
+ }
+ return $id;
+ }
+
+ /** @return array{processingStatus:string, reportDocumentId?:string} */
+ public function get( string $report_id, string $market ): array {
+ return $this->client->request( 'GET', $market, self::BASE . '/reports/' . rawurlencode( $report_id ), array(), null, 'reports.get' );
+ }
+
+ /** Resolve and download a document; returns decompressed raw content. */
+ public function download( string $document_id, string $market ): string {
+ $doc = $this->client->request( 'GET', $market, self::BASE . '/documents/' . rawurlencode( $document_id ), array(), null, 'reports.document' );
+ $url = (string) ( $doc['url'] ?? '' );
+ if ( '' === $url ) {
+ throw new \RuntimeException( 'Report document has no download URL.' );
+ }
+ $res = wp_remote_get( $url, array( 'timeout' => 120 ) );
+ if ( is_wp_error( $res ) ) {
+ throw new \RuntimeException( 'Report download failed: ' . $res->get_error_message() );
+ }
+ if ( 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
+ throw new \RuntimeException( 'Report download HTTP ' . wp_remote_retrieve_response_code( $res ) );
+ }
+ $body = (string) wp_remote_retrieve_body( $res );
+ return 'GZIP' === strtoupper( (string) ( $doc['compressionAlgorithm'] ?? '' ) ) || str_starts_with( $body, "\x1f\x8b" )
+ ? ReportParser::maybe_gunzip( $body )
+ : $body;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Research/ProductResearch.php b/sg-commerce-pro/src/Suite/Research/ProductResearch.php
new file mode 100644
index 0000000..cccff7b
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Research/ProductResearch.php
@@ -0,0 +1,251 @@
+ 1.0, 'gourmet' => 1.0, 'home' => 1.6, 'kitchen' => 1.4, 'beauty' => 1.2, 'hpc' => 1.3,
+ 'health' => 1.3, 'sports' => 0.9, 'toy' => 1.0, 'pet' => 0.7, 'office' => 0.6, 'electronics' => 0.9,
+ 'wireless' => 0.9, 'baby' => 0.5, 'lawn' => 0.8, 'garden' => 0.8, 'home_improvement' => 0.8,
+ 'apparel' => 1.8, 'fashion' => 1.8, 'shoes' => 1.2, 'automotive' => 0.7, 'books' => 1.5,
+ );
+
+ public function __construct( private AmazonClient $client ) {}
+
+ public function search( string $keywords, string $market ): array {
+ $mp = $this->client->marketplace_info( $market );
+ $res = $this->client->request( 'GET', $market, '/catalog/2022-04-01/items', array(
+ 'keywords' => $keywords,
+ 'marketplaceIds' => (string) ( $mp['id'] ?? '' ),
+ 'includedData' => 'summaries,salesRanks,images',
+ 'pageSize' => 20,
+ ), null, 'catalog.search' );
+
+ $items = array();
+ foreach ( (array) ( $res['items'] ?? array() ) as $it ) {
+ $asin = (string) ( $it['asin'] ?? '' );
+ $sum = (array) ( $it['summaries'][0] ?? array() );
+ [ $rank, $group ] = self::top_rank( (array) ( $it['salesRanks'][0] ?? array() ) );
+ $img = '';
+ foreach ( (array) ( $it['images'][0]['images'] ?? array() ) as $im ) {
+ if ( 'MAIN' === ( $im['variant'] ?? '' ) || '' === $img ) {
+ $img = (string) ( $im['link'] ?? '' );
+ }
+ }
+ $items[ $asin ] = array(
+ 'asin' => $asin,
+ 'title' => (string) ( $sum['itemName'] ?? '' ),
+ 'brand' => (string) ( $sum['brand'] ?? $sum['brandName'] ?? '' ),
+ 'category' => (string) ( $sum['browseClassification']['displayName'] ?? $group ),
+ 'image' => $img,
+ 'bsr' => $rank,
+ 'est_units' => null !== $rank ? self::estimate_monthly_units( $rank, $group ) : null,
+ 'price' => null,
+ 'offers' => null,
+ 'est_revenue'=> null,
+ );
+ }
+ if ( $items ) {
+ try {
+ $pr = $this->client->request( 'GET', $market, '/products/pricing/v0/competitivePrice', array(
+ 'MarketplaceId' => (string) ( $mp['id'] ?? '' ),
+ 'Asins' => implode( ',', array_slice( array_keys( $items ), 0, 20 ) ),
+ 'ItemType' => 'Asin',
+ ), null, 'pricing.competitivePricing' );
+ foreach ( (array) ( $pr['payload'] ?? array() ) as $p ) {
+ $asin = (string) ( $p['ASIN'] ?? '' );
+ if ( ! isset( $items[ $asin ] ) ) {
+ continue;
+ }
+ $cp = (array) ( $p['Product']['CompetitivePricing'] ?? array() );
+ foreach ( (array) ( $cp['CompetitivePrices'] ?? array() ) as $price ) {
+ $items[ $asin ]['price'] = (float) ( $price['Price']['LandedPrice']['Amount'] ?? $price['Price']['ListingPrice']['Amount'] ?? 0 ) ?: null;
+ break;
+ }
+ $count = 0;
+ foreach ( (array) ( $cp['NumberOfOfferListings'] ?? array() ) as $n ) {
+ $count += (int) ( $n['Count'] ?? 0 );
+ }
+ $items[ $asin ]['offers'] = $count;
+ }
+ } catch ( \Throwable ) {
+ // Pricing is enrichment only; search still returns.
+ }
+ }
+ foreach ( $items as &$i ) {
+ if ( null !== $i['price'] && null !== $i['est_units'] ) {
+ $i['est_revenue'] = round( $i['price'] * $i['est_units'], 2 );
+ }
+ }
+ unset( $i );
+ $list = array_values( $items );
+ $units = array_filter( array_column( $list, 'est_units' ) );
+ $revs = array_filter( array_column( $list, 'est_revenue' ) );
+ $prices = array_filter( array_column( $list, 'price' ) );
+ return array(
+ 'items' => $list,
+ 'summary' => array(
+ 'results' => count( $list ),
+ 'avg_price' => $prices ? round( array_sum( $prices ) / count( $prices ), 2 ) : null,
+ 'avg_units' => $units ? (int) round( array_sum( $units ) / count( $units ) ) : null,
+ 'total_revenue' => $revs ? round( array_sum( $revs ), 2 ) : null,
+ 'top3_share_pct' => $revs ? round( array_sum( array_slice( self::rsorted( $revs ), 0, 3 ) ) / max( 1, array_sum( $revs ) ) * 100, 1 ) : null,
+ 'opportunity' => self::opportunity( $list ),
+ ),
+ );
+ }
+
+ public function fees( string $asin, float $price, string $market ): array {
+ $mp = $this->client->marketplace_info( $market );
+ $cur = (string) ( $mp['currency'] ?? 'USD' );
+ $res = $this->client->request( 'POST', $market, '/products/fees/v0/items/' . rawurlencode( $asin ) . '/feesEstimate', array(), array(
+ 'FeesEstimateRequest' => array(
+ 'MarketplaceId' => (string) ( $mp['id'] ?? '' ),
+ 'IsAmazonFulfilled' => true,
+ 'PriceToEstimateFees' => array(
+ 'ListingPrice' => array( 'CurrencyCode' => $cur, 'Amount' => round( $price, 2 ) ),
+ 'Shipping' => array( 'CurrencyCode' => $cur, 'Amount' => 0 ),
+ ),
+ 'Identifier' => 'sg-' . wp_generate_password( 8, false ),
+ ),
+ ), 'fees.estimate' );
+ $est = (array) ( $res['payload']['FeesEstimateResult'] ?? array() );
+ if ( 'Success' !== ( $est['Status'] ?? 'Success' ) ) {
+ throw new \RuntimeException( (string) ( $est['Error']['Message'] ?? 'Fee estimate failed.' ) );
+ }
+ $details = array();
+ $referral = 0.0;
+ $fba = 0.0;
+ foreach ( (array) ( $est['FeesEstimate']['FeeDetailList'] ?? array() ) as $d ) {
+ $amt = (float) ( $d['FinalFee']['Amount'] ?? $d['FeeAmount']['Amount'] ?? 0 );
+ $type = (string) ( $d['FeeType'] ?? '' );
+ $details[] = array( 'type' => $type, 'amount' => $amt );
+ if ( 'ReferralFee' === $type ) {
+ $referral += $amt;
+ } elseif ( str_contains( $type, 'FBA' ) ) {
+ $fba += $amt;
+ }
+ }
+ return array(
+ 'asin' => $asin,
+ 'price' => $price,
+ 'currency' => $cur,
+ 'total' => (float) ( $est['FeesEstimate']['TotalFeesEstimate']['Amount'] ?? array_sum( array_column( $details, 'amount' ) ) ),
+ 'referral' => $referral,
+ 'fba' => $fba,
+ 'details' => $details,
+ );
+ }
+
+ /**
+ * Pure profitability calculator (FBA revenue calculator +).
+ *
+ * @param array{price:float, unit_cost:float, inbound:float, referral_pct?:float, referral?:float, fba_fee:float, storage_per_unit?:float, tacos_pct?:float, return_pct?:float, cvr_pct?:float} $in
+ */
+ public static function calc( array $in ): array {
+ $price = max( 0.0, (float) ( $in['price'] ?? 0 ) );
+ $referral = isset( $in['referral'] ) && $in['referral'] > 0 ? (float) $in['referral'] : $price * (float) ( $in['referral_pct'] ?? 15 ) / 100;
+ $fba = (float) ( $in['fba_fee'] ?? 0 );
+ $storage = (float) ( $in['storage_per_unit'] ?? 0 );
+ $cogs = (float) ( $in['unit_cost'] ?? 0 ) + (float) ( $in['inbound'] ?? 0 );
+ $ads = $price * (float) ( $in['tacos_pct'] ?? 0 ) / 100;
+ // Returns: lose the sale on return_pct of units; referral is mostly refunded, FBA fee is not.
+ $ret = (float) ( $in['return_pct'] ?? 0 ) / 100;
+ $returns = $ret * ( $price - $referral * 0.8 );
+ $fees = $referral + $fba + $storage;
+ $profit = $price - $fees - $cogs - $ads - $returns;
+ $pre_ads = $price - $fees - $cogs - $returns;
+ $cvr = (float) ( $in['cvr_pct'] ?? 10 ) / 100;
+ return array(
+ 'price' => round( $price, 2 ),
+ 'amazon_fees' => round( $fees, 2 ),
+ 'referral' => round( $referral, 2 ),
+ 'cogs' => round( $cogs, 2 ),
+ 'ads_per_unit' => round( $ads, 2 ),
+ 'returns_per_unit' => round( $returns, 2 ),
+ 'profit_per_unit' => round( $profit, 2 ),
+ 'margin_pct' => $price > 0 ? round( $profit / $price * 100, 2 ) : null,
+ 'roi_pct' => $cogs > 0 ? round( $profit / $cogs * 100, 2 ) : null,
+ 'breakeven_acos' => $price > 0 ? round( max( 0, $pre_ads ) / $price * 100, 2 ) : null,
+ 'max_cpc' => round( max( 0, $pre_ads ) * $cvr, 2 ),
+ 'breakeven_price' => self::breakeven_price( $in ),
+ );
+ }
+
+ /** Units/month from BSR (power law, display-group adjusted). */
+ public static function estimate_monthly_units( int $rank, string $group ): int {
+ if ( $rank <= 0 ) {
+ return 0;
+ }
+ $factor = 1.0;
+ $g = strtolower( $group );
+ foreach ( self::GROUP_FACTOR as $k => $f ) {
+ if ( str_contains( $g, $k ) ) {
+ $factor = $f;
+ break;
+ }
+ }
+ return (int) max( 0, round( $factor * 417000 * pow( $rank, -0.89 ) ) );
+ }
+
+ /** @return array{0:?int, 1:string} */
+ private static function top_rank( array $sales_ranks ): array {
+ foreach ( (array) ( $sales_ranks['displayGroupRanks'] ?? array() ) as $r ) {
+ return array( (int) ( $r['rank'] ?? 0 ) ?: null, (string) ( $r['websiteDisplayGroup'] ?? $r['title'] ?? '' ) );
+ }
+ foreach ( (array) ( $sales_ranks['classificationRanks'] ?? array() ) as $r ) {
+ return array( (int) ( $r['rank'] ?? 0 ) ?: null, (string) ( $r['title'] ?? '' ) );
+ }
+ return array( null, '' );
+ }
+
+ private static function rsorted( array $v ): array {
+ rsort( $v );
+ return $v;
+ }
+
+ /** 0–100: demand high, competition (offers, top-3 concentration) low, price in the sweet spot. */
+ private static function opportunity( array $items ): ?int {
+ if ( ! $items ) {
+ return null;
+ }
+ $units = array_filter( array_column( $items, 'est_units' ) );
+ $prices = array_filter( array_column( $items, 'price' ) );
+ $offers = array_filter( array_column( $items, 'offers' ), static fn( $v ) => null !== $v );
+ $demand = $units ? min( 40, ( array_sum( $units ) / count( $units ) ) / 25 ) : 0;
+ $comp = $offers ? max( 0, 30 - ( array_sum( $offers ) / count( $offers ) ) * 3 ) : 15;
+ $avg_p = $prices ? array_sum( $prices ) / count( $prices ) : 0;
+ $price = $avg_p >= 15 && $avg_p <= 60 ? 30 : ( $avg_p >= 10 ? 18 : 8 );
+ return (int) round( min( 100, $demand + $comp + $price ) );
+ }
+
+ private static function breakeven_price( array $in ): ?float {
+ $cogs = (float) ( $in['unit_cost'] ?? 0 ) + (float) ( $in['inbound'] ?? 0 );
+ $fixed = (float) ( $in['fba_fee'] ?? 0 ) + (float) ( $in['storage_per_unit'] ?? 0 ) + $cogs;
+ $pct = ( (float) ( $in['referral_pct'] ?? 15 ) + (float) ( $in['tacos_pct'] ?? 0 ) ) / 100;
+ return $pct < 1 ? round( $fixed / ( 1 - $pct ), 2 ) : null;
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Rest/SuiteController.php b/sg-commerce-pro/src/Suite/Rest/SuiteController.php
new file mode 100644
index 0000000..9c42dab
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Rest/SuiteController.php
@@ -0,0 +1,677 @@
+ [methods, handler]
+ '/suite/overview' => array( 'GET', 'overview' ),
+ '/suite/profit' => array( 'GET', 'profit' ),
+ '/suite/orders' => array( 'GET', 'orders' ),
+ '/suite/costs' => array( 'GET,POST', 'costs' ),
+ '/suite/expenses' => array( 'GET,POST', 'expenses' ),
+ '/suite/expenses/(?P\d+)' => array( 'DELETE', 'expense_delete' ),
+ '/suite/restock' => array( 'GET', 'restock' ),
+ '/suite/inventory-health' => array( 'GET', 'inventory_health' ),
+ '/suite/ads/overview' => array( 'GET', 'ads_overview' ),
+ '/suite/ads/campaigns' => array( 'GET', 'ads_campaigns' ),
+ '/suite/ads/targets' => array( 'GET', 'ads_targets' ),
+ '/suite/ads/search-terms' => array( 'GET', 'ads_search_terms' ),
+ '/suite/ads/actions' => array( 'GET', 'ads_actions' ),
+ '/suite/ads/optimize' => array( 'POST', 'ads_optimize' ),
+ '/suite/ads/apply' => array( 'POST', 'ads_apply' ),
+ '/suite/ads/dismiss' => array( 'POST', 'ads_dismiss' ),
+ '/suite/ads/sync' => array( 'POST', 'ads_sync' ),
+ '/suite/ads/test' => array( 'POST', 'ads_test' ),
+ '/suite/keywords' => array( 'GET,POST', 'keywords' ),
+ '/suite/keywords/(?P\d+)' => array( 'DELETE', 'keyword_delete' ),
+ '/suite/keywords/research' => array( 'POST', 'keyword_research' ),
+ '/suite/keywords/queries' => array( 'GET', 'keyword_queries' ),
+ '/suite/listings' => array( 'GET', 'listings' ),
+ '/suite/listings/audit' => array( 'POST', 'listing_audit' ),
+ '/suite/listings/ai' => array( 'POST', 'listing_ai' ),
+ '/suite/monitor' => array( 'GET', 'monitor' ),
+ '/suite/monitor/history' => array( 'GET', 'monitor_history' ),
+ '/suite/monitor/run' => array( 'POST', 'monitor_run' ),
+ '/suite/reviews' => array( 'GET', 'reviews' ),
+ '/suite/reviews/run' => array( 'POST', 'reviews_run' ),
+ '/suite/reimbursements' => array( 'GET', 'reimbursements' ),
+ '/suite/reimbursements/scan' => array( 'POST', 'reimbursements_scan' ),
+ '/suite/reimbursements/(?P\d+)' => array( 'POST', 'reimbursement_update' ),
+ '/suite/research/search' => array( 'POST', 'research_search' ),
+ '/suite/research/fees' => array( 'POST', 'research_fees' ),
+ '/suite/research/calc' => array( 'POST', 'research_calc' ),
+ '/suite/traffic' => array( 'GET', 'traffic' ),
+ '/suite/returns' => array( 'GET', 'returns' ),
+ '/suite/feedback' => array( 'GET', 'feedback' ),
+ '/suite/alerts' => array( 'GET,POST', 'alerts' ),
+ '/suite/reports' => array( 'GET,POST', 'reports' ),
+ '/suite/reports/poll' => array( 'POST', 'reports_poll' ),
+ '/suite/sync' => array( 'POST', 'sync' ),
+ '/suite/settings' => array( 'GET,POST', 'settings' ),
+ '/suite/demo' => array( 'POST,DELETE', 'demo' ),
+ );
+ foreach ( $r as $path => [ $methods, $handler ] ) {
+ register_rest_route( self::NS, $path, array(
+ 'methods' => $methods,
+ 'permission_callback' => static fn() => current_user_can( 'manage_options' ),
+ 'callback' => function ( \WP_REST_Request $req ) use ( $handler ) {
+ try {
+ return rest_ensure_response( $this->{$handler}( $req ) );
+ } catch ( \Throwable $e ) {
+ return new \WP_Error( 'sg_suite_error', $e->getMessage(), array( 'status' => $e instanceof \InvalidArgumentException ? 400 : 500 ) );
+ }
+ },
+ ) );
+ }
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Helpers */
+ /* ------------------------------------------------------------------ */
+
+ private function market( \WP_REST_Request $req ): string {
+ $mp = $this->c->get( Marketplaces::class );
+ $m = strtoupper( (string) ( $req['market'] ?? '' ) );
+ return $mp->exists( $m ) ? $m : $mp->primary();
+ }
+
+ /** @return array{0:string,1:string} */
+ private function range( \WP_REST_Request $req, string $market, int $default_days = 30 ): array {
+ $today = SuiteTime::today( $market );
+ $to = SuiteTime::ymd( $req['to'] ?? '', $today );
+ $from = SuiteTime::ymd( $req['from'] ?? '', SuiteTime::shift( $to, -( $default_days - 1 ) ) );
+ if ( $from > $to ) {
+ [ $from, $to ] = array( $to, $from );
+ }
+ if ( SuiteTime::days_between( $from, $to ) > 400 ) {
+ $from = SuiteTime::shift( $to, -399 );
+ }
+ return array( $from, $to );
+ }
+
+ private function json( \WP_REST_Request $req ): array {
+ $p = $req->get_json_params();
+ return is_array( $p ) ? $p : $req->get_params();
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Dashboard & profit */
+ /* ------------------------------------------------------------------ */
+
+ public function overview( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $today = SuiteTime::today( $market );
+ $profit = $this->c->get( ProfitEngine::class );
+ $pnl30 = $profit->pnl( $market, SuiteTime::shift( $today, -29 ), $today );
+ $restock = $this->c->get( RestockPlanner::class )->build( $market );
+ $board = $this->c->get( ListingMonitor::class )->board();
+ $mine = array_filter( $board, static fn( $r ) => (int) $r['is_mine'] );
+ $bb_won = array_filter( $mine, static fn( $r ) => (int) $r['buybox_is_mine'] );
+ $listing_scores = Db::rows( 'SELECT score FROM {t:listing_audits} WHERE market = %s', array( $market ) );
+ $reimb = Db::row( "SELECT COUNT(*) AS n, COALESCE(SUM(est_amount),0) AS v FROM {t:reimb_cases} WHERE status = 'open'" );
+ $ads = $this->c->get( AdsInsights::class )->overview( SuiteTime::shift( $today, -29 ), $today );
+ $traffic = Db::row(
+ "SELECT COALESCE(SUM(sessions),0) AS sessions, COALESCE(SUM(units),0) AS units FROM {t:traffic_daily}
+ WHERE market = %s AND asin = '' AND report_date BETWEEN %s AND %s",
+ array( $market, SuiteTime::shift( $today, -29 ), $today )
+ );
+ return array(
+ 'market' => $market,
+ 'currency' => $pnl30['currency'],
+ 'periods' => $profit->dashboard( $market ),
+ 'series' => $pnl30['series'],
+ 'top_skus' => array_slice( $pnl30['skus'], 0, 8 ),
+ 'totals_30' => $pnl30['totals'],
+ 'restock' => $restock['summary'] + array( 'urgent' => array_slice( array_values( array_filter( $restock['rows'], static fn( $r ) => in_array( $r['status'], array( 'out_of_stock', 'critical', 'reorder_now' ), true ) ) ), 0, 6 ) ),
+ 'buybox' => array( 'asins' => count( $mine ), 'winning' => count( $bb_won ), 'pct' => $mine ? round( count( $bb_won ) / count( $mine ) * 100, 1 ) : null, 'hijacked' => count( array_filter( $mine, static fn( $r ) => (int) $r['other_sellers'] > 0 ) ) ),
+ 'listings' => array( 'audited' => count( $listing_scores ), 'avg_score' => $listing_scores ? (int) round( array_sum( array_column( $listing_scores, 'score' ) ) / count( $listing_scores ) ) : null ),
+ 'reimburse' => array( 'open' => (int) ( $reimb['n'] ?? 0 ), 'value' => round( (float) ( $reimb['v'] ?? 0 ), 2 ) ),
+ 'ads' => $ads['totals'],
+ 'conversion' => (int) ( $traffic['sessions'] ?? 0 ) > 0 ? round( (int) $traffic['units'] / (int) $traffic['sessions'] * 100, 2 ) : null,
+ 'sessions' => (int) ( $traffic['sessions'] ?? 0 ),
+ 'reviews' => $this->c->get( ReviewRequester::class )->stats( 30 )['by_status'],
+ 'alerts' => AlertDispatcher::recent( 8 ),
+ 'unread' => AlertDispatcher::unread_count(),
+ 'connected' => $this->c->get( AmazonClient::class )->has_credentials(),
+ 'ads_ready' => $this->c->get( AdsClient::class )->is_ready(),
+ 'demo' => DemoSeeder::active(),
+ 'missing_costs' => $pnl30['missing_costs'],
+ );
+ }
+
+ public function profit( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ [ $from, $to ] = $this->range( $req, $market );
+ $out = $this->c->get( ProfitEngine::class )->pnl( $market, $from, $to );
+ $days = SuiteTime::days_between( $from, $to );
+ $prev = $this->c->get( ProfitEngine::class )->pnl( $market, SuiteTime::shift( $from, -$days ), SuiteTime::shift( $from, -1 ), false );
+ $out['previous'] = $prev['totals'];
+ return $out;
+ }
+
+ public function orders( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ [ $from, $to ] = $this->range( $req, $market, 7 );
+ $args = array( $market, $from, $to );
+ $where = 'o.market = %s AND o.local_date BETWEEN %s AND %s';
+ $q = trim( (string) ( $req['q'] ?? '' ) );
+ if ( '' !== $q ) {
+ $where .= ' AND (o.amazon_order_id LIKE %s OR EXISTS (SELECT 1 FROM {t:order_items} x WHERE x.amazon_order_id = o.amazon_order_id AND (x.sku LIKE %s OR x.asin LIKE %s)))';
+ $like = '%' . $GLOBALS['wpdb']->esc_like( $q ) . '%';
+ array_push( $args, $like, $like, $like );
+ }
+ $status = (string) ( $req['status'] ?? '' );
+ if ( '' !== $status ) {
+ $where .= ' AND o.status = %s';
+ $args[] = $status;
+ }
+ $orders = Db::rows( "SELECT o.* FROM {t:orders} o WHERE {$where} ORDER BY o.purchase_date DESC LIMIT 500", $args );
+ $ids = array_column( $orders, 'amazon_order_id' );
+ $items = array();
+ $fees = array();
+ if ( $ids ) {
+ foreach ( Db::rows( 'SELECT * FROM {t:order_items} WHERE amazon_order_id IN (' . Db::in( $ids ) . ')', $ids ) as $it ) {
+ $items[ $it['amazon_order_id'] ][] = $it;
+ }
+ foreach ( Db::rows( "SELECT amazon_order_id, category, SUM(amount) AS amt FROM {t:fin_events} WHERE amazon_order_id IN (" . Db::in( $ids ) . ') GROUP BY amazon_order_id, category', $ids ) as $f ) {
+ $fees[ $f['amazon_order_id'] ][ $f['category'] ] = (float) $f['amt'];
+ }
+ }
+ $costs = $this->c->get( CostBook::class );
+ $unit_fee = $this->c->get( ProfitEngine::class )->fee_per_unit( $market, $to );
+ foreach ( $orders as &$o ) {
+ $o['items'] = $items[ $o['amazon_order_id'] ] ?? array();
+ $f = $fees[ $o['amazon_order_id'] ] ?? array();
+ $sales = array_sum( array_map( static fn( $i ) => (float) $i['item_price'], $o['items'] ) );
+ $promo = array_sum( array_map( static fn( $i ) => (float) $i['promo_discount'], $o['items'] ) );
+ $cogs = array_sum( array_map( static fn( $i ) => (int) $i['qty'] * $costs->landed( $market, (string) $i['sku'] ), $o['items'] ) );
+ if ( isset( $f['fee'] ) ) {
+ $fee = $f['fee'];
+ $o['fees_estimated'] = false;
+ } else {
+ $fee = -array_sum( array_map( static fn( $i ) => isset( $unit_fee['sku'][ $i['sku'] ] ) ? $unit_fee['sku'][ $i['sku'] ] * (int) $i['qty'] : $unit_fee['ratio'] * (float) $i['item_price'], $o['items'] ) );
+ $o['fees_estimated'] = true;
+ }
+ $o['sales'] = round( $sales, 2 );
+ $o['fees'] = round( $fee, 2 );
+ $o['refund'] = round( (float) ( $f['refund'] ?? 0 ), 2 );
+ $o['cogs'] = round( $cogs, 2 );
+ $o['profit'] = 'Canceled' === $o['status'] ? 0.0 : round( $sales - $promo + $fee + $o['refund'] - $cogs, 2 );
+ }
+ return array( 'from' => $from, 'to' => $to, 'orders' => $orders );
+ }
+
+ public function costs( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $book = $this->c->get( CostBook::class );
+ if ( 'POST' === $req->get_method() ) {
+ $rows = (array) ( $this->json( $req )['rows'] ?? array() );
+ $n = 0;
+ foreach ( $rows as $row ) {
+ if ( ! empty( $row['sku'] ) && $book->save( $market, (string) $row['sku'], (array) $row ) ) {
+ $n++;
+ }
+ }
+ return array( 'saved' => $n );
+ }
+ return array( 'rows' => $book->all( $market ) );
+ }
+
+ public function expenses( \WP_REST_Request $req ): array {
+ if ( 'POST' === $req->get_method() ) {
+ $in = $this->json( $req );
+ $label = sanitize_text_field( (string) ( $in['label'] ?? '' ) );
+ if ( '' === $label ) {
+ throw new \InvalidArgumentException( 'Label is required.' );
+ }
+ $id = Db::insert( 'expenses', array(
+ 'market' => strtoupper( sanitize_text_field( (string) ( $in['market'] ?? '' ) ) ),
+ 'label' => mb_substr( $label, 0, 128 ),
+ 'category' => sanitize_key( (string) ( $in['category'] ?? 'other' ) ) ?: 'other',
+ 'amount' => max( 0, (float) ( $in['amount'] ?? 0 ) ),
+ 'recurrence' => 'monthly' === ( $in['recurrence'] ?? '' ) ? 'monthly' : 'once',
+ 'start_date' => SuiteTime::ymd( $in['start_date'] ?? '', gmdate( 'Y-m-d' ) ),
+ 'end_date' => ! empty( $in['end_date'] ) ? SuiteTime::ymd( $in['end_date'], gmdate( 'Y-m-d' ) ) : null,
+ 'sku' => '',
+ 'created_at' => Db::now(),
+ ) );
+ return array( 'id' => $id );
+ }
+ return array( 'rows' => Db::rows( 'SELECT * FROM {t:expenses} ORDER BY start_date DESC' ) );
+ }
+
+ public function expense_delete( \WP_REST_Request $req ): array {
+ return array( 'deleted' => Db::exec( 'DELETE FROM {t:expenses} WHERE id = %d', array( (int) $req['id'] ) ) );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Inventory */
+ /* ------------------------------------------------------------------ */
+
+ public function restock( \WP_REST_Request $req ): array {
+ return $this->c->get( RestockPlanner::class )->build( $this->market( $req ) );
+ }
+
+ public function inventory_health( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $snap = (string) Db::var( 'SELECT MAX(snapshot_date) FROM {t:inventory_health} WHERE market = %s', array( $market ) );
+ return array(
+ 'snapshot' => $snap,
+ 'rows' => '' === $snap ? array() : Db::rows( 'SELECT * FROM {t:inventory_health} WHERE market = %s AND snapshot_date = %s ORDER BY (age_181_270 + age_271_365 + age_365_plus) DESC, available DESC', array( $market, $snap ) ),
+ );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* PPC */
+ /* ------------------------------------------------------------------ */
+
+ public function ads_overview( \WP_REST_Request $req ): array {
+ [ $from, $to ] = $this->range( $req, $this->market( $req ) );
+ return $this->c->get( AdsInsights::class )->overview( $from, $to ) + array(
+ 'from' => $from, 'to' => $to,
+ 'ready' => $this->c->get( AdsClient::class )->is_ready(),
+ 'planned' => (int) Db::var( "SELECT COUNT(*) FROM {t:ads_actions} WHERE status = 'planned'" ),
+ );
+ }
+
+ public function ads_campaigns( \WP_REST_Request $req ): array {
+ [ $from, $to ] = $this->range( $req, $this->market( $req ) );
+ return array( 'rows' => $this->c->get( AdsInsights::class )->campaigns( $from, $to ) );
+ }
+
+ public function ads_targets( \WP_REST_Request $req ): array {
+ [ $from, $to ] = $this->range( $req, $this->market( $req ) );
+ return array( 'rows' => $this->c->get( AdsInsights::class )->targets( $from, $to, sanitize_text_field( (string) ( $req['campaign'] ?? '' ) ) ) );
+ }
+
+ public function ads_search_terms( \WP_REST_Request $req ): array {
+ [ $from, $to ] = $this->range( $req, $this->market( $req ) );
+ return array( 'rows' => $this->c->get( AdsInsights::class )->search_terms( $from, $to ) );
+ }
+
+ public function ads_actions( \WP_REST_Request $req ): array {
+ $i = $this->c->get( AdsInsights::class );
+ return array( 'planned' => $i->actions( 'planned' ), 'history' => $i->history() );
+ }
+
+ public function ads_optimize(): array {
+ return $this->c->get( AdsSync::class )->optimize();
+ }
+
+ public function ads_apply( \WP_REST_Request $req ): array {
+ return $this->c->get( AdsSync::class )->apply( (array) ( $this->json( $req )['ids'] ?? array() ) );
+ }
+
+ public function ads_dismiss( \WP_REST_Request $req ): array {
+ return array( 'dismissed' => $this->c->get( AdsSync::class )->dismiss( (array) ( $this->json( $req )['ids'] ?? array() ) ) );
+ }
+
+ public function ads_sync(): array {
+ $sync = $this->c->get( AdsSync::class );
+ $structure = $sync->sync_structure();
+ $reports = $sync->request_reports( gmdate( 'Y-m-d', time() - 31 * DAY_IN_SECONDS ), gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) );
+ return array( 'structure' => $structure, 'reports_requested' => count( $reports ) );
+ }
+
+ public function ads_test( \WP_REST_Request $req ): array {
+ $in = $this->json( $req );
+ if ( $in ) {
+ $this->c->get( SuiteSettings::class )->save( $in );
+ }
+ return $this->c->get( AdsClient::class )->test();
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Keywords & listings */
+ /* ------------------------------------------------------------------ */
+
+ public function keywords( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $svc = $this->c->get( KeywordService::class );
+ if ( 'POST' === $req->get_method() ) {
+ $in = $this->json( $req );
+ $asin = strtoupper( sanitize_text_field( (string) ( $in['asin'] ?? '' ) ) );
+ if ( 1 !== preg_match( '/^[A-Z0-9]{10}$/', $asin ) ) {
+ throw new \InvalidArgumentException( 'A valid 10-character ASIN is required.' );
+ }
+ $list = is_array( $in['keywords'] ?? null ) ? $in['keywords'] : preg_split( '/[\r\n,]+/', (string) ( $in['keywords'] ?? '' ) );
+ return array( 'added' => $svc->add( $market, $asin, (array) $list, 'manual', (int) ( $in['priority'] ?? 2 ) ) );
+ }
+ return array( 'rows' => $svc->tracked( $market, strtoupper( sanitize_text_field( (string) ( $req['asin'] ?? '' ) ) ) ), 'asins' => $this->own_asins( $market ) );
+ }
+
+ public function keyword_delete( \WP_REST_Request $req ): array {
+ return array( 'deleted' => $this->c->get( KeywordService::class )->delete( (int) $req['id'] ) );
+ }
+
+ public function keyword_research( \WP_REST_Request $req ): array {
+ $in = $this->json( $req );
+ $asins = array_values( array_filter( array_map( static fn( $a ) => strtoupper( trim( (string) $a ) ), (array) ( $in['asins'] ?? array() ) ) ) );
+ return array( 'rows' => $this->c->get( KeywordService::class )->research( $asins ) );
+ }
+
+ public function keyword_queries( \WP_REST_Request $req ): array {
+ return array( 'rows' => $this->c->get( KeywordService::class )->sqp_queries( $this->market( $req ), strtoupper( sanitize_text_field( (string) ( $req['asin'] ?? '' ) ) ) ) );
+ }
+
+ public function listings( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $rows = Db::rows( 'SELECT * FROM {t:listing_audits} WHERE market = %s ORDER BY score ASC', array( $market ) );
+ foreach ( $rows as &$r ) {
+ $r['findings'] = json_decode( (string) $r['findings'], true ) ?: array();
+ $r['snapshot'] = json_decode( (string) $r['snapshot'], true ) ?: array();
+ }
+ return array( 'rows' => $rows, 'seller_id_set' => '' !== $this->c->get( SuiteSettings::class )->seller_id() );
+ }
+
+ public function listing_audit( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $sku = sanitize_text_field( (string) ( $this->json( $req )['sku'] ?? '' ) );
+ if ( '' === $sku ) {
+ return array( 'audited' => $this->c->get( SuiteModule::class )->audit_batch( 30 ) );
+ }
+ $asin = (string) Db::var( 'SELECT asin FROM ' . $GLOBALS['wpdb']->prefix . 'sg_products WHERE market = %s AND sku = %s', array( $market, $sku ) );
+ $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE market = %s AND asin = %s', array( $market, $asin ) ), 'keyword' );
+ return $this->c->get( ListingAuditor::class )->audit( $market, $sku, $kw );
+ }
+
+ /** AI rewrite of title/bullets/backend using the configured AI provider and tracked keywords. */
+ public function listing_ai( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $sku = sanitize_text_field( (string) ( $this->json( $req )['sku'] ?? '' ) );
+ $row = Db::row( 'SELECT * FROM {t:listing_audits} WHERE market = %s AND sku = %s', array( $market, $sku ) );
+ if ( null === $row ) {
+ throw new \InvalidArgumentException( 'Audit the listing first.' );
+ }
+ $snap = json_decode( (string) $row['snapshot'], true ) ?: array();
+ $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE market = %s AND asin = %s ORDER BY priority', array( $market, (string) $row['asin'] ) ), 'keyword' );
+ $voice = (string) $this->c->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class )->get( 'ai_brand_voice', 'modern, clean, playful but confident' );
+ $prompt = "You are an Amazon listing copywriter. Brand voice: {$voice}.\n"
+ . "Rewrite this Amazon listing to maximise conversion and search indexing while following Amazon policy:\n"
+ . "- Title: start with the brand, max 200 characters, no word more than twice, no !\$?_{}^ characters, no promotional claims.\n"
+ . "- Exactly 5 bullet points, each 150-250 characters, starting with a short CAPITALISED benefit phrase.\n"
+ . "- Backend search terms: max 249 bytes, space separated, no commas, no brand names, no words already in the title.\n"
+ . '- Naturally include these target keywords: ' . implode( ', ', array_slice( $kw, 0, 15 ) ) . "\n\n"
+ . 'Current title: ' . ( $snap['title'] ?? '' ) . "\n"
+ . "Current bullets:\n- " . implode( "\n- ", (array) ( $snap['bullets'] ?? array() ) ) . "\n\n"
+ . "Respond ONLY with JSON: {\"title\": \"...\", \"bullets\": [\"...\"], \"backend\": \"...\"}";
+ $provider = $this->c->get( \SevenGum\Commerce\AI\ProviderInterface::class );
+ $res = $provider->generate( $prompt, array( 'temperature' => 0.6, 'max_tokens' => 1200 ) );
+ $text = (string) ( $res['text'] ?? '' );
+ $json = null;
+ if ( preg_match( '/\{.*\}/s', $text, $m ) ) {
+ $json = json_decode( $m[0], true );
+ }
+ if ( ! is_array( $json ) ) {
+ throw new \RuntimeException( 'The AI provider did not return valid JSON. Raw output: ' . mb_substr( $text, 0, 300 ) );
+ }
+ $draft = array_merge( $snap, array(
+ 'title' => (string) ( $json['title'] ?? '' ),
+ 'bullets' => array_values( array_map( 'strval', (array) ( $json['bullets'] ?? array() ) ) ),
+ 'backend' => (string) ( $json['backend'] ?? '' ),
+ ) );
+ return array( 'draft' => $draft, 'score' => ListingAuditor::score( $draft, $kw ), 'provider' => $res['provider'] ?? '', 'model' => $res['model'] ?? '' );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Monitor, reviews, reimbursements */
+ /* ------------------------------------------------------------------ */
+
+ public function monitor(): array {
+ $mon = $this->c->get( ListingMonitor::class );
+ return array(
+ 'rows' => $mon->board(),
+ 'hijacks' => Db::rows( "SELECT s.*, (SELECT COUNT(*) FROM {t:offer_sellers} x WHERE x.asin = s.asin AND x.is_me = 1) AS mine FROM {t:offer_sellers} s WHERE s.is_me = 0 AND s.active = 1 AND EXISTS (SELECT 1 FROM {t:offer_sellers} m WHERE m.asin = s.asin AND m.market = s.market AND m.is_me = 1) ORDER BY s.first_seen DESC" ),
+ 'watching' => count( $mon->watchlist() ),
+ );
+ }
+
+ public function monitor_history( \WP_REST_Request $req ): array {
+ $mon = $this->c->get( ListingMonitor::class );
+ $market = $this->market( $req );
+ $asin = strtoupper( sanitize_text_field( (string) ( $req['asin'] ?? '' ) ) );
+ return array( 'history' => $mon->history( $market, $asin, max( 7, min( 365, (int) ( $req['days'] ?? 90 ) ) ) ), 'sellers' => $mon->sellers( $market, $asin ) );
+ }
+
+ public function monitor_run(): array {
+ return $this->c->get( ListingMonitor::class )->run();
+ }
+
+ public function reviews(): array {
+ return $this->c->get( ReviewRequester::class )->stats( 30 ) + array( 'enabled' => $this->c->get( SuiteSettings::class )->bool( 'suite_reviews_enabled' ) );
+ }
+
+ public function reviews_run(): array {
+ return $this->c->get( ReviewRequester::class )->run();
+ }
+
+ public function reimbursements( \WP_REST_Request $req ): array {
+ $status = sanitize_key( (string) ( $req['status'] ?? 'open' ) );
+ $rows = 'all' === $status
+ ? Db::rows( 'SELECT * FROM {t:reimb_cases} ORDER BY est_amount DESC LIMIT 500' )
+ : Db::rows( 'SELECT * FROM {t:reimb_cases} WHERE status = %s ORDER BY est_amount DESC LIMIT 500', array( $status ) );
+ foreach ( $rows as &$r ) {
+ $r['evidence'] = json_decode( (string) $r['evidence'], true ) ?: array();
+ }
+ return array(
+ 'rows' => $rows,
+ 'summary' => $this->c->get( ReimbursementAuditor::class )->summary(),
+ 'recent' => Db::rows( 'SELECT * FROM {t:reimbursements} ORDER BY approval_date DESC LIMIT 100' ),
+ );
+ }
+
+ public function reimbursements_scan(): array {
+ return $this->c->get( ReimbursementAuditor::class )->scan();
+ }
+
+ public function reimbursement_update( \WP_REST_Request $req ): array {
+ return array( 'updated' => $this->c->get( ReimbursementAuditor::class )->update_case( (int) $req['id'], $this->json( $req ) ) );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Research */
+ /* ------------------------------------------------------------------ */
+
+ public function research_search( \WP_REST_Request $req ): array {
+ $kw = sanitize_text_field( (string) ( $this->json( $req )['keywords'] ?? '' ) );
+ if ( '' === $kw ) {
+ throw new \InvalidArgumentException( 'Enter keywords to search.' );
+ }
+ return $this->c->get( ProductResearch::class )->search( $kw, $this->market( $req ) );
+ }
+
+ public function research_fees( \WP_REST_Request $req ): array {
+ $in = $this->json( $req );
+ $asin = strtoupper( sanitize_text_field( (string) ( $in['asin'] ?? '' ) ) );
+ if ( 1 !== preg_match( '/^[A-Z0-9]{10}$/', $asin ) ) {
+ throw new \InvalidArgumentException( 'A valid ASIN is required.' );
+ }
+ return $this->c->get( ProductResearch::class )->fees( $asin, (float) ( $in['price'] ?? 0 ), $this->market( $req ) );
+ }
+
+ public function research_calc( \WP_REST_Request $req ): array {
+ return ProductResearch::calc( array_map( 'floatval', array_intersect_key( $this->json( $req ), array_flip( array(
+ 'price', 'unit_cost', 'inbound', 'referral_pct', 'referral', 'fba_fee', 'storage_per_unit', 'tacos_pct', 'return_pct', 'cvr_pct',
+ ) ) ) ) );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Traffic, returns, feedback, alerts */
+ /* ------------------------------------------------------------------ */
+
+ public function traffic( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ [ $from, $to ] = $this->range( $req, $market );
+ $series = Db::rows(
+ "SELECT report_date AS date, sessions, page_views, buy_box_pct, units, ordered_sales FROM {t:traffic_daily}
+ WHERE market = %s AND asin = '' AND report_date BETWEEN %s AND %s ORDER BY report_date",
+ array( $market, $from, $to )
+ );
+ $asins = Db::rows(
+ "SELECT asin, MAX(sku) AS sku, SUM(sessions) AS sessions, SUM(page_views) AS page_views, AVG(buy_box_pct) AS buy_box_pct,
+ SUM(units) AS units, SUM(ordered_sales) AS sales
+ FROM {t:traffic_daily} WHERE market = %s AND asin <> '' AND report_date BETWEEN %s AND %s GROUP BY asin ORDER BY SUM(sessions) DESC",
+ array( $market, $from, $to )
+ );
+ foreach ( $asins as &$a ) {
+ $a['cvr'] = (int) $a['sessions'] > 0 ? round( (int) $a['units'] / (int) $a['sessions'] * 100, 2 ) : null;
+ $a['buy_box_pct'] = round( (float) $a['buy_box_pct'], 1 );
+ }
+ return array( 'from' => $from, 'to' => $to, 'series' => $series, 'asins' => $asins );
+ }
+
+ public function returns( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ [ $from, $to ] = $this->range( $req, $market, 90 );
+ $a = array( $market, $from, $to );
+ return array(
+ 'by_reason' => Db::rows( 'SELECT reason, SUM(qty) AS units FROM {t:returns} WHERE market = %s AND return_date BETWEEN %s AND %s GROUP BY reason ORDER BY units DESC', $a ),
+ 'by_sku' => Db::rows( 'SELECT sku, SUM(qty) AS units, SUM(CASE WHEN disposition = %s THEN qty ELSE 0 END) AS sellable FROM {t:returns} WHERE market = %s AND return_date BETWEEN %s AND %s GROUP BY sku ORDER BY units DESC', array_merge( array( 'SELLABLE' ), $a ) ),
+ 'rows' => Db::rows( 'SELECT * FROM {t:returns} WHERE market = %s AND return_date BETWEEN %s AND %s ORDER BY return_date DESC LIMIT 300', $a ),
+ );
+ }
+
+ public function feedback( \WP_REST_Request $req ): array {
+ $market = $this->market( $req );
+ $rows = Db::rows( 'SELECT * FROM {t:feedback} WHERE market = %s ORDER BY feedback_date DESC LIMIT 300', array( $market ) );
+ $n = count( $rows );
+ $neg = count( array_filter( $rows, static fn( $r ) => (int) $r['rating'] <= 2 ) );
+ return array( 'rows' => $rows, 'count' => $n, 'negative_pct' => $n ? round( $neg / $n * 100, 2 ) : null, 'avg' => $n ? round( array_sum( array_column( $rows, 'rating' ) ) / $n, 2 ) : null );
+ }
+
+ public function alerts( \WP_REST_Request $req ): array {
+ if ( 'POST' === $req->get_method() ) {
+ return array( 'marked' => AlertDispatcher::mark_all_read() );
+ }
+ return array( 'rows' => AlertDispatcher::recent( 100 ), 'unread' => AlertDispatcher::unread_count() );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Reports, sync, settings, demo */
+ /* ------------------------------------------------------------------ */
+
+ public function reports( \WP_REST_Request $req ): array {
+ $pipeline = $this->c->get( ReportPipeline::class );
+ if ( 'POST' === $req->get_method() ) {
+ $in = $this->json( $req );
+ $type = (string) ( $in['type'] ?? '' );
+ if ( ! isset( ReportPipeline::CATALOG[ $type ] ) ) {
+ throw new \InvalidArgumentException( 'Unsupported report type.' );
+ }
+ $market = $this->market( $req );
+ $window = ReportPipeline::CATALOG[ $type ][2];
+ $to = SuiteTime::ymd( $in['to'] ?? '', SuiteTime::shift( SuiteTime::today( $market ), -1 ) );
+ $from = SuiteTime::ymd( $in['from'] ?? '', SuiteTime::shift( $to, -max( 0, $window - 1 ) ) );
+ if ( 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' === $type ) {
+ return array( 'requested' => $pipeline->request_sqp( $market, $this->own_asins( $market ) ) );
+ }
+ $id = 0 === $window ? $pipeline->request( $type, $market ) : $pipeline->request( $type, $market, $from, $to );
+ return array( 'id' => $id, 'row' => Db::row( 'SELECT * FROM {t:reports} WHERE id = %d', array( $id ) ) );
+ }
+ return array(
+ 'catalog' => array_map( static fn( $k, $v ) => array( 'type' => $k, 'label' => $v[0], 'schedule' => $v[1], 'window' => $v[2] ), array_keys( ReportPipeline::CATALOG ), ReportPipeline::CATALOG ),
+ 'rows' => Db::rows( 'SELECT * FROM {t:reports} ORDER BY requested_at DESC LIMIT 200' ),
+ 'jobs' => get_option( 'sg_suite_job_status', array() ),
+ );
+ }
+
+ public function reports_poll(): array {
+ return $this->c->get( ReportPipeline::class )->poll( 10 );
+ }
+
+ /** Manual "sync now" for a given job. */
+ public function sync( \WP_REST_Request $req ): array {
+ $job = sanitize_key( (string) ( $this->json( $req )['job'] ?? '' ) );
+ $m = $this->c->get( SuiteModule::class );
+ $market = $this->market( $req );
+ return array( 'job' => $job, 'result' => match ( $job ) {
+ 'orders' => $m->guard( 'orders', fn() => $this->c->get( OrdersSync::class )->run() ),
+ 'finance' => $m->guard( 'finance', fn() => $this->c->get( FinanceSync::class )->run() ),
+ 'reports' => $m->guard( 'reports.daily', fn() => $this->c->get( ReportPipeline::class )->request_scheduled( 'daily' ) ),
+ 'weekly' => $m->guard( 'reports.weekly', fn() => $this->c->get( ReportPipeline::class )->request_scheduled( 'weekly', $m->sqp_asins() ) ),
+ 'backfill' => $m->guard( 'backfill', function () use ( $market ) {
+ FinanceSync::reset_cursors();
+ return $this->c->get( ReportPipeline::class )->backfill( $market, $this->c->get( SuiteSettings::class )->int( 'suite_backfill_days' ) );
+ } ),
+ 'poll' => $m->guard( 'pipeline', fn() => $this->c->get( ReportPipeline::class )->poll( 10 ) ),
+ 'daily' => $m->daily() ?? 'done',
+ default => throw new \InvalidArgumentException( 'Unknown job.' ),
+ } );
+ }
+
+ public function settings( \WP_REST_Request $req ): array {
+ $s = $this->c->get( SuiteSettings::class );
+ if ( 'POST' === $req->get_method() ) {
+ $saved = $s->save( $this->json( $req ) );
+ return array( 'saved' => $saved, 'settings' => $s->export() );
+ }
+ return array(
+ 'settings' => $s->export(),
+ 'connected' => $this->c->get( AmazonClient::class )->has_credentials(),
+ 'jobs' => get_option( 'sg_suite_job_status', array() ),
+ 'next_runs' => array_map( static fn( $h ) => ( $t = wp_next_scheduled( $h ) ) ? gmdate( 'c', $t ) : null, array_combine( array_keys( SuiteModule::HOOKS ), array_keys( SuiteModule::HOOKS ) ) ),
+ 'ads_hosts' => array_keys( AdsClient::HOSTS ),
+ );
+ }
+
+ public function demo( \WP_REST_Request $req ): array {
+ $seeder = $this->c->get( DemoSeeder::class );
+ if ( 'DELETE' === $req->get_method() ) {
+ $seeder->clear();
+ return array( 'demo' => false );
+ }
+ return array( 'demo' => true, 'counts' => $seeder->seed( $this->market( $req ) ) );
+ }
+
+ /** @return string[] */
+ private function own_asins( string $market ): array {
+ global $wpdb;
+ return array_values( array_filter( (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT asin FROM {$wpdb->prefix}sg_products WHERE market = %s AND asin <> '' ORDER BY asin", $market ) ) ) ); // phpcs:ignore
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Reviews/ReviewRequester.php b/sg-commerce-pro/src/Suite/Reviews/ReviewRequester.php
new file mode 100644
index 0000000..4ced5b9
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Reviews/ReviewRequester.php
@@ -0,0 +1,149 @@
+settings->int( 'suite_reviews_delay_days' ) );
+ // Delivery estimate: latest_delivery, else earliest_delivery, else purchase + 7 days.
+ $ready_before = gmdate( 'Y-m-d H:i:s', $now - $delay * DAY_IN_SECONDS );
+ $too_old = gmdate( 'Y-m-d H:i:s', $now - 29 * DAY_IN_SECONDS );
+ $skip_refunds = $this->settings->bool( 'suite_reviews_skip_refunded' );
+
+ $sql = "SELECT o.amazon_order_id, o.market,
+ COALESCE(o.latest_delivery, o.earliest_delivery, DATE_ADD(o.purchase_date, INTERVAL 7 DAY)) AS delivered
+ FROM {t:orders} o
+ LEFT JOIN {t:solicitations} s ON s.amazon_order_id = o.amazon_order_id
+ WHERE o.status = 'Shipped' AND o.is_demo = 0 AND s.id IS NULL
+ AND COALESCE(o.latest_delivery, o.earliest_delivery, DATE_ADD(o.purchase_date, INTERVAL 7 DAY)) <= %s
+ AND COALESCE(o.latest_delivery, o.earliest_delivery, DATE_ADD(o.purchase_date, INTERVAL 7 DAY)) >= %s";
+ if ( $skip_refunds ) {
+ $sql .= " AND NOT EXISTS (SELECT 1 FROM {t:fin_events} f WHERE f.amazon_order_id = o.amazon_order_id AND f.event_type = 'Refund')
+ AND NOT EXISTS (SELECT 1 FROM {t:returns} r WHERE r.amazon_order_id = o.amazon_order_id)";
+ }
+ $sql .= ' ORDER BY delivered ASC LIMIT %d';
+ return Db::rows( $sql, array( $ready_before, $too_old, $limit ) );
+ }
+
+ /** @return array{sent:int, ineligible:int, errors:int} */
+ public function run(): array {
+ $stats = array( 'sent' => 0, 'ineligible' => 0, 'errors' => 0 );
+ if ( ! $this->settings->bool( 'suite_reviews_enabled' ) || ! $this->client->has_credentials() ) {
+ return $stats;
+ }
+ $sent_today = (int) Db::var(
+ "SELECT COUNT(*) FROM {t:solicitations} WHERE status = 'sent' AND attempted_at >= %s",
+ array( gmdate( 'Y-m-d 00:00:00' ) )
+ );
+ $budget = max( 0, $this->settings->int( 'suite_reviews_daily_cap' ) - $sent_today );
+ // Each order needs 2 calls at 1 rps; cap per tick to stay inside cron time limits.
+ foreach ( $this->candidates( min( $budget, 40 ) ) as $o ) {
+ $r = $this->request_one( (string) $o['amazon_order_id'], (string) $o['market'] );
+ $stats[ $r ]++;
+ if ( 'errors' === $r && $stats['errors'] >= 3 ) {
+ break;
+ }
+ }
+ return $stats;
+ }
+
+ /** @return 'sent'|'ineligible'|'errors' */
+ public function request_one( string $order_id, string $market ): string {
+ $mp = $this->client->marketplace_info( $market );
+ $q = array( 'marketplaceIds' => (string) ( $mp['id'] ?? '' ) );
+ $base = '/solicitations/v1/orders/' . rawurlencode( $order_id );
+ try {
+ $actions = $this->client->request( 'GET', $market, $base, $q, null, 'solicitations' );
+ $available = false;
+ foreach ( (array) ( $actions['_links']['actions'] ?? array() ) as $a ) {
+ if ( str_contains( (string) ( $a['href'] ?? '' ), self::ACTION ) || self::ACTION === ( $a['name'] ?? '' ) ) {
+ $available = true;
+ }
+ }
+ if ( ! $available ) {
+ $this->record( $order_id, $market, 'ineligible', 'Amazon reports no review request available (outside window or already requested).' );
+ return 'ineligible';
+ }
+ $this->client->request( 'POST', $market, $base . '/solicitations/' . self::ACTION, $q, array(), 'solicitations' );
+ $this->record( $order_id, $market, 'sent', '' );
+ return 'sent';
+ } catch ( \Throwable $e ) {
+ $msg = $e->getMessage();
+ // 403 here means the order is not eligible (e.g. already requested in Seller Central).
+ $status = str_contains( $msg, '403' ) || str_contains( $msg, 'not eligible' ) ? 'ineligible' : 'error';
+ $this->record( $order_id, $market, $status, mb_substr( $msg, 0, 250 ) );
+ return 'ineligible' === $status ? 'ineligible' : 'errors';
+ }
+ }
+
+ public function record( string $order_id, string $market, string $status, string $message, bool $demo = false ): void {
+ Db::upsert( 'solicitations', array(
+ 'market' => $market,
+ 'amazon_order_id' => $order_id,
+ 'status' => $status,
+ 'message' => $message,
+ 'attempted_at' => Db::now(),
+ 'is_demo' => $demo ? 1 : 0,
+ ) );
+ }
+
+ /** Allow errored orders to be retried. */
+ public function retry_errors(): int {
+ return Db::exec( "DELETE FROM {t:solicitations} WHERE status = 'error'" );
+ }
+
+ public function stats( int $days = 30 ): array {
+ $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS );
+ $by = array( 'sent' => 0, 'ineligible' => 0, 'error' => 0 );
+ foreach ( Db::rows( 'SELECT status, COUNT(*) AS n FROM {t:solicitations} WHERE attempted_at >= %s GROUP BY status', array( $since ) ) as $r ) {
+ $by[ (string) $r['status'] ] = (int) $r['n'];
+ }
+ $daily = Db::rows(
+ "SELECT DATE(attempted_at) AS d, SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) AS sent FROM {t:solicitations}
+ WHERE attempted_at >= %s GROUP BY DATE(attempted_at) ORDER BY d",
+ array( $since )
+ );
+ return array(
+ 'by_status' => $by,
+ 'daily' => $daily,
+ 'pending' => count( $this->candidates( 500 ) ),
+ 'recent' => Db::rows( 'SELECT * FROM {t:solicitations} ORDER BY attempted_at DESC LIMIT 100' ),
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/SuiteModule.php b/sg-commerce-pro/src/Suite/SuiteModule.php
new file mode 100644
index 0000000..1eb0b58
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/SuiteModule.php
@@ -0,0 +1,309 @@
+ 'sg_fifteen_minutes',
+ 'sg_suite_hourly' => 'hourly',
+ 'sg_suite_daily' => 'daily',
+ );
+
+ public function __construct( private Container $container ) {}
+
+ public function id(): string {
+ return 'suite';
+ }
+
+ public function register(): void {
+ $this->bind();
+
+ add_action( 'admin_init', array( $this, 'ensure_installed' ), 2 );
+ add_action( 'admin_menu', array( $this, 'menu' ), 20 );
+ add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) );
+ add_action( 'rest_api_init', function (): void {
+ $this->container->get( SuiteController::class )->routes();
+ } );
+
+ add_action( 'sg_suite_tick', array( $this, 'tick' ) );
+ add_action( 'sg_suite_hourly', array( $this, 'hourly' ) );
+ add_action( 'sg_suite_daily', array( $this, 'daily' ) );
+ add_action( 'sg_suite_report_ingested', array( $this, 'after_report' ), 10, 3 );
+ }
+
+ private function bind(): void {
+ $c = $this->container;
+ $c->singleton( SuiteSettings::class, static fn( Container $c ) => new SuiteSettings( $c->get( SettingsRepository::class ) ) );
+ $c->singleton( CostBook::class, static fn( Container $c ) => new CostBook( $c->get( SuiteSettings::class ) ) );
+ $c->singleton( ReportsClient::class, static fn( Container $c ) => new ReportsClient( $c->get( AmazonClient::class ) ) );
+ $c->singleton( OrdersSync::class, static fn( Container $c ) => new OrdersSync( $c->get( AmazonClient::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) );
+ $c->singleton( ReportIngestor::class, static fn( Container $c ) => new ReportIngestor( $c->get( OrdersSync::class ) ) );
+ $c->singleton( AdsClient::class, static fn( Container $c ) => new AdsClient( $c->get( SuiteSettings::class ), $c->get( CacheManager::class ), $c->get( Logger::class ) ) );
+ $c->singleton( AdsSync::class, static fn( Container $c ) => new AdsSync( $c->get( AdsClient::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) );
+ $c->singleton( AdsInsights::class, static fn() => new AdsInsights() );
+ $c->singleton( ReportPipeline::class, static fn( Container $c ) => new ReportPipeline(
+ $c->get( ReportsClient::class ), $c->get( ReportIngestor::class ), $c->get( AdsClient::class ),
+ $c->get( AdsSync::class ), $c->get( Marketplaces::class ), $c->get( Logger::class )
+ ) );
+ $c->singleton( FinanceSync::class, static fn( Container $c ) => new FinanceSync( $c->get( AmazonClient::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) );
+ $c->singleton( ProfitEngine::class, static fn( Container $c ) => new ProfitEngine( $c->get( CostBook::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ) ) );
+ $c->singleton( RestockPlanner::class, static fn( Container $c ) => new RestockPlanner( $c->get( CostBook::class ), $c->get( SuiteSettings::class ) ) );
+ $c->singleton( KeywordService::class, static fn( Container $c ) => new KeywordService( $c->get( AdsClient::class ) ) );
+ $c->singleton( ListingAuditor::class, static fn( Container $c ) => new ListingAuditor( $c->get( AmazonClient::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) );
+ $c->singleton( ListingMonitor::class, static fn( Container $c ) => new ListingMonitor( $c->get( AmazonClient::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) );
+ $c->singleton( ReviewRequester::class, static fn( Container $c ) => new ReviewRequester( $c->get( AmazonClient::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) );
+ $c->singleton( ReimbursementAuditor::class, static fn() => new ReimbursementAuditor() );
+ $c->singleton( ProductResearch::class, static fn( Container $c ) => new ProductResearch( $c->get( AmazonClient::class ) ) );
+ $c->singleton( DemoSeeder::class, static fn( Container $c ) => new DemoSeeder( $c->get( ReimbursementAuditor::class ), $c->get( AdsSync::class ) ) );
+ $c->singleton( SuiteController::class, static fn( Container $c ) => new SuiteController( $c ) );
+ }
+
+ /** Create tables + schedules for sites that updated without re-activating. */
+ public function ensure_installed(): void {
+ SuiteSchema::maybe_install();
+ self::schedule();
+ }
+
+ public static function schedule(): void {
+ foreach ( self::HOOKS as $hook => $recurrence ) {
+ if ( ! wp_next_scheduled( $hook ) ) {
+ wp_schedule_event( time() + 120, $recurrence, $hook );
+ }
+ }
+ }
+
+ public static function unschedule(): void {
+ foreach ( array_keys( self::HOOKS ) as $hook ) {
+ wp_clear_scheduled_hook( $hook );
+ }
+ }
+
+ public function menu(): void {
+ add_menu_page(
+ 'Amazon Seller Suite',
+ 'Amazon Suite',
+ 'manage_options',
+ self::PAGE,
+ array( $this, 'render' ),
+ 'dashicons-chart-area',
+ 57
+ );
+ $sections = array(
+ '' => 'Dashboard',
+ 'profit' => 'Profit & P&L',
+ 'orders' => 'Orders',
+ 'restock' => 'Inventory & Restock',
+ 'ppc' => 'PPC Manager',
+ 'keywords' => 'Keywords',
+ 'listings' => 'Listing Optimizer',
+ 'monitor' => 'Hijacker & Buy Box',
+ 'reviews' => 'Review Requests',
+ 'reimbursements' => 'Reimbursements',
+ 'research' => 'Product Research',
+ 'reports' => 'Reports Center',
+ 'settings' => 'Suite Settings',
+ );
+ foreach ( $sections as $route => $label ) {
+ add_submenu_page( self::PAGE, $label, $label, 'manage_options', self::PAGE . ( '' === $route ? '' : '#/' . $route ), array( $this, 'render' ) );
+ }
+ }
+
+ public function assets( string $hook ): void {
+ if ( ! str_contains( $hook, self::PAGE ) ) {
+ return;
+ }
+ wp_enqueue_style( 'sg-suite', SG_COMMERCE_URL . 'assets/css/suite.css', array(), SG_COMMERCE_VERSION );
+ wp_enqueue_script( 'sg-suite', SG_COMMERCE_URL . 'assets/js/suite.js', array(), SG_COMMERCE_VERSION, true );
+ $mp = $this->container->get( Marketplaces::class );
+ $markets = array();
+ foreach ( $mp->enabled_codes() as $code ) {
+ $m = $mp->get( $code );
+ $markets[] = array( 'code' => $code, 'currency' => $m['currency'] ?? 'USD', 'country' => $m['country'] ?? $code, 'tld' => $m['tld'] ?? 'com' );
+ }
+ wp_localize_script( 'sg-suite', 'SGSuite', array(
+ 'root' => esc_url_raw( rest_url( 'sg-commerce/v1/suite' ) ),
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ 'markets' => $markets,
+ 'primary' => $mp->primary(),
+ 'connected' => $this->container->get( AmazonClient::class )->has_credentials(),
+ 'ads' => $this->container->get( AdsClient::class )->is_ready(),
+ 'demo' => DemoSeeder::active(),
+ 'version' => SG_COMMERCE_VERSION,
+ 'coreUrl' => admin_url( 'admin.php?page=sg-commerce-amazon' ),
+ ) );
+ }
+
+ public function render(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You do not have permission to access this page.', 'sg-commerce' ) );
+ }
+ require SG_COMMERCE_DIR . 'admin/views/suite.php';
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Schedules */
+ /* ------------------------------------------------------------------ */
+
+ private function can_sync(): bool {
+ return $this->container->get( SuiteSettings::class )->bool( 'suite_sync_enabled' )
+ && $this->container->get( AmazonClient::class )->has_credentials();
+ }
+
+ public function tick(): void {
+ $this->guard( 'pipeline', fn() => $this->container->get( ReportPipeline::class )->poll( 8 ) );
+ if ( ! $this->can_sync() ) {
+ return;
+ }
+ $this->guard( 'orders', fn() => $this->container->get( OrdersSync::class )->run() );
+ if ( $this->container->get( SuiteSettings::class )->bool( 'suite_monitor_enabled' ) ) {
+ $this->guard( 'monitor', fn() => $this->container->get( ListingMonitor::class )->run() );
+ }
+ }
+
+ public function hourly(): void {
+ if ( ! $this->can_sync() ) {
+ return;
+ }
+ $this->guard( 'finance', fn() => $this->container->get( FinanceSync::class )->run() );
+ $this->guard( 'reviews', fn() => $this->container->get( ReviewRequester::class )->run() );
+ }
+
+ public function daily(): void {
+ $pipeline = $this->container->get( ReportPipeline::class );
+ if ( $this->can_sync() ) {
+ $this->guard( 'reports.daily', fn() => $pipeline->request_scheduled( 'daily' ) );
+ $last_weekly = (int) get_option( 'sg_suite_last_weekly', 0 );
+ if ( time() - $last_weekly > 6 * DAY_IN_SECONDS ) {
+ $this->guard( 'reports.weekly', fn() => $pipeline->request_scheduled( 'weekly', $this->sqp_asins() ) );
+ update_option( 'sg_suite_last_weekly', time(), false );
+ }
+ $this->guard( 'listings', fn() => $this->audit_batch( 25 ) );
+ }
+ $ads = $this->container->get( AdsClient::class );
+ $settings = $this->container->get( SuiteSettings::class );
+ if ( $settings->bool( 'suite_ads_enabled' ) && $ads->is_ready() ) {
+ $sync = $this->container->get( AdsSync::class );
+ $this->guard( 'ads.structure', fn() => $sync->sync_structure() );
+ $this->guard( 'ads.reports', fn() => $sync->request_reports( gmdate( 'Y-m-d', time() - 8 * DAY_IN_SECONDS ), gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) ) );
+ }
+ $this->guard( 'reimbursements', fn() => $this->container->get( ReimbursementAuditor::class )->scan() );
+ $this->guard( 'prune', function () use ( $pipeline ) {
+ $pipeline->prune();
+ $this->container->get( ListingMonitor::class )->prune();
+ return true;
+ } );
+ }
+
+ /** Re-plan PPC once the targeting/search-term data for the day lands. */
+ public function after_report( string $type, string $market, int $rows ): void {
+ if ( ! in_array( $type, array( 'spTargeting', 'spSearchTerm' ), true ) ) {
+ return;
+ }
+ if ( get_transient( 'sg_suite_ads_optimized' ) ) {
+ return;
+ }
+ set_transient( 'sg_suite_ads_optimized', 1, 6 * HOUR_IN_SECONDS );
+ $this->guard( 'ads.optimize', fn() => $this->container->get( AdsSync::class )->optimize() );
+ }
+
+ /** @return array market => own ASINs */
+ public function sqp_asins(): array {
+ global $wpdb;
+ $out = array();
+ $rows = $wpdb->get_results( "SELECT DISTINCT market, asin FROM {$wpdb->prefix}sg_products WHERE asin <> '' AND sku NOT LIKE 'DEMO-%'", ARRAY_A ) ?: array(); // phpcs:ignore
+ foreach ( $rows as $r ) {
+ $out[ (string) $r['market'] ][] = (string) $r['asin'];
+ }
+ return $out;
+ }
+
+ public function audit_batch( int $limit ): int {
+ global $wpdb;
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT p.market, p.sku, p.asin FROM {$wpdb->prefix}sg_products p
+ LEFT JOIN " . SuiteSchema::table( 'listing_audits' ) . " a ON a.market = p.market AND a.sku = p.sku
+ WHERE p.sku NOT LIKE %s ORDER BY a.audited_at IS NOT NULL, a.audited_at ASC LIMIT %d",
+ 'DEMO-%', $limit
+ ), ARRAY_A ) ?: array(); // phpcs:ignore
+ $auditor = $this->container->get( ListingAuditor::class );
+ $n = 0;
+ foreach ( $rows as $r ) {
+ $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE market = %s AND asin = %s', array( $r['market'], $r['asin'] ) ), 'keyword' );
+ try {
+ $auditor->audit( (string) $r['market'], (string) $r['sku'], $kw );
+ $n++;
+ } catch ( \Throwable $e ) {
+ $this->container->get( Logger::class )->warning( 'Listing audit failed', array( 'sku' => $r['sku'], 'error' => $e->getMessage() ) );
+ if ( str_contains( $e->getMessage(), 'Seller ID' ) ) {
+ break;
+ }
+ }
+ }
+ return $n;
+ }
+
+ /** Run a job, log failures, record last-run status for the Settings screen. */
+ public function guard( string $job, callable $fn ): mixed {
+ $status = get_option( 'sg_suite_job_status', array() );
+ $status = is_array( $status ) ? $status : array();
+ try {
+ $result = $fn();
+ $status[ $job ] = array( 'at' => time(), 'ok' => true, 'result' => is_scalar( $result ) || is_array( $result ) ? $result : null );
+ update_option( 'sg_suite_job_status', $status, false );
+ return $result;
+ } catch ( \Throwable $e ) {
+ $status[ $job ] = array( 'at' => time(), 'ok' => false, 'error' => mb_substr( $e->getMessage(), 0, 300 ) );
+ update_option( 'sg_suite_job_status', $status, false );
+ $this->container->get( Logger::class )->error( "Suite job {$job} failed: " . $e->getMessage() );
+ return null;
+ }
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/SuiteSchema.php b/sg-commerce-pro/src/Suite/SuiteSchema.php
new file mode 100644
index 0000000..0be06d6
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/SuiteSchema.php
@@ -0,0 +1,538 @@
+prefix . 'sg_suite_' . $short;
+ }
+
+ public static function maybe_install(): void {
+ if ( (int) get_option( self::OPTION, 0 ) < self::VERSION ) {
+ self::install();
+ }
+ }
+
+ public static function install(): void {
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ $c = $wpdb->get_charset_collate();
+ $t = static fn( string $s ): string => self::table( $s );
+
+ dbDelta( "CREATE TABLE {$t('orders')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ amazon_order_id VARCHAR(32) NOT NULL,
+ purchase_date DATETIME NOT NULL,
+ local_date DATE NOT NULL,
+ last_update DATETIME DEFAULT NULL,
+ status VARCHAR(32) DEFAULT '' NOT NULL,
+ fulfillment_channel VARCHAR(8) DEFAULT '' NOT NULL,
+ sales_channel VARCHAR(64) DEFAULT '' NOT NULL,
+ order_total DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ currency VARCHAR(3) DEFAULT '' NOT NULL,
+ units INT DEFAULT 0 NOT NULL,
+ is_business TINYINT(1) DEFAULT 0 NOT NULL,
+ is_prime TINYINT(1) DEFAULT 0 NOT NULL,
+ ship_country VARCHAR(2) DEFAULT '' NOT NULL,
+ earliest_delivery DATETIME DEFAULT NULL,
+ latest_delivery DATETIME DEFAULT NULL,
+ items_synced TINYINT(1) DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY order_id (amazon_order_id),
+ KEY market_date (market, local_date),
+ KEY status (status),
+ KEY items_synced (items_synced)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('order_items')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ amazon_order_id VARCHAR(32) NOT NULL,
+ order_item_id VARCHAR(64) NOT NULL,
+ local_date DATE NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ title VARCHAR(255) DEFAULT '' NOT NULL,
+ qty INT DEFAULT 0 NOT NULL,
+ item_price DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ item_tax DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ promo_discount DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ status VARCHAR(32) DEFAULT '' NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY order_item (amazon_order_id, order_item_id),
+ KEY market_sku_date (market, sku, local_date),
+ KEY local_date (local_date)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('fin_events')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ event_hash CHAR(40) NOT NULL,
+ posted_date DATETIME NOT NULL,
+ local_date DATE NOT NULL,
+ event_type VARCHAR(48) NOT NULL,
+ amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ charge_type VARCHAR(64) DEFAULT '' NOT NULL,
+ category VARCHAR(16) NOT NULL,
+ amount DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ currency VARCHAR(3) DEFAULT '' NOT NULL,
+ qty INT DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY event_hash (event_hash),
+ KEY market_date_cat (market, local_date, category),
+ KEY order_id (amazon_order_id),
+ KEY sku_date (sku, local_date)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('reports')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ report_type VARCHAR(96) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ source VARCHAR(8) DEFAULT 'sp' NOT NULL,
+ remote_id VARCHAR(64) DEFAULT '' NOT NULL,
+ document_id VARCHAR(128) DEFAULT '' NOT NULL,
+ status VARCHAR(16) DEFAULT 'requested' NOT NULL,
+ data_start DATETIME DEFAULT NULL,
+ data_end DATETIME DEFAULT NULL,
+ options LONGTEXT,
+ rows_ingested INT DEFAULT 0 NOT NULL,
+ attempts INT DEFAULT 0 NOT NULL,
+ error TEXT,
+ requested_at DATETIME NOT NULL,
+ completed_at DATETIME DEFAULT NULL,
+ PRIMARY KEY (id),
+ KEY status (status, requested_at),
+ KEY type_time (report_type, requested_at)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('sku_costs')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ sku VARCHAR(64) NOT NULL,
+ unit_cost DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ inbound_per_unit DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ prep_per_unit DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ other_per_unit DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ lead_time_days INT DEFAULT 0 NOT NULL,
+ moq INT DEFAULT 0 NOT NULL,
+ case_pack INT DEFAULT 0 NOT NULL,
+ supplier VARCHAR(128) DEFAULT '' NOT NULL,
+ updated_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY market_sku (market, sku)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('expenses')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ label VARCHAR(128) NOT NULL,
+ category VARCHAR(32) DEFAULT 'other' NOT NULL,
+ amount DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ recurrence VARCHAR(8) DEFAULT 'once' NOT NULL,
+ start_date DATE NOT NULL,
+ end_date DATE DEFAULT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ created_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ KEY start_date (start_date)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('ads_campaigns')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ profile_id VARCHAR(32) NOT NULL,
+ campaign_id VARCHAR(32) NOT NULL,
+ name VARCHAR(255) DEFAULT '' NOT NULL,
+ state VARCHAR(16) DEFAULT '' NOT NULL,
+ targeting_type VARCHAR(16) DEFAULT '' NOT NULL,
+ daily_budget DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ bidding_strategy VARCHAR(48) DEFAULT '' NOT NULL,
+ start_date DATE DEFAULT NULL,
+ updated_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY campaign (profile_id, campaign_id)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('ads_targets')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ profile_id VARCHAR(32) NOT NULL,
+ target_id VARCHAR(32) NOT NULL,
+ kind VARCHAR(8) DEFAULT 'keyword' NOT NULL,
+ campaign_id VARCHAR(32) NOT NULL,
+ ad_group_id VARCHAR(32) DEFAULT '' NOT NULL,
+ keyword_text VARCHAR(255) DEFAULT '' NOT NULL,
+ match_type VARCHAR(32) DEFAULT '' NOT NULL,
+ state VARCHAR(16) DEFAULT '' NOT NULL,
+ bid DECIMAL(10,2) DEFAULT NULL,
+ updated_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY target (profile_id, target_id),
+ KEY campaign (campaign_id)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('ads_daily')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ profile_id VARCHAR(32) NOT NULL,
+ report_date DATE NOT NULL,
+ level VARCHAR(8) NOT NULL,
+ campaign_id VARCHAR(32) NOT NULL,
+ ad_group_id VARCHAR(32) DEFAULT '' NOT NULL,
+ target_id VARCHAR(32) DEFAULT '' NOT NULL,
+ keyword_text VARCHAR(255) DEFAULT '' NOT NULL,
+ match_type VARCHAR(32) DEFAULT '' NOT NULL,
+ impressions INT DEFAULT 0 NOT NULL,
+ clicks INT DEFAULT 0 NOT NULL,
+ cost DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ sales DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ orders INT DEFAULT 0 NOT NULL,
+ units INT DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY row_key (profile_id, report_date, level, campaign_id, target_id),
+ KEY date_level (report_date, level)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('ads_search_terms')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ profile_id VARCHAR(32) NOT NULL,
+ row_hash CHAR(40) NOT NULL,
+ report_date DATE NOT NULL,
+ campaign_id VARCHAR(32) NOT NULL,
+ ad_group_id VARCHAR(32) DEFAULT '' NOT NULL,
+ target_id VARCHAR(32) DEFAULT '' NOT NULL,
+ keyword_text VARCHAR(255) DEFAULT '' NOT NULL,
+ match_type VARCHAR(32) DEFAULT '' NOT NULL,
+ search_term VARCHAR(255) NOT NULL,
+ impressions INT DEFAULT 0 NOT NULL,
+ clicks INT DEFAULT 0 NOT NULL,
+ cost DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ sales DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ orders INT DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY row_hash (row_hash),
+ KEY report_date (report_date),
+ KEY term (search_term(64))
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('ads_actions')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ profile_id VARCHAR(32) DEFAULT '' NOT NULL,
+ kind VARCHAR(16) NOT NULL,
+ entity_id VARCHAR(64) DEFAULT '' NOT NULL,
+ campaign_id VARCHAR(32) DEFAULT '' NOT NULL,
+ ad_group_id VARCHAR(32) DEFAULT '' NOT NULL,
+ label VARCHAR(255) DEFAULT '' NOT NULL,
+ old_value VARCHAR(64) DEFAULT '' NOT NULL,
+ new_value VARCHAR(64) DEFAULT '' NOT NULL,
+ reason TEXT,
+ payload LONGTEXT,
+ status VARCHAR(12) DEFAULT 'planned' NOT NULL,
+ error TEXT,
+ created_at DATETIME NOT NULL,
+ applied_at DATETIME DEFAULT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ KEY status (status, created_at),
+ KEY kind (kind)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('keywords')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ asin VARCHAR(20) NOT NULL,
+ keyword VARCHAR(190) NOT NULL,
+ priority TINYINT DEFAULT 2 NOT NULL,
+ source VARCHAR(16) DEFAULT 'manual' NOT NULL,
+ created_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY market_asin_kw (market, asin, keyword),
+ KEY asin (asin)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('keyword_metrics')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ asin VARCHAR(20) NOT NULL,
+ keyword VARCHAR(190) NOT NULL,
+ period_start DATE NOT NULL,
+ period_end DATE NOT NULL,
+ query_score INT DEFAULT 0 NOT NULL,
+ query_volume INT DEFAULT 0 NOT NULL,
+ impressions_total INT DEFAULT 0 NOT NULL,
+ impressions_asin INT DEFAULT 0 NOT NULL,
+ clicks_total INT DEFAULT 0 NOT NULL,
+ clicks_asin INT DEFAULT 0 NOT NULL,
+ cart_adds_total INT DEFAULT 0 NOT NULL,
+ cart_adds_asin INT DEFAULT 0 NOT NULL,
+ purchases_total INT DEFAULT 0 NOT NULL,
+ purchases_asin INT DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY metric_key (market, asin, keyword, period_start),
+ KEY period (period_start)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('asin_snapshots')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ asin VARCHAR(20) NOT NULL,
+ captured_at DATETIME NOT NULL,
+ is_mine TINYINT(1) DEFAULT 0 NOT NULL,
+ my_price DECIMAL(14,4) DEFAULT NULL,
+ buybox_price DECIMAL(14,4) DEFAULT NULL,
+ buybox_seller VARCHAR(32) DEFAULT '' NOT NULL,
+ buybox_is_mine TINYINT(1) DEFAULT 0 NOT NULL,
+ lowest_price DECIMAL(14,4) DEFAULT NULL,
+ offer_count INT DEFAULT 0 NOT NULL,
+ fba_offer_count INT DEFAULT 0 NOT NULL,
+ bsr INT DEFAULT NULL,
+ bsr_category VARCHAR(128) DEFAULT '' NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ KEY asin_time (market, asin, captured_at),
+ KEY captured (captured_at)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('offer_sellers')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ asin VARCHAR(20) NOT NULL,
+ seller_id VARCHAR(32) NOT NULL,
+ is_me TINYINT(1) DEFAULT 0 NOT NULL,
+ is_fba TINYINT(1) DEFAULT 0 NOT NULL,
+ last_price DECIMAL(14,4) DEFAULT NULL,
+ first_seen DATETIME NOT NULL,
+ last_seen DATETIME NOT NULL,
+ active TINYINT(1) DEFAULT 1 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY seller_asin (market, asin, seller_id),
+ KEY active (active, last_seen)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('solicitations')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ amazon_order_id VARCHAR(32) NOT NULL,
+ status VARCHAR(16) NOT NULL,
+ message VARCHAR(255) DEFAULT '' NOT NULL,
+ attempted_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY order_id (amazon_order_id),
+ KEY status_time (status, attempted_at)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('reimb_cases')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ case_hash CHAR(40) NOT NULL,
+ kind VARCHAR(32) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ fnsku VARCHAR(20) DEFAULT '' NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ reference_id VARCHAR(64) DEFAULT '' NOT NULL,
+ event_date DATE DEFAULT NULL,
+ qty INT DEFAULT 0 NOT NULL,
+ est_amount DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ currency VARCHAR(3) DEFAULT '' NOT NULL,
+ status VARCHAR(16) DEFAULT 'open' NOT NULL,
+ amazon_case_id VARCHAR(32) DEFAULT '' NOT NULL,
+ evidence LONGTEXT,
+ notes TEXT,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY case_hash (case_hash),
+ KEY status (status, kind)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('ledger')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ row_hash CHAR(40) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ event_date DATE NOT NULL,
+ fnsku VARCHAR(20) DEFAULT '' NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ event_type VARCHAR(32) DEFAULT '' NOT NULL,
+ reference_id VARCHAR(64) DEFAULT '' NOT NULL,
+ qty INT DEFAULT 0 NOT NULL,
+ fulfillment_center VARCHAR(16) DEFAULT '' NOT NULL,
+ disposition VARCHAR(32) DEFAULT '' NOT NULL,
+ reason VARCHAR(16) DEFAULT '' NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY row_hash (row_hash),
+ KEY fnsku_date (fnsku, event_date),
+ KEY type_reason (event_type, reason)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('reimbursements')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ reimbursement_id VARCHAR(32) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ approval_date DATE NOT NULL,
+ case_id VARCHAR(32) DEFAULT '' NOT NULL,
+ amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL,
+ reason VARCHAR(64) DEFAULT '' NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ fnsku VARCHAR(20) DEFAULT '' NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ amount DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ currency VARCHAR(3) DEFAULT '' NOT NULL,
+ qty_cash INT DEFAULT 0 NOT NULL,
+ qty_inventory INT DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY reimb (reimbursement_id, fnsku),
+ KEY order_id (amazon_order_id),
+ KEY approval (approval_date)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('returns')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ row_hash CHAR(40) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ return_date DATE NOT NULL,
+ amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ fnsku VARCHAR(20) DEFAULT '' NOT NULL,
+ qty INT DEFAULT 0 NOT NULL,
+ fulfillment_center VARCHAR(16) DEFAULT '' NOT NULL,
+ disposition VARCHAR(32) DEFAULT '' NOT NULL,
+ reason VARCHAR(64) DEFAULT '' NOT NULL,
+ status VARCHAR(48) DEFAULT '' NOT NULL,
+ comments VARCHAR(500) DEFAULT '' NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY row_hash (row_hash),
+ KEY order_id (amazon_order_id),
+ KEY sku_date (sku, return_date)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('traffic_daily')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ report_date DATE NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ sku VARCHAR(64) DEFAULT '' NOT NULL,
+ sessions INT DEFAULT 0 NOT NULL,
+ page_views INT DEFAULT 0 NOT NULL,
+ buy_box_pct DECIMAL(6,2) DEFAULT 0 NOT NULL,
+ units INT DEFAULT 0 NOT NULL,
+ order_items INT DEFAULT 0 NOT NULL,
+ ordered_sales DECIMAL(14,4) DEFAULT 0 NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY day_asin (market, report_date, asin),
+ KEY report_date (report_date)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('listing_audits')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ sku VARCHAR(64) NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ score INT DEFAULT 0 NOT NULL,
+ status VARCHAR(32) DEFAULT '' NOT NULL,
+ content_hash CHAR(40) DEFAULT '' NOT NULL,
+ findings LONGTEXT,
+ snapshot LONGTEXT,
+ audited_at DATETIME NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY market_sku (market, sku)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('inventory_health')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ market VARCHAR(2) NOT NULL,
+ snapshot_date DATE NOT NULL,
+ sku VARCHAR(64) NOT NULL,
+ asin VARCHAR(20) DEFAULT '' NOT NULL,
+ available INT DEFAULT 0 NOT NULL,
+ age_0_90 INT DEFAULT 0 NOT NULL,
+ age_91_180 INT DEFAULT 0 NOT NULL,
+ age_181_270 INT DEFAULT 0 NOT NULL,
+ age_271_365 INT DEFAULT 0 NOT NULL,
+ age_365_plus INT DEFAULT 0 NOT NULL,
+ units_t30 INT DEFAULT 0 NOT NULL,
+ sell_through DECIMAL(8,2) DEFAULT NULL,
+ days_of_supply INT DEFAULT NULL,
+ est_storage_cost DECIMAL(14,4) DEFAULT NULL,
+ recommended_action VARCHAR(64) DEFAULT '' NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY snap_sku (market, snapshot_date, sku)
+ ) {$c};" );
+
+ dbDelta( "CREATE TABLE {$t('feedback')} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ row_hash CHAR(40) NOT NULL,
+ market VARCHAR(2) DEFAULT '' NOT NULL,
+ feedback_date DATE NOT NULL,
+ rating TINYINT DEFAULT 0 NOT NULL,
+ comments TEXT,
+ amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL,
+ is_demo TINYINT(1) DEFAULT 0 NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY row_hash (row_hash),
+ KEY rating_date (rating, feedback_date)
+ ) {$c};" );
+
+ update_option( self::OPTION, self::VERSION, false );
+ }
+
+ /** Drop every suite table (used by uninstall + tests). */
+ public static function drop_all(): void {
+ global $wpdb;
+ foreach ( self::TABLES as $short ) {
+ $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table( $short ) ); // phpcs:ignore WordPress.DB.PreparedSQL
+ }
+ delete_option( self::OPTION );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/SuiteSettings.php b/sg-commerce-pro/src/Suite/SuiteSettings.php
new file mode 100644
index 0000000..02f7a51
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/SuiteSettings.php
@@ -0,0 +1,152 @@
+ [type, default] */
+ public const SCHEMA = array(
+ // Sync
+ 'suite_sync_enabled' => array( 'bool', true ),
+ 'suite_backfill_days' => array( 'int', 60 ),
+ 'suite_order_items_per_run' => array( 'int', 25 ),
+ // Profit
+ 'suite_default_unit_cost' => array( 'float', 0.0 ),
+ 'suite_include_tax' => array( 'bool', false ),
+ // Restock
+ 'suite_lead_time_days' => array( 'int', 30 ),
+ 'suite_safety_days' => array( 'int', 14 ),
+ 'suite_target_cover_days' => array( 'int', 60 ),
+ 'suite_overstock_days' => array( 'int', 180 ),
+ // Ads
+ 'suite_ads_enabled' => array( 'bool', false ),
+ 'suite_ads_region' => array( 'string', 'na' ),
+ 'suite_ads_profile_id' => array( 'string', '' ),
+ 'suite_ads_target_acos' => array( 'float', 30.0 ),
+ 'suite_ads_lookback_days' => array( 'int', 14 ),
+ 'suite_ads_min_clicks' => array( 'int', 12 ),
+ 'suite_ads_max_step_pct' => array( 'float', 20.0 ),
+ 'suite_ads_min_bid' => array( 'float', 0.20 ),
+ 'suite_ads_max_bid' => array( 'float', 4.00 ),
+ 'suite_ads_raise_low_impr' => array( 'bool', true ),
+ 'suite_ads_neg_min_clicks' => array( 'int', 15 ),
+ 'suite_ads_harvest_min_orders'=> array( 'int', 2 ),
+ 'suite_ads_harvest_ad_group' => array( 'string', '' ),
+ 'suite_ads_harvest_campaign' => array( 'string', '' ),
+ 'suite_ads_auto_apply' => array( 'bool', false ),
+ // Reviews
+ 'suite_reviews_enabled' => array( 'bool', false ),
+ 'suite_reviews_delay_days' => array( 'int', 7 ),
+ 'suite_reviews_daily_cap' => array( 'int', 150 ),
+ 'suite_reviews_skip_refunded' => array( 'bool', true ),
+ // Monitor
+ 'suite_monitor_enabled' => array( 'bool', true ),
+ 'suite_monitor_price_drop_pct'=> array( 'float', 10.0 ),
+ 'suite_monitor_extra_asins' => array( 'string', '' ),
+ // Alerts
+ 'suite_alert_email' => array( 'string', '' ),
+ 'suite_alert_email_levels' => array( 'string', 'critical' ),
+ );
+
+ public function __construct( private SettingsRepository $repo ) {}
+
+ public function get( string $key ): mixed {
+ if ( ! isset( self::SCHEMA[ $key ] ) ) {
+ return null;
+ }
+ [ $type, $default ] = self::SCHEMA[ $key ];
+ return self::cast( $type, $this->repo->get( $key, $default ) );
+ }
+
+ public function int( string $key ): int { return (int) $this->get( $key ); }
+ public function float( string $key ): float { return (float) $this->get( $key ); }
+ public function bool( string $key ): bool { return (bool) $this->get( $key ); }
+ public function string( string $key ): string { return (string) $this->get( $key ); }
+
+ /** All public settings + secret presence flags (safe for REST). */
+ public function export(): array {
+ $out = array();
+ foreach ( array_keys( self::SCHEMA ) as $k ) {
+ $out[ $k ] = $this->get( $k );
+ }
+ foreach ( self::SECRETS as $s ) {
+ $out[ 'has_' . $s ] = $this->has_secret( $s );
+ }
+ $out['seller_id_set'] = '' !== (string) $this->repo->get( 'amazon_seller_id', '' );
+ return $out;
+ }
+
+ /**
+ * Persist from untrusted input. Unknown keys are ignored; values are
+ * cast to their declared type. Empty secrets keep the stored value.
+ *
+ * @return string[] keys saved
+ */
+ public function save( array $input ): array {
+ $values = array();
+ foreach ( self::SCHEMA as $k => [ $type ] ) {
+ if ( array_key_exists( $k, $input ) ) {
+ $v = self::cast( $type, $input[ $k ] );
+ if ( 'string' === $type ) {
+ $v = sanitize_text_field( (string) $v );
+ }
+ $values[ $k ] = $v;
+ }
+ }
+ if ( $values ) {
+ $this->repo->update_many( $values );
+ }
+ $saved = array_keys( $values );
+ foreach ( self::SECRETS as $s ) {
+ $v = isset( $input[ $s ] ) ? trim( (string) $input[ $s ] ) : '';
+ if ( '' !== $v ) {
+ $this->repo->set( $s, $v );
+ $saved[] = $s;
+ }
+ }
+ if ( isset( $input['amazon_seller_id'] ) && '' !== trim( (string) $input['amazon_seller_id'] ) ) {
+ $this->repo->set( 'amazon_seller_id', sanitize_text_field( (string) $input['amazon_seller_id'] ) );
+ $saved[] = 'amazon_seller_id';
+ }
+ return $saved;
+ }
+
+ public function secret( string $key ): string {
+ return (string) $this->repo->get( $key, '' );
+ }
+
+ public function has_secret( string $key ): bool {
+ return $this->repo->has( $key );
+ }
+
+ public function seller_id(): string {
+ return (string) $this->repo->get( 'amazon_seller_id', '' );
+ }
+
+ private static function cast( string $type, mixed $v ): mixed {
+ return match ( $type ) {
+ 'int' => (int) $v,
+ 'float' => (float) $v,
+ 'bool' => is_string( $v ) ? in_array( strtolower( $v ), array( '1', 'true', 'yes', 'on' ), true ) : (bool) $v,
+ default => is_scalar( $v ) ? (string) $v : '',
+ };
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Support/Db.php b/sg-commerce-pro/src/Suite/Support/Db.php
new file mode 100644
index 0000000..5abb4ac
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Support/Db.php
@@ -0,0 +1,136 @@
+replace().
+ *
+ * @package SevenGum\Commerce\Suite\Support
+ */
+
+declare( strict_types = 1 );
+
+namespace SevenGum\Commerce\Suite\Support;
+
+use SevenGum\Commerce\Suite\SuiteSchema;
+
+defined( 'ABSPATH' ) || exit;
+
+final class Db {
+
+ /**
+ * @param string $short Suite table short name.
+ * @param array $row Column => value.
+ * @param string[] $keep Columns NOT overwritten on conflict.
+ */
+ public static function upsert( string $short, array $row, array $keep = array() ): bool {
+ global $wpdb;
+ if ( ! $row ) {
+ return false;
+ }
+ $table = SuiteSchema::table( $short );
+ $cols = array_keys( $row );
+ $ph = array();
+ $values = array();
+ foreach ( $row as $v ) {
+ if ( null === $v ) {
+ $ph[] = 'NULL';
+ continue;
+ }
+ if ( is_int( $v ) || is_bool( $v ) ) {
+ $ph[] = '%d';
+ $values[] = (int) $v;
+ } elseif ( is_float( $v ) ) {
+ $ph[] = '%s';
+ $values[] = self::num( $v );
+ } else {
+ $ph[] = '%s';
+ $values[] = (string) $v;
+ }
+ }
+ $updates = array();
+ foreach ( $cols as $col ) {
+ if ( ! in_array( $col, $keep, true ) ) {
+ $updates[] = "`{$col}` = VALUES(`{$col}`)";
+ }
+ }
+ $sql = "INSERT INTO {$table} (`" . implode( '`,`', $cols ) . '`) VALUES (' . implode( ',', $ph ) . ')';
+ if ( $updates ) {
+ $sql .= ' ON DUPLICATE KEY UPDATE ' . implode( ', ', $updates );
+ }
+ $prepared = $values ? $wpdb->prepare( $sql, $values ) : $sql; // phpcs:ignore WordPress.DB.PreparedSQL
+ return false !== $wpdb->query( $prepared ); // phpcs:ignore WordPress.DB.PreparedSQL
+ }
+
+ /** Upsert many rows; returns count written. */
+ public static function upsert_many( string $short, array $rows, array $keep = array() ): int {
+ $n = 0;
+ foreach ( $rows as $row ) {
+ if ( self::upsert( $short, $row, $keep ) ) {
+ $n++;
+ }
+ }
+ return $n;
+ }
+
+ /** Prepared SELECT returning ARRAY_A rows. `{t:name}` expands to a suite table. */
+ public static function rows( string $sql, array $args = array() ): array {
+ global $wpdb;
+ $sql = self::expand( $sql );
+ $res = $wpdb->get_results( $args ? $wpdb->prepare( $sql, $args ) : $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL
+ return is_array( $res ) ? $res : array();
+ }
+
+ public static function row( string $sql, array $args = array() ): ?array {
+ $rows = self::rows( $sql, $args );
+ return $rows[0] ?? null;
+ }
+
+ public static function var( string $sql, array $args = array() ): mixed {
+ global $wpdb;
+ $sql = self::expand( $sql );
+ return $wpdb->get_var( $args ? $wpdb->prepare( $sql, $args ) : $sql ); // phpcs:ignore WordPress.DB.PreparedSQL
+ }
+
+ public static function exec( string $sql, array $args = array() ): int {
+ global $wpdb;
+ $sql = self::expand( $sql );
+ $res = $wpdb->query( $args ? $wpdb->prepare( $sql, $args ) : $sql ); // phpcs:ignore WordPress.DB.PreparedSQL
+ return is_int( $res ) ? $res : 0;
+ }
+
+ public static function insert( string $short, array $row ): int {
+ global $wpdb;
+ $wpdb->insert( SuiteSchema::table( $short ), $row );
+ return (int) $wpdb->insert_id;
+ }
+
+ public static function update( string $short, array $data, array $where ): int {
+ global $wpdb;
+ $res = $wpdb->update( SuiteSchema::table( $short ), $data, $where );
+ return is_int( $res ) ? $res : 0;
+ }
+
+ /** Replace `{t:orders}` placeholders with real table names. */
+ public static function expand( string $sql ): string {
+ return (string) preg_replace_callback(
+ '/\{t:([a-z_]+)\}/',
+ static fn( array $m ): string => SuiteSchema::table( $m[1] ),
+ $sql
+ );
+ }
+
+ /** Placeholder list for IN (...) clauses. */
+ public static function in( array $values, string $ph = '%s' ): string {
+ return implode( ',', array_fill( 0, max( 1, count( $values ) ), $ph ) );
+ }
+
+ public static function num( float $v ): string {
+ return number_format( $v, 4, '.', '' );
+ }
+
+ public static function now(): string {
+ return gmdate( 'Y-m-d H:i:s' );
+ }
+}
diff --git a/sg-commerce-pro/src/Suite/Support/SuiteAlerts.php b/sg-commerce-pro/src/Suite/Support/SuiteAlerts.php
new file mode 100644
index 0000000..6db3316
--- /dev/null
+++ b/sg-commerce-pro/src/Suite/Support/SuiteAlerts.php
@@ -0,0 +1,47 @@
+ 'America/Los_Angeles', 'CA' => 'America/Los_Angeles', 'MX' => 'America/Mexico_City',
+ 'BR' => 'America/Sao_Paulo', 'UK' => 'Europe/London', 'IE' => 'Europe/Dublin',
+ 'DE' => 'Europe/Berlin', 'FR' => 'Europe/Paris', 'IT' => 'Europe/Rome', 'ES' => 'Europe/Madrid',
+ 'NL' => 'Europe/Amsterdam', 'BE' => 'Europe/Brussels', 'SE' => 'Europe/Stockholm',
+ 'PL' => 'Europe/Warsaw', 'TR' => 'Europe/Istanbul', 'AE' => 'Asia/Dubai', 'SA' => 'Asia/Riyadh',
+ 'IN' => 'Asia/Kolkata', 'JP' => 'Asia/Tokyo', 'AU' => 'Australia/Sydney', 'SG' => 'Asia/Singapore',
+ );
+
+ public static function tz( string $market ): \DateTimeZone {
+ return new \DateTimeZone( self::TZ[ strtoupper( $market ) ] ?? 'UTC' );
+ }
+
+ /** ISO-8601 / MySQL UTC timestamp → 'Y-m-d' in marketplace time. */
+ public static function local_date( string $utc, string $market ): string {
+ try {
+ $dt = new \DateTimeImmutable( '' === $utc ? 'now' : $utc, new \DateTimeZone( 'UTC' ) );
+ } catch ( \Exception ) {
+ $dt = new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) );
+ }
+ return $dt->setTimezone( self::tz( $market ) )->format( 'Y-m-d' );
+ }
+
+ /** ISO-8601 → MySQL DATETIME in UTC. */
+ public static function to_mysql( ?string $iso ): ?string {
+ if ( null === $iso || '' === $iso ) {
+ return null;
+ }
+ try {
+ return ( new \DateTimeImmutable( $iso ) )->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+ } catch ( \Exception ) {
+ return null;
+ }
+ }
+
+ /** Today's date in the marketplace timezone. */
+ public static function today( string $market ): string {
+ return ( new \DateTimeImmutable( 'now', self::tz( $market ) ) )->format( 'Y-m-d' );
+ }
+
+ /** Shift a Y-m-d date by N days. */
+ public static function shift( string $ymd, int $days ): string {
+ return ( new \DateTimeImmutable( $ymd ) )->modify( sprintf( '%+d days', $days ) )->format( 'Y-m-d' );
+ }
+
+ /** Inclusive day count between two Y-m-d dates. */
+ public static function days_between( string $from, string $to ): int {
+ $a = new \DateTimeImmutable( $from );
+ $b = new \DateTimeImmutable( $to );
+ return (int) $a->diff( $b )->days + 1;
+ }
+
+ /** Validate Y-m-d; fall back to $default. */
+ public static function ymd( mixed $value, string $default ): string {
+ $v = is_string( $value ) ? $value : '';
+ return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $v ) ? $v : $default;
+ }
+}
diff --git a/sg-commerce-pro/src/Testing/Sandbox.php b/sg-commerce-pro/src/Testing/Sandbox.php
new file mode 100644
index 0000000..e69bb9f
--- /dev/null
+++ b/sg-commerce-pro/src/Testing/Sandbox.php
@@ -0,0 +1,162 @@
+ array(
+ array(
+ 'marketplace' => array(
+ 'id' => 'ATVPDKIKX0DER',
+ 'name' => 'Amazon.com',
+ 'countryCode' => 'US',
+ 'domainName' => 'www.amazon.com',
+ ),
+ 'participation' => array(
+ 'isParticipating' => true,
+ 'hasSuspendedListings' => false,
+ ),
+ ),
+ ),
+ );
+ }
+
+ // FBA inventory.
+ if ( str_contains( $path, '/fba/inventory/v1/summaries' ) ) {
+ return array(
+ 'payload' => array(
+ 'inventorySummaries' => self::mock_inventory_rows(),
+ ),
+ );
+ }
+
+ // Competitive pricing.
+ if ( str_contains( $path, '/products/pricing/v0/items/' ) && str_contains( $path, '/offers' ) ) {
+ return array(
+ 'payload' => self::mock_offers_response(),
+ );
+ }
+
+ // MCF preview.
+ if ( str_contains( $path, '/mfn/v0/fulfillmentOrders/preview' ) ) {
+ return array(
+ 'payload' => array(
+ 'fulfillmentPreviews' => array(
+ array(
+ 'isFulfillable' => true,
+ 'marketplaceId' => 'ATVPDKIKX0DER',
+ 'shippingSpeedCategory' => 'Standard',
+ 'estimatedFees' => array(
+ array( 'name' => 'FBAPerUnitFulfillmentFee',
+ 'amount' => array( 'value' => 3.22, 'currencyCode' => 'USD' ) ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ // MCF create.
+ if ( 'POST' === $method && str_contains( $path, '/mfn/v0/fulfillmentOrders' ) && ! str_contains( $path, 'preview' ) ) {
+ return array( 'payload' => array() );
+ }
+
+ // MCF get.
+ if ( 'GET' === $method && str_contains( $path, '/mfn/v0/fulfillmentOrders/' ) ) {
+ return array(
+ 'payload' => array(
+ 'fulfillmentOrder' => array(
+ 'fulfillmentOrderStatus' => 'COMPLETE',
+ ),
+ 'fulfillmentShipments' => array(
+ array(
+ 'shippingDate' => gmdate( 'c', time() - DAY_IN_SECONDS ),
+ 'estimatedArrivalDate' => gmdate( 'c', time() + DAY_IN_SECONDS ),
+ 'fulfillmentShipmentPackage' => array(
+ array( 'trackingNumber' => 'SANDBOX-TRK-' . mt_rand( 10000, 99999 ),
+ 'carrierCode' => 'UPS' ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ // Default empty.
+ return array( 'payload' => array() );
+ }
+
+ private static function mock_inventory_rows(): array {
+ $flavors = array( 'cookie', 'mint', 'cinnamon', 'ginger', 'blueberry', 'pineapple' );
+ $out = array();
+ foreach ( $flavors as $i => $f ) {
+ $out[] = array(
+ 'asin' => 'B0SG' . str_pad( (string) ( $i * 111 ), 6, '0', STR_PAD_LEFT ),
+ 'sellerSku' => 'SG-' . strtoupper( $f ) . '-01',
+ 'productName' => 'Seven Gum ' . ucfirst( $f ) . ' 30-pc',
+ 'totalQuantity' => 50 + ( $i * 10 ),
+ 'fulfillableQuantity' => 40 + ( $i * 8 ),
+ );
+ }
+ return $out;
+ }
+
+ private static function mock_offers_response(): array {
+ return array(
+ 'Summary' => array(
+ 'BuyBoxPrices' => array(
+ array(
+ 'ListingPrice' => array( 'Amount' => 4.99, 'CurrencyCode' => 'USD' ),
+ ),
+ ),
+ 'NumberOfOffers' => array( array( 'OfferCount' => 3 ) ),
+ ),
+ 'Offers' => array(
+ array(
+ 'SellerId' => 'A1SELLER',
+ 'IsBuyBoxWinner' => true,
+ 'ListingPrice' => array( 'Amount' => 4.99, 'CurrencyCode' => 'USD' ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/sg-commerce-pro/src/Widget/Shortcode.php b/sg-commerce-pro/src/Widget/Shortcode.php
new file mode 100644
index 0000000..727d64b
--- /dev/null
+++ b/sg-commerce-pro/src/Widget/Shortcode.php
@@ -0,0 +1,156 @@
+ '',
+ 'market' => 'auto', // Geotargeting module resolves 'auto' to visitor's market.
+ 'show' => 'image,name,desc,price,stock,cta',
+ 'style' => 'card',
+ ),
+ is_array( $atts ) ? $atts : array(),
+ 'sg_product'
+ );
+
+ $sku = sanitize_text_field( (string) $atts['sku'] );
+ $market = strtoupper( sanitize_text_field( (string) $atts['market'] ) );
+ if ( 'AUTO' === $market || '' === $market ) {
+ // Geotargeting filter didn't resolve (module disabled) — fall back to primary.
+ $market = $this->marketplaces->primary();
+ }
+
+ if ( '' === $sku ) {
+ return '[sg_product] requires a sku attribute.
';
+ }
+ if ( ! $this->marketplaces->exists( $market ) ) {
+ return sprintf(
+ 'Invalid market code: %s
',
+ esc_html( $market )
+ );
+ }
+
+ $data = $this->fetch( $sku, $market );
+ if ( null === $data ) {
+ return sprintf(
+ 'Product %s not found in our catalog.
',
+ esc_html( $sku )
+ );
+ }
+
+ return $this->render_html( $data, $atts );
+ }
+
+ private function fetch( string $sku, string $market ): ?array {
+ $key = $market . '|' . $sku;
+ if ( array_key_exists( $key, $this->cache ) ) {
+ return $this->cache[ $key ];
+ }
+ $row = $this->products->find_by_sku( $market, $sku );
+ if ( null === $row ) {
+ $this->cache[ $key ] = null;
+ return null;
+ }
+ $mp = $this->marketplaces->get( $market );
+ $lang = (string) ( $mp['language'] ?? 'en' );
+
+ // Prefer the localized AI description, fall back to English.
+ $description = $this->copywriter->find_description( (int) $row['id'], $lang )
+ ?? $this->copywriter->find_description( (int) $row['id'], 'en' )
+ ?? '';
+
+ $data = array(
+ 'id' => (int) $row['id'],
+ 'sku' => (string) $row['sku'],
+ 'asin' => (string) $row['asin'],
+ 'market' => (string) $row['market'],
+ 'name' => (string) $row['product_name'],
+ 'image_url' => (string) $row['image_url'],
+ 'stock' => (int) $row['fulfillable_qty'],
+ 'in_stock' => (int) $row['fulfillable_qty'] > 0,
+ 'price' => null !== $row['buybox_price'] ? (float) $row['buybox_price'] : null,
+ 'currency' => (string) $row['buybox_currency'] ?: ( $mp['currency'] ?? '' ),
+ 'description' => $description,
+ 'amazon_url' => '' !== $row['asin'] ? $this->marketplaces->product_url( (string) $row['asin'], $market ) : '',
+ );
+ $this->cache[ $key ] = $data;
+ return $data;
+ }
+
+ private function render_html( array $d, array $atts ): string {
+ $show = array_map( 'trim', explode( ',', (string) $atts['show'] ) );
+ $style = in_array( $atts['style'], array( 'default', 'compact', 'card' ), true ) ? $atts['style'] : 'card';
+
+ ob_start();
+ ?>
+
+ container;
+
+ $c->singleton( Shortcode::class, static fn( Container $c ) =>
+ new Shortcode(
+ $c->get( ProductRepository::class ),
+ $c->get( Marketplaces::class ),
+ $c->get( Copywriter::class )
+ )
+ );
+
+ add_shortcode( 'sg_product', function ( $atts ): string {
+ return $this->container->get( Shortcode::class )->render( $atts );
+ } );
+
+ add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) );
+ }
+
+ public function maybe_enqueue_assets(): void {
+ global $post;
+ // Only load on pages containing our shortcode.
+ if ( ! $post || ! has_shortcode( (string) $post->post_content, 'sg_product' ) ) {
+ return;
+ }
+ wp_enqueue_style(
+ 'sg-commerce-widget',
+ SG_COMMERCE_URL . 'assets/css/widget.css',
+ array(),
+ SG_COMMERCE_VERSION
+ );
+ wp_enqueue_script(
+ 'sg-commerce-widget',
+ SG_COMMERCE_URL . 'assets/js/widget.js',
+ array(),
+ SG_COMMERCE_VERSION,
+ true
+ );
+ wp_localize_script(
+ 'sg-commerce-widget',
+ 'SG_COMMERCE',
+ array( 'rest' => esc_url_raw( rest_url( 'sg-commerce/v1/' ) ) )
+ );
+ }
+}
diff --git a/sg-commerce-pro/tests/integration-smoke.php b/sg-commerce-pro/tests/integration-smoke.php
new file mode 100644
index 0000000..fd31119
--- /dev/null
+++ b/sg-commerce-pro/tests/integration-smoke.php
@@ -0,0 +1,161 @@
+ 'administrator', 'number' => 1 ) );
+wp_set_current_user( $admins[0]->ID );
+
+$fail = 0;
+$call = static function ( string $method, string $route, array $params = array() ) use ( &$fail ) {
+ $req = new WP_REST_Request( $method, '/sg-commerce/v1/suite' . $route );
+ if ( 'GET' === $method ) {
+ $req->set_query_params( $params );
+ } else {
+ $req->set_header( 'content-type', 'application/json' );
+ $req->set_body( wp_json_encode( $params ) );
+ }
+ $t = microtime( true );
+ $res = rest_do_request( $req );
+ $ms = (int) ( ( microtime( true ) - $t ) * 1000 );
+ $ok = $res->get_status() < 300;
+ if ( ! $ok ) {
+ $fail++;
+ }
+ printf( "%-4s %-34s %d %5dms %s\n", $method, $route, $res->get_status(), $ms, $ok ? '' : wp_json_encode( $res->get_data() ) );
+ return $res->get_data();
+};
+$assert = static function ( bool $cond, string $msg ) use ( &$fail ) {
+ if ( ! $cond ) {
+ $fail++;
+ echo "ASSERT FAILED: {$msg}\n";
+ }
+};
+
+$seed = $call( 'POST', '/demo', array() );
+echo 'seed counts: ' . wp_json_encode( $seed['counts'] ?? array() ) . "\n";
+$assert( ( $seed['counts']['orders'] ?? 0 ) > 500, 'demo orders seeded' );
+
+$o = $call( 'GET', '/overview' );
+$assert( isset( $o['periods']['mtd']['sales'] ), 'overview has MTD sales' );
+$assert( ( $o['totals_30']['sales'] ?? 0 ) > 0, '30-day sales > 0' );
+$assert( ( $o['totals_30']['amazon_fees'] ?? 0 ) < 0, 'fees negative' );
+$assert( ( $o['totals_30']['cogs'] ?? 0 ) < 0, 'cogs negative' );
+echo 'overview 30d: ' . wp_json_encode( array_intersect_key( $o['totals_30'], array_flip( array( 'orders', 'units', 'sales', 'refunds', 'amazon_fees', 'fees_estimated', 'account_fees', 'ads', 'cogs', 'expenses', 'net_profit', 'margin_pct', 'roi_pct', 'tacos_pct' ) ) ) ) . "\n";
+echo 'forecast: ' . wp_json_encode( $o['periods']['forecast'] ) . "\n";
+
+$p = $call( 'GET', '/profit', array( 'from' => gmdate( 'Y-m-d', strtotime( '-60 days' ) ) ) );
+$assert( count( $p['skus'] ?? array() ) >= 18, 'profit by sku rows' );
+$assert( count( $p['series'] ?? array() ) >= 60, 'profit daily series' );
+$assert( ! empty( $p['fee_breakdown'] ), 'fee breakdown' );
+
+$ord = $call( 'GET', '/orders' );
+$assert( count( $ord['orders'] ?? array() ) > 50, 'orders list' );
+$call( 'GET', '/orders', array( 'q' => 'SPEARMINT' ) );
+
+$c = $call( 'GET', '/costs' );
+$assert( count( $c['rows'] ?? array() ) >= 19, 'costs rows' );
+$call( 'POST', '/costs', array( 'rows' => array( array( 'sku' => 'DEMO-P1-LEMON', 'unit_cost' => 1.2, 'inbound_per_unit' => 0.1, 'lead_time_days' => 40 ) ) ) );
+$e = $call( 'POST', '/expenses', array( 'label' => 'Test expense', 'amount' => 10, 'recurrence' => 'once' ) );
+$call( 'GET', '/expenses' );
+$call( 'DELETE', '/expenses/' . (int) ( $e['id'] ?? 0 ) );
+
+$r = $call( 'GET', '/restock' );
+$assert( ( $r['summary']['skus'] ?? 0 ) >= 19, 'restock skus' );
+$statuses = array_count_values( array_column( $r['rows'] ?? array(), 'status' ) );
+echo 'restock statuses: ' . wp_json_encode( $statuses ) . "\n";
+$assert( isset( $statuses['out_of_stock'] ), 'restock has OOS sku' );
+$call( 'GET', '/inventory-health' );
+
+$a = $call( 'GET', '/ads/overview' );
+$assert( ( $a['totals']['cost'] ?? 0 ) > 0, 'ads spend' );
+$call( 'GET', '/ads/campaigns' );
+$call( 'GET', '/ads/targets' );
+$call( 'GET', '/ads/search-terms' );
+$plan = $call( 'POST', '/ads/optimize' );
+echo 'ppc plan: ' . wp_json_encode( $plan ) . "\n";
+$assert( ( $plan['bids'] ?? 0 ) + ( $plan['terms'] ?? 0 ) > 0, 'optimizer produced actions' );
+$acts = $call( 'GET', '/ads/actions' );
+$kinds = array_count_values( array_column( $acts['planned'] ?? array(), 'kind' ) );
+echo 'planned kinds: ' . wp_json_encode( $kinds ) . "\n";
+$ids = array_slice( array_map( 'intval', array_column( array_filter( $acts['planned'], static fn( $x ) => 'bid' === $x['kind'] ), 'id' ) ), 0, 2 );
+$ap = $call( 'POST', '/ads/apply', array( 'ids' => $ids ) );
+$assert( ( $ap['applied'] ?? 0 ) === count( $ids ), 'demo actions applied' );
+
+$k = $call( 'GET', '/keywords' );
+$assert( count( $k['rows'] ?? array() ) >= 10, 'tracked keywords' );
+$call( 'POST', '/keywords', array( 'asin' => 'B0DEMO0001', 'keywords' => "test keyword one\ntest keyword two" ) );
+$call( 'GET', '/keywords/queries', array( 'asin' => 'B0DEMO0001' ) );
+$call( 'POST', '/keywords/research', array( 'asins' => array( 'B0DEMO0001' ) ) );
+
+$l = $call( 'GET', '/listings' );
+$assert( count( $l['rows'] ?? array() ) >= 19, 'listing audits' );
+echo 'listing scores: ' . implode( ',', array_column( $l['rows'], 'score' ) ) . "\n";
+
+$m = $call( 'GET', '/monitor' );
+$assert( count( $m['hijacks'] ?? array() ) >= 1, 'hijacker detected in demo' );
+$call( 'GET', '/monitor/history', array( 'asin' => 'B0DEMO0006' ) );
+
+$rv = $call( 'GET', '/reviews' );
+echo 'reviews: ' . wp_json_encode( $rv['by_status'] ?? array() ) . "\n";
+
+$rb = $call( 'GET', '/reimbursements' );
+$kinds = array_count_values( array_column( $rb['rows'] ?? array(), 'kind' ) );
+echo 'reimbursement cases: ' . wp_json_encode( $kinds ) . ' value=' . array_sum( array_column( $rb['rows'], 'est_amount' ) ) . "\n";
+$assert( count( $rb['rows'] ?? array() ) > 0, 'reimbursement cases found' );
+if ( ! empty( $rb['rows'][0]['id'] ) ) {
+ $call( 'POST', '/reimbursements/' . (int) $rb['rows'][0]['id'], array( 'status' => 'filed', 'amazon_case_id' => '1234567890' ) );
+}
+
+$calc = $call( 'POST', '/research/calc', array( 'price' => 16.99, 'unit_cost' => 4.2, 'inbound' => 0.5, 'referral_pct' => 15, 'fba_fee' => 4.02, 'tacos_pct' => 10, 'return_pct' => 2 ) );
+echo 'calc: ' . wp_json_encode( $calc ) . "\n";
+$assert( abs( ( $calc['profit_per_unit'] ?? 0 ) - 3.5 ) < 1.5, 'calc profit sane' );
+
+$call( 'GET', '/traffic' );
+$call( 'GET', '/returns' );
+$call( 'GET', '/feedback' );
+$call( 'GET', '/alerts' );
+$call( 'GET', '/reports' );
+$s = $call( 'GET', '/settings' );
+$assert( ! isset( $s['settings']['ads_client_secret'] ), 'no secrets in settings payload' );
+$call( 'POST', '/settings', array( 'suite_ads_target_acos' => '25', 'ads_client_secret' => 'shh-secret-value' ) );
+$s2 = $call( 'GET', '/settings' );
+$assert( true === ( $s2['settings']['has_ads_client_secret'] ?? null ), 'secret stored flag' );
+$assert( ! str_contains( wp_json_encode( $s2 ), 'shh-secret-value' ), 'secret never echoed' );
+$raw = get_option( 'sg_commerce_secret_ads_client_secret' );
+$assert( is_string( $raw ) && ! str_contains( $raw, 'shh-secret-value' ), 'secret encrypted at rest' );
+
+// Permission check: anonymous must be rejected.
+wp_set_current_user( 0 );
+$res = rest_do_request( new WP_REST_Request( 'GET', '/sg-commerce/v1/suite/overview' ) );
+$assert( in_array( $res->get_status(), array( 401, 403 ), true ), 'anonymous rejected' );
+wp_set_current_user( $admins[0]->ID );
+
+// Remove the keywords this test added (they are not demo-flagged).
+\SevenGum\Commerce\Suite\Support\Db::exec( 'DELETE FROM {t:keywords} WHERE keyword LIKE %s', array( 'test keyword%' ) );
+$al = $call( 'GET', '/alerts' );
+$assert( count( $al['rows'] ?? array() ) >= 5, 'demo alerts seeded' );
+
+$call( 'DELETE', '/demo' );
+global $wpdb;
+$left = 0;
+foreach ( \SevenGum\Commerce\Suite\SuiteSchema::TABLES as $t ) {
+ if ( in_array( $t, array( 'reports', 'sku_costs' ), true ) ) {
+ continue;
+ }
+ $left += (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . \SevenGum\Commerce\Suite\SuiteSchema::table( $t ) . ' WHERE is_demo = 1' );
+}
+$left += (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_products WHERE sku LIKE 'DEMO-%'" );
+$left += (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_alerts WHERE context LIKE '%\"demo\":true%'" );
+$assert( 0 === $left, "demo fully cleared (left={$left})" );
+
+echo $fail ? "\nFAILED: {$fail}\n" : "\nALL OK\n";
diff --git a/sg-commerce-pro/tests/unit.php b/sg-commerce-pro/tests/unit.php
new file mode 100644
index 0000000..a3cf6eb
--- /dev/null
+++ b/sg-commerce-pro/tests/unit.php
@@ -0,0 +1,266 @@
+ $c, 'CurrencyAmount' => $v );
+}
+
+/* -------------------------------------------------------------------- */
+echo "FinancialEventFlattener\n";
+$events = array(
+ 'ShipmentEventList' => array( array(
+ 'AmazonOrderId' => '111-1', 'PostedDate' => '2026-09-10T12:00:00Z', 'MarketplaceName' => 'Amazon.com',
+ 'ShipmentItemList' => array( array(
+ 'SellerSKU' => 'SKU-A', 'QuantityShipped' => 2,
+ 'ItemChargeList' => array(
+ array( 'ChargeType' => 'Principal', 'ChargeAmount' => money( 33.98 ) ),
+ array( 'ChargeType' => 'Tax', 'ChargeAmount' => money( 2.46 ) ),
+ ),
+ 'ItemFeeList' => array(
+ array( 'FeeType' => 'Commission', 'FeeAmount' => money( -5.10 ) ),
+ array( 'FeeType' => 'FBAPerUnitFulfillmentFee', 'FeeAmount' => money( -8.04 ) ),
+ ),
+ 'PromotionList' => array( array( 'PromotionType' => 'PromotionMetaDataDefinitionValue', 'PromotionAmount' => money( -3.40 ) ) ),
+ 'ItemTaxWithheldList' => array( array( 'TaxesWithheld' => array( array( 'ChargeType' => 'MarketplaceFacilitatorTax-Principal', 'ChargeAmount' => money( -2.46 ) ) ) ) ),
+ ) ),
+ ) ),
+ 'RefundEventList' => array( array(
+ 'AmazonOrderId' => '111-0', 'PostedDate' => '2026-09-11T08:00:00Z',
+ 'ShipmentItemAdjustmentList' => array( array(
+ 'SellerSKU' => 'SKU-A', 'QuantityShipped' => 1,
+ 'ItemChargeAdjustmentList' => array( array( 'ChargeType' => 'Principal', 'ChargeAmount' => money( -16.99 ) ) ),
+ 'ItemFeeAdjustmentList' => array(
+ array( 'FeeType' => 'Commission', 'FeeAmount' => money( 2.04 ) ),
+ array( 'FeeType' => 'RefundCommission', 'FeeAmount' => money( -0.51 ) ),
+ ),
+ ) ),
+ ) ),
+ 'ServiceFeeEventList' => array( array( 'FeeList' => array( array( 'FeeType' => 'FBAStorageFee', 'FeeAmount' => money( -42.17 ) ) ) ) ),
+ 'AdjustmentEventList' => array( array(
+ 'AdjustmentType' => 'WAREHOUSE_DAMAGE', 'PostedDate' => '2026-09-12T00:00:00Z',
+ 'AdjustmentItemList' => array( array( 'SellerSKU' => 'SKU-A', 'Quantity' => 2, 'TotalAmount' => money( 19.80 ) ) ),
+ ) ),
+ 'ProductAdsPaymentEventList' => array( array( 'postedDate' => '2026-09-13T00:00:00Z', 'transactionType' => 'Charge', 'invoiceId' => 'INV1', 'transactionValue' => money( 250.00 ) ) ),
+);
+$lines = FinancialEventFlattener::flatten( $events, '2026-09-30T00:00:00Z' );
+$by = array();
+foreach ( $lines as $l ) {
+ $by[ $l['category'] ] = ( $by[ $l['category'] ] ?? 0 ) + $l['amount'];
+}
+check( 'revenue = principal only', near( $by['revenue'] ?? 0, 33.98 ) );
+check( 'tax nets to zero (collected + withheld)', near( $by['tax'] ?? 1, 0.0 ) );
+check( 'fees include item, refund and storage fees', near( $by['fee'] ?? 0, -5.10 - 8.04 + 2.04 - 0.51 - 42.17 ) );
+check( 'promotion captured', near( $by['promo'] ?? 0, -3.40 ) );
+check( 'refund principal negative', near( $by['refund'] ?? 0, -16.99 ) );
+check( 'warehouse damage is a reimbursement', near( $by['reimbursement'] ?? 0, 19.80 ) );
+check( 'ads invoice negative', near( $by['ads'] ?? 0, -250.0 ) );
+$units = array_sum( array_map( static fn( $l ) => 'revenue' === $l['category'] ? $l['qty'] : 0, $lines ) );
+check( 'units attributed once on Principal', 2 === $units, (string) $units );
+$refund_units = array_sum( array_map( static fn( $l ) => 'refund' === $l['category'] ? $l['qty'] : 0, $lines ) );
+check( 'refund units negative', -1 === $refund_units );
+$storage = array_values( array_filter( $lines, static fn( $l ) => 'FBAStorageFee' === $l['charge_type'] ) );
+check( 'ServiceFeeEvent without PostedDate uses window end', '2026-09-30T00:00:00Z' === ( $storage[0]['posted'] ?? '' ) );
+check( 'payout identity: Σ amount == net', near( array_sum( array_column( $lines, 'amount' ) ), 33.98 + 2.46 - 2.46 - 5.10 - 8.04 - 3.40 - 16.99 + 2.04 - 0.51 - 42.17 + 19.80 - 250.0 ) );
+check( 'hash is deterministic', FinancialEventFlattener::hash( $lines[0], 0 ) === FinancialEventFlattener::hash( $lines[0], 0 ) );
+check( 'hash ordinal disambiguates duplicates', FinancialEventFlattener::hash( $lines[0], 0 ) !== FinancialEventFlattener::hash( $lines[0], 1 ) );
+check( 'adjustment categories', 'fee' === FinancialEventFlattener::adjustment_category( 'FBAStorageFeeAdjustment' ) && 'adjustment' === FinancialEventFlattener::adjustment_category( 'ReserveDebit' ) );
+
+/* -------------------------------------------------------------------- */
+echo "ReportParser / ReportIngestor::date\n";
+$tsv = "amazon-order-id\tsku\tquantity\titem-price\n111-1\tSKU-A\t2\t33.98\r\n111-2\tSKU-B\t1\t\"4.99\"\n";
+$rows = ReportParser::tsv( $tsv );
+check( 'tsv rows', 2 === count( $rows ) );
+check( 'tsv header normalised', isset( $rows[0]['amazon-order-id'] ) && '33.98' === $rows[0]['item-price'] );
+check( 'tsv strips quotes', '4.99' === $rows[1]['item-price'] );
+check( 'gzip transparently decoded', 2 === count( ReportParser::tsv( gzencode( $tsv ) ) ) );
+check( 'Windows-1252 converted to UTF-8', 'Café' === ReportParser::tsv( "name\n" . "Caf\xE9" )[0]['name'] );
+check( 'BOM stripped from header', isset( ReportParser::tsv( "\xEF\xBB\xBFDate\tFNSKU\n1\t2" )[0]['date'] ) );
+check( 'header with spaces → hyphens', isset( ReportParser::tsv( "Event Type\tReference ID\nA\tB" )[0]['event-type'] ) );
+check( 'money US', near( ReportParser::money( '$1,234.50' ), 1234.50 ) );
+check( 'money EU', near( ReportParser::money( '1.234,50' ), 1234.50 ) );
+check( 'money EU decimal', near( ReportParser::money( '4,99' ), 4.99 ) );
+check( 'money negative', near( ReportParser::money( '-12.30' ), -12.30 ) );
+check( 'json gzip', 1 === ReportParser::json( gzencode( '[{"a":1}]' ) )[0]['a'] );
+check( 'date ISO', '2026-09-01' === ReportIngestor::date( '2026-09-01T07:00:00+00:00' ) );
+check( 'date US m/d/Y', '2026-09-05' === ReportIngestor::date( '09/05/2026' ) );
+check( 'date empty', null === ReportIngestor::date( '' ) );
+
+/* -------------------------------------------------------------------- */
+echo "BidOptimizer\n";
+$opt = new BidOptimizer( array( 'target_acos' => 30, 'min_clicks' => 12, 'max_step_pct' => 20, 'min_bid' => 0.2, 'max_bid' => 4, 'raise_low_impr' => true, 'neg_min_clicks' => 15, 'harvest_min_orders' => 2, 'avg_order_value' => 17 ) );
+$base = array( 'kind' => 'keyword', 'campaign_id' => 'C1', 'ad_group_id' => 'AG1', 'state' => 'ENABLED' );
+$plan = $opt->bids( array(
+ $base + array( 'target_id' => 'hi-acos', 'label' => 'a', 'bid' => 1.50, 'impressions' => 5000, 'clicks' => 100, 'cost' => 120, 'sales' => 200, 'orders' => 10 ),
+ $base + array( 'target_id' => 'lo-acos', 'label' => 'b', 'bid' => 0.50, 'impressions' => 5000, 'clicks' => 50, 'cost' => 25, 'sales' => 300, 'orders' => 15 ),
+ $base + array( 'target_id' => 'no-orders', 'label' => 'c', 'bid' => 1.00, 'impressions' => 3000, 'clicks' => 14, 'cost' => 9, 'sales' => 0, 'orders' => 0 ),
+ $base + array( 'target_id' => 'bleeder', 'label' => 'd', 'bid' => 1.00, 'impressions' => 3000, 'clicks' => 40, 'cost' => 40, 'sales' => 0, 'orders' => 0 ),
+ $base + array( 'target_id' => 'invisible', 'label' => 'e', 'bid' => 0.40, 'impressions' => 20, 'clicks' => 0, 'cost' => 0, 'sales' => 0, 'orders' => 0 ),
+ $base + array( 'target_id' => 'on-target', 'label' => 'f', 'bid' => 0.60, 'impressions' => 5000, 'clicks' => 100, 'cost' => 60, 'sales' => 200, 'orders' => 10 ),
+ array_merge( $base, array( 'state' => 'PAUSED' ) ) + array( 'target_id' => 'paused', 'label' => 'g', 'bid' => 1.0, 'impressions' => 0, 'clicks' => 50, 'cost' => 50, 'sales' => 0, 'orders' => 0 ),
+) );
+$a = array();
+foreach ( $plan as $p ) {
+ $a[ $p['entity_id'] ] = $p;
+}
+check( 'high ACoS → bid cut, capped at -20%', isset( $a['hi-acos'] ) && near( (float) $a['hi-acos']['new_value'], 1.20 ) );
+check( 'low ACoS → bid raised, capped at +20%', isset( $a['lo-acos'] ) && near( (float) $a['lo-acos']['new_value'], 0.60 ) );
+check( 'no orders after min clicks → cut', isset( $a['no-orders'] ) && near( (float) $a['no-orders']['new_value'], 0.80 ) );
+check( 'heavy bleeder (≥2× break-even CPA) → pause', 'pause' === ( $a['bleeder']['kind'] ?? '' ) );
+check( 'low impressions → raise half step', isset( $a['invisible'] ) && near( (float) $a['invisible']['new_value'], 0.44 ) );
+check( 'on-target keyword left alone', ! isset( $a['on-target'] ) );
+check( 'paused keywords ignored', ! isset( $a['paused'] ) );
+$clamp = ( new BidOptimizer( array( 'target_acos' => 90, 'max_step_pct' => 50, 'max_bid' => 1.0 ) ) )->bids( array(
+ $base + array( 'target_id' => 'x', 'label' => 'x', 'bid' => 0.9, 'impressions' => 900, 'clicks' => 10, 'cost' => 9, 'sales' => 100, 'orders' => 5 ),
+) );
+check( 'bids clamp to max_bid', near( (float) ( $clamp[0]['new_value'] ?? 0 ), 1.0 ) );
+
+$terms = $opt->search_terms( array(
+ array( 'search_term' => 'Sugar Free Gum Bulk', 'campaign_id' => 'AUTO', 'ad_group_id' => 'AGA', 'keyword_text' => 'close-match', 'match_type' => 'AUTO', 'impressions' => 900, 'clicks' => 20, 'cost' => 15, 'sales' => 85, 'orders' => 5 ),
+ array( 'search_term' => 'nicotine gum', 'campaign_id' => 'BROAD', 'ad_group_id' => 'AGB', 'keyword_text' => 'gum', 'match_type' => 'BROAD', 'impressions' => 900, 'clicks' => 22, 'cost' => 18, 'sales' => 0, 'orders' => 0 ),
+ array( 'search_term' => 'spearmint gum', 'campaign_id' => 'AUTO', 'ad_group_id' => 'AGA', 'keyword_text' => 'close-match', 'match_type' => 'AUTO', 'impressions' => 900, 'clicks' => 20, 'cost' => 10, 'sales' => 80, 'orders' => 4 ),
+ array( 'search_term' => 'b0abcdefgh', 'campaign_id' => 'AUTO', 'ad_group_id' => 'AGA', 'keyword_text' => 'substitutes', 'match_type' => 'AUTO', 'impressions' => 900, 'clicks' => 20, 'cost' => 10, 'sales' => 80, 'orders' => 4 ),
+ array( 'search_term' => 'bubble gum machine', 'campaign_id' => 'BROAD', 'ad_group_id' => 'AGB', 'keyword_text' => 'gum', 'match_type' => 'BROAD', 'impressions' => 900, 'clicks' => 30, 'cost' => 20, 'sales' => 0, 'orders' => 0 ),
+), array( 'spearmint gum' => true ), array( 'AGB|bubble gum machine' => true ), 'EXACT-C', 'EXACT-AG' );
+$k = array();
+foreach ( $terms as $t ) {
+ $k[ $t['entity_id'] ] = $t;
+}
+check( 'converting term harvested to exact destination', 'harvest' === ( $k['sugar free gum bulk']['kind'] ?? '' ) && 'EXACT-AG' === $k['sugar free gum bulk']['ad_group_id'] );
+check( 'harvest bid = CPC × 1.1', near( (float) $k['sugar free gum bulk']['payload']['bid'], 0.83 ) );
+check( 'non-converting term negated in its source ad group', 'negative' === ( $k['nicotine gum']['kind'] ?? '' ) && 'AGB' === $k['nicotine gum']['ad_group_id'] );
+check( 'already-exact keyword not re-harvested', ! isset( $k['spearmint gum'] ) );
+check( 'ASIN term → product-target harvest', 'harvest_asin' === ( $k['b0abcdefgh']['kind'] ?? '' ) );
+check( 'existing negative not duplicated', ! isset( $k['bubble gum machine'] ) );
+
+$budgets = $opt->budgets( array(
+ array( 'campaign_id' => 'capped-good', 'name' => 'A', 'state' => 'ENABLED', 'daily_budget' => 20, 'days' => 7, 'cost' => 135, 'sales' => 700 ),
+ array( 'campaign_id' => 'capped-bad', 'name' => 'B', 'state' => 'ENABLED', 'daily_budget' => 20, 'days' => 7, 'cost' => 138, 'sales' => 200 ),
+ array( 'campaign_id' => 'fine', 'name' => 'C', 'state' => 'ENABLED', 'daily_budget' => 50, 'days' => 7, 'cost' => 100, 'sales' => 600 ),
+) );
+$b = array_column( $budgets, 'new_value', 'entity_id' );
+check( 'profitable capped campaign → +20% budget', near( (float) ( $b['capped-good'] ?? 0 ), 24.0 ) );
+check( 'unprofitable capped campaign → -15% budget', near( (float) ( $b['capped-bad'] ?? 0 ), 17.0 ) );
+check( 'uncapped campaign untouched', ! isset( $b['fine'] ) );
+
+/* -------------------------------------------------------------------- */
+echo "RestockPlanner::plan\n";
+$in = static fn( array $o ) => $o + array( 'sku' => 'S', 'asin' => 'A', 'title' => 'T', 'fulfillable' => 0, 'reserved' => 0, 'inbound' => 0, 'units_7' => 0, 'units_30' => 0, 'units_90' => 0, 'oos_days_30' => 0, 'lead_time' => 30, 'moq' => 0, 'case_pack' => 0, 'unit_cost' => 2.0, 'price' => 10.0 );
+$plan = RestockPlanner::plan( array(
+ $in( array( 'sku' => 'steady', 'fulfillable' => 300, 'units_7' => 70, 'units_30' => 300, 'units_90' => 900, 'case_pack' => 24, 'moq' => 100 ) ),
+ $in( array( 'sku' => 'oos', 'fulfillable' => 0, 'units_7' => 0, 'units_30' => 150, 'units_90' => 600, 'oos_days_30' => 15 ) ),
+ $in( array( 'sku' => 'fat', 'fulfillable' => 5000, 'units_7' => 14, 'units_30' => 60, 'units_90' => 180 ) ),
+ $in( array( 'sku' => 'gap', 'fulfillable' => 20, 'inbound' => 2000, 'units_7' => 70, 'units_30' => 300, 'units_90' => 900 ) ),
+ $in( array( 'sku' => 'dead', 'fulfillable' => 40 ) ),
+), '2026-10-01', array( 'safety_days' => 14, 'target_cover' => 60, 'overstock_days' => 180 ) );
+$r = array_column( $plan['rows'], null, 'sku' );
+check( 'velocity = 0.5·v7 + 0.3·v30 + 0.2·v90', near( $r['steady']['velocity'], 10.0 ) );
+check( 'days of cover', 30 === $r['steady']['days_of_cover'] );
+check( 'steady SKU below reorder point → critical', 'critical' === $r['steady']['status'], $r['steady']['status'] );
+// need = 10 × (30 + 60) − 300 = 600 → case pack 24 → 600
+check( 'order qty rounded up to case pack', 600 === $r['steady']['order_qty'], (string) $r['steady']['order_qty'] );
+check( 'stock-out date', '2026-10-31' === $r['steady']['stockout_date'] );
+// v30 = 150 / (30 − 15 OOS days) = 10 → 0.3·10 + 0.2·6.67 = 4.33 (vs 2.83 if OOS days were counted).
+check( 'OOS SKU flagged + velocity excludes stock-out days', 'out_of_stock' === $r['oos']['status'] && near( $r['oos']['velocity'], 4.333 ) );
+check( 'lost sales per day for OOS', $r['oos']['lost_sales_day'] > 40 );
+check( 'overstock detected', 'overstock' === $r['fat']['status'] && 0 === $r['fat']['order_qty'] );
+check( 'inbound gap → stockout_risk', 'stockout_risk' === $r['gap']['status'], $r['gap']['status'] );
+check( 'no sales → no_sales, no order', 'no_sales' === $r['dead']['status'] && 0 === $r['dead']['order_qty'] );
+check( 'urgency ordering', 'oos' === $plan['rows'][0]['sku'] );
+check( 'summary PO value', near( $plan['summary']['order_value'], array_sum( array_column( $plan['rows'], 'order_value' ) ) ) );
+
+/* -------------------------------------------------------------------- */
+echo "ListingAuditor::score\n";
+$good = array(
+ 'asin' => 'B0X', 'brand' => '7 SEVEN', 'product_type' => 'GUM', 'status' => array( 'BUYABLE', 'DISCOVERABLE' ), 'issues' => array(),
+ 'title' => '7 SEVEN Sugar-Free Gum, Spearmint, 50 Pieces, Xylitol Sweetened, Aspartame-Free, Long-Lasting Flavor, Vegan',
+ 'bullets' => array_fill( 0, 5, 'LONG-LASTING FLAVOR — Our slow-release flavor beads keep every piece fresh for up to 45 minutes, so one piece covers your whole commute or meeting without fading.' ),
+ 'description' => str_repeat( 'Clean, confident chewing gum with xylitol sweetness and long-lasting flavor. ', 18 ),
+ 'backend' => 'sugarless chewing gum xylitol mints breath freshener keto vegan office travel bottle aspartame free dental gum fresh breath dry mouth',
+ 'images' => array_fill( 0, 7, 'x.jpg' ),
+);
+$s = ListingAuditor::score( $good, array( 'spearmint gum', 'xylitol gum' ) );
+check( 'good listing scores ≥ 90', $s['score'] >= 90, (string) $s['score'] );
+check( 'keyword coverage 100%', 100 === $s['keyword_coverage']['pct'] );
+$bad = array_merge( $good, array(
+ 'title' => 'Gum Gum Gum!! Best Seller Sugar Free GUM GUM', 'bullets' => array( 'Short 😀' ), 'description' => '',
+ 'backend' => str_repeat( 'gum ', 70 ) . 'B0ABCDEFGH', 'images' => array( 'x.jpg' ),
+ 'status' => array( 'DISCOVERABLE' ), 'issues' => array( array( 'code' => '90220', 'severity' => 'ERROR', 'message' => 'missing weight' ) ),
+) );
+$s2 = ListingAuditor::score( $bad, array( 'spearmint gum' ) );
+$msgs = implode( ' | ', array_column( $s2['findings'], 'message' ) );
+check( 'bad listing scores < 40', $s2['score'] < 40, (string) $s2['score'] );
+check( 'flags repeated words', str_contains( $msgs, 'more than twice' ) );
+check( 'flags banned characters', str_contains( $msgs, 'decorative characters' ) );
+check( 'flags promotional claims', str_contains( $msgs, 'best seller' ) );
+check( 'flags emoji in bullets', str_contains( $msgs, 'emoji' ) );
+check( 'flags backend > 249 bytes', str_contains( $msgs, '249 bytes' ) );
+check( 'flags ASIN in backend', str_contains( $msgs, 'ASINs' ) );
+check( 'flags suppression', 'suppressed' === $s2['status'] );
+check( 'grade F', 'F' === $s2['grade'] );
+check( 'claims ignore substrings (wholesale ≠ sale)', array() === ListingAuditor::claims( 'wholesale pack' ) );
+check( 'extract() maps Listings API attributes', 'Hello' === ListingAuditor::extract( array(
+ 'summaries' => array( array( 'asin' => 'B0X', 'status' => array( 'BUYABLE' ) ) ),
+ 'attributes' => array( 'item_name' => array( array( 'value' => 'Hello' ) ), 'bullet_point' => array( array( 'value' => 'b1' ), array( 'value' => 'b2' ) ),
+ 'other_product_image_locator_1' => array( array( 'media_location' => 'i1' ) ), 'main_product_image_locator' => array( array( 'media_location' => 'm' ) ) ),
+) )['title'] );
+
+/* -------------------------------------------------------------------- */
+echo "ProductResearch\n";
+$c = ProductResearch::calc( array( 'price' => 20, 'unit_cost' => 4, 'inbound' => 1, 'referral_pct' => 15, 'fba_fee' => 5, 'tacos_pct' => 10, 'return_pct' => 0, 'cvr_pct' => 10 ) );
+// 20 − 3 (referral) − 5 (FBA) − 5 (COGS) − 2 (ads) = 5
+check( 'profit per unit', near( $c['profit_per_unit'], 5.0 ) );
+check( 'margin %', near( $c['margin_pct'], 25.0 ) );
+check( 'ROI %', near( $c['roi_pct'], 100.0 ) );
+check( 'break-even ACoS = pre-ad margin', near( $c['breakeven_acos'], 35.0 ) );
+check( 'max CPC = pre-ad profit × CVR', near( $c['max_cpc'], 0.70 ) );
+check( 'break-even price', near( $c['breakeven_price'], 10 / 0.75 ) );
+check( 'BSR estimate decreases with rank', ProductResearch::estimate_monthly_units( 100, 'grocery' ) > ProductResearch::estimate_monthly_units( 10000, 'grocery' ) );
+check( 'BSR group factor applied', ProductResearch::estimate_monthly_units( 1000, 'home' ) > ProductResearch::estimate_monthly_units( 1000, 'baby' ) );
+
+echo "\n{$pass} passed, {$fail} failed\n";
+exit( $fail ? 1 : 0 );
diff --git a/sg-commerce-pro/uninstall.php b/sg-commerce-pro/uninstall.php
new file mode 100644
index 0000000..a59db49
--- /dev/null
+++ b/sg-commerce-pro/uninstall.php
@@ -0,0 +1,123 @@
+prefix}sg_products",
+ "{$wpdb->prefix}sg_price_history",
+ "{$wpdb->prefix}sg_descriptions",
+ "{$wpdb->prefix}sg_competitors",
+ "{$wpdb->prefix}sg_rules",
+ "{$wpdb->prefix}sg_alerts",
+ "{$wpdb->prefix}sg_audit",
+ "{$wpdb->prefix}sg_mcf_orders",
+ "{$wpdb->prefix}sg_comparisons",
+ "{$wpdb->prefix}sg_injection_rules",
+ "{$wpdb->prefix}sg_settlements",
+ "{$wpdb->prefix}sg_inbound_plans",
+ "{$wpdb->prefix}sg_product_ean",
+ );
+ // v4.0 — every Amazon Seller Suite table shares the sg_suite_ prefix.
+ $suite_tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'sg_suite_' ) . '%' ) );
+ $tables = array_merge( $tables, (array) $suite_tables );
+ foreach ( $tables as $t ) {
+ $wpdb->query( "DROP TABLE IF EXISTS {$t}" );
+ }
+
+ $option_keys = array(
+ 'sg_commerce_settings',
+ 'sg_commerce_schema_version',
+ 'sg_commerce_installed_at',
+ 'sg_commerce_activated_at',
+ 'sg_commerce_default_rules_seeded',
+ 'sg_commerce_last_cron_sync',
+ );
+ foreach ( $option_keys as $key ) {
+ delete_option( $key );
+ }
+
+ // Encrypted secrets — one option each.
+ $secret_keys = array(
+ 'amazon_client_id',
+ 'amazon_client_secret',
+ 'amazon_refresh_token',
+ 'amazon_seller_id',
+ 'openai_api_key',
+ 'anthropic_api_key',
+ 'ollama_auth_token',
+ 'ads_client_id',
+ 'ads_client_secret',
+ 'ads_refresh_token',
+ );
+ foreach ( $secret_keys as $sk ) {
+ delete_option( 'sg_commerce_secret_' . $sk );
+ }
+
+ // Catch-all: any other core option added over time (carton templates, sandbox flag,
+ // notices, API-key flashes…) shares the sg_commerce_ prefix.
+ $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'sg_commerce_' ) . '%' ) );
+
+ // Suite options (cursors, job status, demo flag, schema version).
+ $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'sg_suite_' ) . '%' ) );
+ $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_sg_suite_' ) . '%', $wpdb->esc_like( '_transient_timeout_sg_suite_' ) . '%' ) );
+
+ // Transients.
+ $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_sg_commerce/%' OR option_name LIKE '_transient_timeout_sg_commerce/%'" );
+
+ // Cron.
+ $crons = array(
+ 'sg_commerce_hourly_sync',
+ 'sg_commerce_daily_analytics',
+ 'sg_commerce_repricer_tick',
+ 'sg_commerce_competitor_scan',
+ 'sg_commerce_ai_queue_process',
+ 'sg_commerce_mcf_refresh',
+ 'sg_commerce_pii_purge',
+ 'sg_commerce_sqs_poll',
+ 'sg_commerce_ai_describe',
+ 'sg_commerce_ai_translate',
+ 'sg_suite_tick',
+ 'sg_suite_hourly',
+ 'sg_suite_daily',
+ );
+ foreach ( $crons as $hook ) {
+ wp_clear_scheduled_hook( $hook );
+ }
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( '', array(), 'sg_commerce' );
+ }
+
+ // Log directory.
+ $uploads = wp_upload_dir( null, false );
+ if ( ! empty( $uploads['basedir'] ) ) {
+ $log_dir = trailingslashit( $uploads['basedir'] ) . 'sg-commerce-logs';
+ if ( is_dir( $log_dir ) ) {
+ array_map( 'unlink', glob( $log_dir . '/*' ) ?: array() );
+ @rmdir( $log_dir );
+ }
+ }
+}
+
+if ( is_multisite() ) {
+ $sites = get_sites( array( 'fields' => 'ids', 'number' => 0 ) );
+ foreach ( (array) $sites as $blog_id ) {
+ switch_to_blog( (int) $blog_id );
+ sg_commerce_uninstall_one_site();
+ restore_current_blog();
+ }
+} else {
+ sg_commerce_uninstall_one_site();
+}