Skip to content

Phase 3: Shopee Slice (Inbound) — Kickoff Plan

Status: Ready to start 📋 Predecessor: Phase 2 Kickoff (staging validated 2026-04-22 — see Phase 2 Findings) Exit criterion (from Project Plan): "A real Shopee order lands in Medusa via the connector."

This plan turns the Phase 3 objective — end-to-end Shopee connector — into a concrete module scaffold, data model, ingestion + status + escrow flows, failure path, and validation checklist. Inbound only: orders flow Shopee → Medusa. Outbound (push price / stock / shipment back to Shopee) is deferred to Phase 6 alongside Lazada so both connectors generalize the outbound pattern at once.


Goal

Ship src/modules/connectors/shopee/: a connector that listens to Shopee's webhooks (with hourly polling as backstop), persists every raw payload, normalizes orders against the merchant's existing SKU discipline, creates Medusa orders with correct line items / customer / shipping / sales-channel attribution, tracks order status updates through the full Shopee lifecycle, and pulls escrow income detail when orders complete. Failures publish to a dedicated Telegram channel and persist as exception rows for operator action.

When Phase 3 exits, the merchant's real Shopee orders flow into Medusa within minutes of being placed, with status changes mirrored and final settlement amounts captured for Phase 4 (DCA / costing) to consume.


Scope

In scope

  • src/modules/connectors/shopee/ connector module (service, SDK wrapper, normalizers, mappers, webhook endpoint, polling + reconciliation + escrow jobs).
  • src/modules/connectors/shared/ cross-connector primitives (ConnectorException model + alerting.ts Telegram publisher).
  • Four new data models: ShopeeRawEvent, ShopeeOrderSync, ShopeeEscrow, ConnectorException.
  • Inbound order ingestion: webhook → raw event → normalize → Medusa createOrderWorkflow.
  • Order status sync: Shopee push code 3 (order status update) → Medusa fulfill / cancel / complete workflows. Handler matrix covers all 10 valid OrderStatus values for the SG (GLOBAL) endpoint.
  • Escrow income capture: webhook-triggered + daily backstop, populates ShopeeEscrow rows for Phase 4 to consume.
  • Lost-push recovery (hourly via getLostPushMessage()) and daily reconciliation job (03:00 SGT).
  • Webhook signature verification (HMAC-SHA256, Shopee's Authorization header).
  • OAuth bootstrap script + persistent token storage on a Docker volume.
  • TCGChannelListing table (deferred from Phase 2) — defined and populated during inbound, but not used for inbound matching (SKU-based matching is sufficient). Outbound consumers come in Phase 6.
  • Failure path: ConnectorException row + Telegram alert via n8n webhook → dedicated #tcg-platform-alerts chat.
  • n8n workflow JSON for the alert formatter, exported to homelab/04_n8n_workflows/.
  • Tests: unit (normalizers, mappers, signature verification), integration (full webhook → order flow with mocked SDK), manual end-to-end (real test order on the merchant's Shopee shop).
  • tcg-platform/phase-3-findings.md — validation findings doc, written during the staging run.

Out of scope

Out of scope Home phase
Outbound stock decrement push to Shopee Phase 6
Outbound price update push to Shopee Phase 6
Outbound fulfillment / shipping label push Phase 6 or 7
Bulk listing import from Shopee Phase 5 (admin UI)
Auto-retry beyond SDK transient errors Phase 5 (exception queue UI with operator-driven retry)
Lazada connector Phase 6
Per-channel pricing Phase 4
DCA computation from escrow data Phase 4 (Phase 3 just stores the data)
Custom Medusa admin widget for connector status Phase 5
Email / SMS alerting Out of MVP — Telegram only
Multi-shop support (single-tenant per design) Out of MVP — see deferred SaaS section
Shopee Ads / promotions / vouchers as first-class entities Out of MVP — captured raw in escrow JSONB only

Prerequisites

Two things must land before Phase 3 implementation starts:

  1. Local development environmentdocker-compose.dev.yml (Postgres + Redis sidecars, Medusa via host-side yarn dev) so iteration is sub-second instead of 2-3 minute Docker rebuilds. Shipping as a separate small PR before Phase 3 begins.
  2. Shopee credentialspartner_id, partner_key, shop_id, OAuth refresh token from the merchant's in-house seller app. Stored locally in .env.development (gitignored), in staging via docker-compose.yml env block (rotated per Credentials Index process), never committed.

Architecture

Module layout

src/modules/connectors/
├── index.ts                              registers the connectors module (umbrella)
├── shared/
│   ├── service.ts                        ConnectorExceptionService (CRUD + Telegram dispatch)
│   ├── alerting.ts                       publishAlert(payload) — POST to n8n webhook
│   └── models/
│       └── connector-exception.ts        ConnectorException model
└── shopee/
    ├── service.ts                        ShopeeConnectorService (orchestration)
    ├── sdk.ts                            wraps @congminh1254/shopee-sdk client (singleton)
    ├── auth/
    │   └── postgres-token-storage.ts     implements TokenStorage backed by ShopeeAuthToken
    ├── normalizers/
    │   ├── order.ts                      Shopee Order → canonical order shape
    │   ├── address.ts                    Shopee shipping address → Medusa address shape
    │   ├── status.ts                     OrderStatus → Medusa workflow dispatch
    │   └── escrow.ts                     OrderIncome → ShopeeEscrow row
    ├── mappers/
    │   └── sku-to-variant.ts             model_sku → Medusa variant.sku
    ├── jobs/
    │   ├── lost-push-recovery.ts         hourly: getLostPushMessage() backstop
    │   ├── reconcile-daily.ts            03:00 SGT — full get_order_list reconciliation
    │   ├── fetch-escrow.ts               on-demand: triggered by COMPLETED status
    │   └── escrow-backstop-daily.ts      04:00 SGT — catches missed escrow fetches
    ├── subscribers/
    │   └── raw-event-created.ts          processes new ShopeeRawEvent rows
    ├── api/
    │   └── webhook.ts                    POST /connectors/shopee/webhook
    ├── scripts/
    │   └── shopee-oauth-bootstrap.ts     one-shot OAuth dance executor
    └── models/
        ├── shopee-raw-event.ts
        ├── shopee-order-sync.ts
        ├── shopee-escrow.ts
        └── shopee-auth-token.ts          single-row token storage (Postgres-backed)

src/links/ (cross-module Module Links via defineLink()):

  • shopee-order-sync-medusa-order.ts — 1:1 from ShopeeOrderSync to Medusa order.
  • tcg-channel-listing-product-variant.ts — N:1 from TCGChannelListing to productVariant (Phase 2 deferred this here).

Inbound flow

sequenceDiagram
    participant SH as Shopee
    participant WH as POST /connectors/shopee/webhook
    participant DB as ShopeeRawEvent (table)
    participant SUB as raw-event subscriber
    participant SDK as Shopee SDK
    participant NORM as Normalizer + Mapper
    participant MEDUSA as Medusa workflows
    participant SYNC as ShopeeOrderSync
    participant EX as ConnectorException
    participant TG as Telegram (#tcg-platform-alerts)

    SH->>WH: POST webhook (any event_type)
    WH->>WH: verify HMAC-SHA256(partner_key, url+body)
    WH->>DB: insert raw event (signature_ok, processed=false)
    WH-->>SH: 200 OK (immediate ack)

    Note over SUB: subscriber listens for<br/>ShopeeRawEvent.created
    SUB->>SDK: get_order_detail(ordersn) [if event needs detail]
    SDK-->>SUB: Shopee order payload
    SUB->>NORM: normalize order + resolve SKUs
    alt All SKUs resolve
        SUB->>MEDUSA: createOrderWorkflow / updateOrder / cancelOrder
        MEDUSA-->>SUB: medusa_order_id
        SUB->>SYNC: upsert (ordersn, status='created')
        SUB->>DB: mark raw event processed=true
    else SKU miss / signature fail / Medusa failure
        SUB->>EX: insert exception (error_class, retry_count)
        SUB->>TG: publish alert via n8n webhook
    end

Lost-push recovery (hourly): Calls sdk.push.getLostPushMessage() — Shopee's purpose-built API that returns up to 100 missed webhook deliveries from the past 3 days, paginated via has_next_page / last_message_id. After processing, calls confirmConsumedLostPushMessage({ last_message_id }) to advance the cursor. Inserts each recovered message as a ShopeeRawEvent with source='poll' and runs through the same subscriber as live webhooks. This replaces blind get_order_list polling — Shopee's API knows exactly what we missed.

Daily reconciliation (03:00 SGT): Backstop for messages older than 3 days (the lost-push window) or other edge cases. Pulls full order list via sdk.order.getOrderList() for the past 24h. Compares against ShopeeOrderSync. Any orphans → process. Logs sanity warning if Medusa has orders that Shopee doesn't (impossible in inbound-only mode but worth catching in case of test-data confusion).

Escrow flow:

  1. Event-driven (primary): push code 3 webhook with new status = COMPLETED → enqueue fetchEscrowJob(ordersn) → SDK call sdk.payment.getEscrowDetail({ order_sn }) → upsert ShopeeEscrow. Per SDK docs, escrow data may not be immediately available for orders just transitioning out of READY_TO_SHIP; the connector handles error_not_found gracefully (re-enqueue with backoff rather than failing immediately).
  2. Backstop (daily 04:00 SGT): scans ShopeeOrderSync where status = created AND no matching ShopeeEscrow row AND shopee_status = 'COMPLETED' AND completed > 14 days ago → fetches escrow. Catches anything where the COMPLETED webhook was missed and lost-push recovery + reconciliation also didn't catch the status change.

Note on escrow_release_time: The getEscrowDetail() response does NOT include escrow_release_time — that field is only available on EscrowListItem from getEscrowList(). If we need the release time per-order, the backstop job uses getEscrowList() (paginated by release_time_from / release_time_to) instead of per-order getEscrowDetail(). For Phase 3 we capture release time only when available; Phase 4 (DCA) decides whether release timing is load-bearing for cashflow accounting.

Push event types subscribed

Shopee Push uses integer code values, not string event names. The SDK's PushManager receives all push types from a single endpoint; the connector decides per-code what to do. From the audit:

Code Meaning Phase 3 handling
1 Shop authorization Log only (we OAuth'd via in-house seller app — shouldn't see this normally)
2 Shop deauthorization Telegram alert + log (operator action required: re-OAuth)
3 Order status update Primary inbound trigger — fetch order detail, normalize, dispatch Medusa workflow
4 TrackingNo push Update ShopeeOrderSync with tracking info, log only (Phase 6 handles outbound shipment sync)
5–11 Various non-order pushes (announcements, banned items, promotions, webchat, video, …) Log + ack 200, no further action
12 OpenAPI authorization expiry Telegram alert (high severity — token rotation needed) + log
13 Brand register result Log only

PushMessage.data is a JSON-encoded string (NOT a nested object) and must be JSON.parse()'d to access ordersn / status / etc. The webhook handler does this once per inbound message before storing the raw event.

Order lifecycle handler matrix

The SDK's OrderStatus union contains exactly 10 values for SG (ShopeeRegion.GLOBAL). Each maps to a deterministic Medusa effect. The INVOICE_PENDING value referenced in some Shopee docs is region-restricted to PH/BR/VN/TH and is NOT part of the SG OrderStatus union — the connector ignores it.

Shopee order_status Medusa workflow / effect SDK calls
UNPAID createOrderWorkflow — Medusa order in pending state getOrdersDetail()
READY_TO_SHIP updateOrderWorkflow — payment captured, transition to processing getOrdersDetail()
PROCESSED createOrderShipmentWorkflow — record shipment + tracking getOrdersDetail()
RETRY_SHIP Log only — update shipment metadata if available getOrdersDetail()
SHIPPED markOrderFulfilledWorkflow getOrdersDetail()
TO_CONFIRM_RECEIVE Log only — note awaiting buyer confirmation getOrdersDetail()
IN_CANCEL Log only — hold for resolution getOrdersDetail()
CANCELLED cancelOrderWorkflow getOrdersDetail()
TO_RETURN Log only — Phase 3 scope is inbound only; return reconciliation lands in Phase 6 getOrdersDetail()
COMPLETED completeOrderWorkflow + enqueue fetchEscrowJob(ordersn) getOrdersDetail() + getEscrowDetail()

pending_terms (e.g. SYSTEM_PENDING, KYC_PENDING) seen on UNPAID orders → log only, don't create a separate Medusa state.

Package-level LogisticsStatus (delivered, lost, etc.) is NOT used as an order-state trigger in Phase 3 — only the top-level order_status drives Medusa workflows. Logistics status is captured in the raw event payload and exposed for Phase 6 outbound use.


Data model

ConnectorException (cross-connector, lives in shared/)

id              pk
connector       enum: 'shopee' | 'lazada' | 'telegram' | ...
raw_event_id    text                          (polymorphic — references shopee_raw_event.id, etc.)
error_class     enum: 'signature_mismatch' | 'sku_not_found' | 'normalization_failed'
                    | 'medusa_workflow_failed' | 'escrow_fetch_failed' | 'sdk_transient'
                    | 'retry_exhausted' | 'unknown'
error_message   text
retry_count     int default 0
status          enum: 'open' | 'resolved' | 'wont_fix'
resolved_at     timestamptz nullable
resolved_by     text nullable                 (operator email)
operator_note   text nullable
created_at, updated_at
INDEX (connector, status) WHERE status = 'open'

ShopeeRawEvent (Shopee-specific)

id              pk
event_type      text                          ('order_status_update', 'return_status_update', etc.)
shopee_event_id text unique nullable          (Shopee's request ID — idempotency key for webhooks)
shop_id         bigint
ordersn         text nullable                 (Shopee's order serial number)
payload         jsonb                         (verbatim webhook body or polled order detail)
signature       text nullable                 (Shopee's Authorization header — webhooks only)
signature_ok    bool nullable
source          enum: 'webhook' | 'poll' | 'reconcile'
processed       bool default false
processed_at    timestamptz nullable
created_at
INDEX (event_type, processed) WHERE processed = false
INDEX (ordersn)
INDEX (created_at)        -- for 90-day retention sweep (Phase 9)

90-day retention per PRD line 273. Cleanup cron lands in Phase 9; the index is in place from Phase 3.

ShopeeOrderSync (Shopee-specific)

id                 pk
ordersn            text unique                (one row per Shopee order)
shop_id            bigint
medusa_order_id    text nullable              (linked to Medusa order via Module Link)
status             enum: 'pending' | 'created' | 'failed'
shopee_status      text                        (last known Shopee status — UNPAID, READY_TO_SHIP, etc.)
last_raw_event_id  text                        (FK-ish to shopee_raw_event)
created_at, updated_at
INDEX (status)
INDEX (shopee_status)

ShopeeEscrow (Shopee-specific)

Field names match the SDK's OrderIncome schema verbatim — the audit (2026-04-23) found several names in the original draft did not match the SDK. Schema source: [SDK:src/schemas/payment.ts].

id                          pk
ordersn                     text unique           (1:1 with ShopeeOrderSync.ordersn)
escrow_amount               numeric               (what merchant actually receives — KEY FIELD for Phase 4)
original_price              numeric               (sticker price before any discount)
seller_discount             numeric
shopee_discount             numeric
voucher_from_seller         numeric               (renamed from seller_voucher to match SDK)
voucher_from_shopee         numeric               (renamed from shopee_voucher to match SDK)
commission_fee              numeric               (Shopee's cut)
service_fee                 numeric
seller_transaction_fee      numeric               (renamed from transaction_fee to match SDK)
actual_shipping_fee         numeric
seller_lost_compensation    numeric
buyer_total_amount          numeric               (renamed from buyer_paid_amount to match SDK)
currency                    text                  (sourced from Order.currency, hardcoded 'SGD' for SG shop —
                                                   getEscrowDetail does NOT return a currency field)
escrow_release_time         timestamptz nullable  (only populated when fetched via getEscrowList — getEscrowDetail
                                                   does not return this field; backstop job uses getEscrowList)
raw_escrow_payload          jsonb                 (full OrderIncome — Phase 4 mines additional fields:
                                                   final_shipping_fee, shopee_shipping_rebate, seller_return_refund,
                                                   coins, escrow_tax, reverse_shipping_fee, drc_adjustable_refund,
                                                   credit_card_transaction_fee, seller_coin_cash_back, etc.)
fetched_at                  timestamptz
created_at, updated_at

ShopeeAuthToken (Shopee-specific, single-row)

Backs the custom PostgresTokenStorage implementation that replaces the SDK's default file-based token storage (which uses synchronous fs.writeFileSync and would block the Medusa event loop on every refresh). One row per shop_id; for the single-tenant MVP there will only ever be one row.

id                  pk
shop_id             bigint unique
access_token        text
refresh_token       text
expire_in           int                 (seconds — from Shopee response)
expired_at          bigint              (epoch ms — set by SDK at write time)
merchant_id_list    jsonb nullable
shop_id_list        jsonb nullable
supplier_id_list    jsonb nullable
raw_token_payload   jsonb               (full AccessToken object for forward compat)
created_at, updated_at

The PostgresTokenStorage class implements the SDK's TokenStorage interface: - store(token: AccessToken): Promise<void> — upsert by shop_id - get(): Promise<AccessToken | null> — select latest - clear(): Promise<void> — delete (used during clearAuth flows)

Passed as second arg to ShopeeSDK constructor: new ShopeeSDK(config, new PostgresTokenStorage(...)).

TCGChannelListing (Phase 2 deferral — defined here, lightly used)

id                     pk
variant_id             fk to productVariant (nullable)         (for fungible)
serialized_item_id     fk to TcgSerializedItem (nullable)      (for serialized)
channel                enum: 'shopee' | 'lazada' | 'telegram' | 'carousell' | 'instagram' | 'in_store' | 'event'
external_listing_id    text                                     (e.g. Shopee item_id)
external_variation_id  text nullable                            (e.g. Shopee model_id)
listing_url            text nullable
status                 enum: 'active' | 'inactive' | 'out_of_stock' | 'paused'
last_synced_at         timestamptz
CONSTRAINT exactly_one_target CHECK ((variant_id IS NULL) != (serialized_item_id IS NULL))
INDEX (channel, external_listing_id, external_variation_id)

In Phase 3, this table is populated during inbound (cheap to upsert when we already have the variant). It is not used for inbound matchingmodel_sku direct match is sufficient. Phase 6 outbound code will read this table to know which Shopee (item_id, model_id) to push price/stock changes to.

Link From To Cardinality
shopee-order-sync-medusa-order ShopeeOrderSync order (Medusa) 1:1
tcg-channel-listing-product-variant TCGChannelListing productVariant N:1 (when variant_id set)
tcg-channel-listing-serialized-item TCGChannelListing TcgSerializedItem N:1 (when serialized_item_id set)

Listing → variant mapping

The merchant maintains consistent model_sku discipline on every Shopee variation (generated from a Google Sheets formula). Phase 3 leverages this:

mapShopeeOrderItem(orderItem):
    variant = await query.graph({
        entity: "product_variant",
        fields: ["id", "sku"],
        filters: { sku: orderItem.model_sku }
    })
    if variant.length === 0:
        throw SkuNotFoundError(orderItem.model_sku, ordersn)
    if variant.length > 1:
        throw AmbiguousSkuError(orderItem.model_sku)        # Medusa SKU should be unique
    return variant[0]

Edge cases:

  • model_sku empty on Shopee side — operator forgot to set it. Falls into sku_not_found exception with the Shopee item_id + model_id in the error context so the operator can fix it on Shopee and re-process.
  • Multiple matches — should not happen (Medusa enforces unique SKU per variant) but guarded against with AmbiguousSkuError.
  • Variant exists but has no TCGVariantMetadata — fine, we still create the order. Accessories go through this path.

Future (Phase 5): the SKU generator formula moves into the admin product-creation UI so SKUs are generated in-platform and pushed to Shopee via outbound (Phase 6) rather than spreadsheet → manual paste.


Webhook signature verification

Per the SDK source audit, Shopee's actual algorithm is HMAC-SHA256(partner_key, JSON.stringify(body)) — body only, NOT push_url + body. The signature is returned in the authorization header in the format SHA256 <hex_digest>. The SDK's PushManager does not ship a verification helper; we implement it ourselves.

import crypto from 'node:crypto'

export function verifyWebhookSignature(
  rawBody: string,
  authorizationHeader: string | undefined,
  partnerKey: string,
): boolean {
  if (!authorizationHeader?.startsWith('SHA256 ')) return false
  const provided = authorizationHeader.slice('SHA256 '.length)
  const expected = crypto
    .createHmac('sha256', partnerKey)
    .update(rawBody)
    .digest('hex')
  // constant-time compare to prevent timing attacks
  if (provided.length !== expected.length) return false
  return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
}

The handler captures the raw request body before any JSON parsing (Express middleware order matters — express.raw({ type: 'application/json' }) for the webhook route specifically), passes it to verifyWebhookSignature(), then JSON.parse()s it for storage and downstream processing.

Mismatch → raw event still inserted (with signature_ok=false) but not processed by the subscriber. Exception row with error_class='signature_mismatch'. Telegram alert. Webhook still returns 200 (don't hint to attackers whether their signature was right or wrong).

Signature covers only webhooks, not lost-push recovery / reconciliation (those use authenticated SDK calls).


Idempotency + retries

Idempotency

  • Webhook handler: unique on shopee_raw_event.shopee_event_id (Shopee's per-request ID). Duplicate POSTs return 200 without re-inserting.
  • Order processing: unique on shopee_order_sync.ordersn. Re-processing same order is a no-op (state already set).
  • Lost-push recovery / reconciliation: share the same processor. Lost-push uses Shopee's per-message last_message_id cursor (advanced via confirmConsumedLostPushMessage) so the same message is never re-pulled. Daily reconciliation filters by ordersn NOT IN (SELECT ordersn FROM shopee_order_sync) before injection.
  • Escrow: unique on shopee_escrow.ordersn. Upsert pattern.

Retries

  • Webhook ACK: always 200 immediately after raw event insert. Shopee's retry policy doesn't help us — 5xx-ing on processing failure just gets duplicates we'd dedupe anyway. Process async via subscriber.
  • Subscriber processing failure: ConnectorException row created. No automatic retry in Phase 3. Operator triggers manual retry via SQL (UPDATE shopee_raw_event SET processed=false WHERE id=…) until Phase 5 ships the exception queue UI with a retry button. Rationale: most failures are mapping issues that need operator action; auto-retry just makes the operator deal with N exceptions instead of 1.
  • SDK transient errors (network blips, 5xx from Shopee API): 3 in-process retries with exponential backoff (1s / 4s / 16s) before giving up and writing an exception with error_class='sdk_transient'. These are likely to self-resolve.

Failure path → Telegram

shared/alerting.ts posts to a webhook URL configured via SHOPEE_ALERT_WEBHOOK_URL env var. URL points at an n8n webhook trigger on CT 102, which formats and posts to the dedicated #tcg-platform-alerts Telegram chat.

Alert payload (JSON sent to n8n):

{
  "connector": "shopee",
  "error_class": "sku_not_found",
  "ordersn": "240422ABCDEF",
  "shop_id": 12345,
  "message": "SKU 'POK-OBF-223-NM-EN' not found in Medusa variants",
  "exception_id": "cex_01ABC...",
  "raw_event_id": "srev_01XYZ...",
  "occurred_at": "2026-04-23T08:15:30Z"
}

n8n side (out of tcg-platform repo, lives in homelab/04_n8n_workflows/): receives the JSON, formats as Telegram message with a severity emoji + a copy-pastable line for finding the row by exception_id. Phase 3 ships the n8n workflow JSON export alongside the connector commit.

Severity policy: all failures alert. Per merchant decision — operational visibility outranks noise concerns at this stage. If alert volume becomes painful (e.g. Shopee webhooks for OOS items spamming the channel), filtering moves into n8n, not into the connector itself.


SDK + OAuth bootstrap

@congminh1254/shopee-sdk (v1.5.5+) is the SDK of record. CJS-only package (verify Medusa workspace consumes via require() cleanly at build time — no ESM entry exists). Bundled TypeScript types via lib/sdk.d.ts.

shopee/sdk.ts exports a singleton client constructed from env, with the Postgres-backed TokenStorage injected:

import ShopeeSDK, { ShopeeRegion } from '@congminh1254/shopee-sdk'
import { PostgresTokenStorage } from './auth/postgres-token-storage'

export const shopeeSdk = new ShopeeSDK(
  {
    partner_id: Number(process.env.SHOPEE_PARTNER_ID),
    partner_key: process.env.SHOPEE_PARTNER_KEY!,
    region: ShopeeRegion.GLOBAL,            // SG uses the GLOBAL endpoint —
                                            // there is no ShopeeRegion.SG
                                            // in the actual SDK source
    shop_id: Number(process.env.SHOPEE_SHOP_ID),
  },
  new PostgresTokenStorage(/* injected DB client */),
)

Region note: The SDK's ShopeeRegion enum (src/schemas/region.ts) only defines GLOBAL, CHINA, BRAZIL, TEST_GLOBAL, TEST_CHINA. The README claims twelve named regions (incl. ShopeeRegion.SG) but the source disagrees — the implementation plan must verify the compiled lib/sdk.js at build time. Singapore Shopee's API endpoint (https://partner.shopeemobile.com/api/v2) IS the GLOBAL one, so functionally ShopeeRegion.GLOBAL is correct regardless.

Token storage: custom PostgresTokenStorage backed by the ShopeeAuthToken table. Replaces the SDK's default file-based storage which uses synchronous fs.writeFileSync and would block the Medusa event loop on every refresh. No Docker volume needed — Postgres already persists across container rebuilds.

Auto-refresh: SDK handles token refresh automatically before each authenticated request (checks token.expired_at < Date.now(), refreshes + retries) and on invalid_access_token errors. The connector does not need to manage refresh manually.

Error wrapping: SDK throws ShopeeApiError (Shopee API-level failures with non-empty error field) and ShopeeSdkError (network / SDK-internal). Both are caught in the subscriber and converted to ConnectorException rows + Telegram alerts.

Response payload location: The SDK's FetchResponse<T> wrapper has both .result and .response fields. SDK usage patterns access actual data via .response, not .result.

OAuth bootstrap: one-shot script yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code> after operator completes Shopee's OAuth dance (using the merchant's in-house seller app). The auth_code → access_token + refresh_token exchange writes the first row into ShopeeAuthToken. Documented in the connector README.


Testing strategy

Unit tests

  • normalizers/order.ts — fixture-based against recorded Shopee get_order_detail payloads. Covers: simple single-line order, multi-line order, order with discounts, order with vouchers.
  • normalizers/status.ts — every documented Shopee status maps to the right Medusa workflow (or no-op for transitional states).
  • mappers/sku-to-variant.ts — known-SKU happy path, unknown-SKU SkuNotFoundError, ambiguous-SKU AmbiguousSkuError.
  • webhook.ts signature verification — known-good payload + tampered payload + missing header.
  • shared/alerting.ts — payload shape, error handling when n8n is unreachable.

Integration tests (Medusa's medusaIntegrationTestRunner)

  • Webhook → raw event written, ACKed within 100ms.
  • Subscriber → Medusa order created with correct line items + sales channel + customer.
  • Status update flow: READY_TO_SHIP → SHIPPED updates Medusa fulfillment status.
  • Cancellation flow: CANCELLED from Shopee → Medusa order canceled.
  • Escrow fetch: completed-order webhook → ShopeeEscrow row with non-zero escrow_amount.
  • Idempotency: re-processing same ordersn is no-op.
  • Mapping miss: SKU not found → ConnectorException row + Telegram webhook fired (mocked endpoint).

End-to-end manual validation (the exit criterion)

On staging, with real Shopee credentials:

  1. Operator lists a low-value test SKU on Shopee (e.g. a $1 sleeves single from the Phase 2 seed catalogue).
  2. Operator (using a non-merchant Shopee account) places a real order.
  3. Within 5 min: Shopee webhook hit https://tcg-staging.exzentcg.com/connectors/shopee/webhook, raw event row created, Medusa order appears in admin UI, ShopeeOrderSync row in created state, no exception rows, no Telegram alerts.
  4. Operator marks the order shipped on Shopee → Medusa fulfillment status updates within 5 min.
  5. After buyer confirms receipt (or auto-confirm period elapses) → ShopeeEscrow row appears with non-zero escrow_amount.
  6. Negative test: temporarily rename a Medusa variant SKU → place test order → expect exception row + Telegram alert. Restore SKU.

CI runs unit + integration only. End-to-end is operator-driven on staging.


Open questions (to resolve before / during implementation)

  1. Test-order destruction. Once the end-to-end test order lands in Medusa, do we cancel it on the Shopee side (so no real fulfillment happens) and let the cancel-flow validation cover that, or do we let it complete naturally so escrow validation runs against a real settled order? Trade-off: real settlement takes 5-7 days; cancellation is instant but doesn't validate the escrow path on real data.
  2. Sales channel naming. New Medusa sales channel — call it shopee (lowercase, matches connector enum) or Shopee (display-friendly)? Likely shopee internally with a display name override.
  3. Customer matching policy. Match Shopee buyers across orders by buyer_user_id (stored in customer metadata)? Pro: unified order history per buyer. Con: if Shopee changes the user_id mapping, we get split customers. Default: yes, match by buyer_user_id. Verify against SDK behaviour during implementation.
  4. Currency handling. SGD-only for Phase 3? PRD doesn't pin it down. The merchant is Singapore-based so likely yes; multi-currency is Phase 6+ once Lazada (which has multiple SEA markets) lands.
  5. n8n alert chat lifecycle. Who creates the dedicated #tcg-platform-alerts Telegram chat + records the bot token in the Credentials Index? Operator action, not a code task — flagging here so it doesn't get missed.
  6. Stale model_sku cache invalidation. SKU → variant lookup is cheap (one indexed query). Do we need a cache layer at all in Phase 3, or just hit Postgres each time? Default: no cache (premature optimization). Revisit if order volume + lookup latency become measurable.

Exit criteria checklist

From the Project Plan Phase 3 row ("A real Shopee order lands in Medusa via the connector"), expanded:

  • [ ] src/modules/connectors/shopee/ and src/modules/connectors/shared/ shipped, registered in medusa-config.ts, Medusa boots cleanly.
  • [ ] All five new migrations apply cleanly on a fresh Postgres (ConnectorException, ShopeeRawEvent, ShopeeOrderSync, ShopeeEscrow, ShopeeAuthToken) plus the deferred TCGChannelListing migration.
  • [ ] Webhook endpoint reachable at https://tcg-staging.exzentcg.com/connectors/shopee/webhook (NPM proxy + Cloudflare Tunnel — same path config pattern as admin).
  • [ ] OAuth bootstrap script runs end-to-end against the merchant's in-house Shopee app, persisted tokens survive container restart.
  • [ ] Real test order placed end-to-end → Medusa order created within 5 min with correct line items, customer, address, sales channel.
  • [ ] Status update test: shipping the test order on Shopee → Medusa fulfillment status updates within 5 min.
  • [ ] Cancellation test: cancelling a Shopee test order → Medusa order status flips to canceled within 5 min.
  • [ ] Escrow test: ShopeeEscrow row appears for a settled order with non-zero escrow_amount.
  • [ ] Negative test: SKU miss → ConnectorException row + Telegram alert fired.
  • [ ] Lost-push recovery (getLostPushMessage) runs hourly, advances last_message_id cursor, no duplicate orders created.
  • [ ] PostgresTokenStorage survives container restart — token refreshes between restarts read back the same row, no re-OAuth needed.
  • [ ] Daily reconciliation runs, no false-positive orphans logged.
  • [ ] CI green on all Phase 3 PRs (build + type-check + integration tests).
  • [ ] n8n workflow JSON exported to homelab/04_n8n_workflows/ and the corresponding doc page added.
  • [ ] tcg-platform/phase-3-findings.md committed with verified scenarios + gaps + open questions.
  • [ ] homelab/05_service_deployments/tcg-staging.md updated with "Phase 3 applied" line + new phase-3-shopee-live Proxmox snapshot.
  • [ ] Local dev environment PR (separate, prerequisite) shipped and verified.

Task split (D1 / D2 / D3)

In practice the merchant is currently solo on this; the split is documented for when D2 / D3 join. Until then, all tasks land via the subagent-driven flow used for Phase 2.

D1 — Lead (most)

  • Connector module architecture, OAuth bootstrap, SDK wrapping, retry / idempotency strategy.
  • Order normalizer + status mapper + escrow fetcher.
  • Webhook signature verification.
  • ConnectorException model + alerting infrastructure.
  • Phase 3 design + implementation plan + findings doc.

D2 — Mid

  • Lost-push recovery + reconciliation jobs.
  • Integration test suite using Medusa's test runner.
  • TCGChannelListing migration + populate-during-inbound logic.
  • README + connector-specific docs.

D3 — Support (low)

  • Unit test fixtures (recorded Shopee payloads, anonymized).
  • n8n workflow JSON authoring + export to homelab repo.
  • Phase 3 findings doc shell + manual validation walkthrough docs.

Notes for the implementation plan stage

The pre-implementation audit (against the SDK source + Shopee Open Platform docs) ran on 2026-04-23. Findings folded into this spec; full report archived at /tmp/shopee-audit-2026-04-23.md (subagent transcript). Material deltas applied:

  1. Regionregion: 'sg' corrected to ShopeeRegion.GLOBAL per actual SDK source (only 5 regions exist in src/schemas/region.ts; SG uses the GLOBAL endpoint regardless).
  2. Webhook signature input — corrected from HMAC-SHA256(partner_key, push_url + body) to HMAC-SHA256(partner_key, JSON.stringify(body)). Header format is Authorization: SHA256 <hex>. SDK ships no helper; we write our own.
  3. Escrow field namesseller_voucher / shopee_voucher / transaction_fee / buyer_paid_amount renamed to match the SDK's OrderIncome schema (voucher_from_seller, voucher_from_shopee, seller_transaction_fee, buyer_total_amount). payout_currency removed (does not exist in SDK response — sourced from Order.currency instead).
  4. escrow_release_time — only available on EscrowListItem from getEscrowList(), not from getEscrowDetail(). Backstop job uses the list endpoint to capture release times; primary path captures whatever else is available.
  5. Order statusesINVOICE_PENDING removed (region-restricted to PH/BR/VN/TH, not in SG OrderStatus union); TO_RETURN added as a log-only status. Final list of 10 SG-valid statuses captured in the handler matrix.
  6. Push event types — concrete code-to-meaning matrix added (codes 1–13). Code 3 (order status) and 4 (tracking) are inbound-relevant; code 12 (token expiry) needs Telegram alert.
  7. Recovery API — original "polling backstop" replaced with Shopee's purpose-built getLostPushMessage() API (returns last 3 days of missed pushes, paginated cursor).
  8. Token storage — switched from default file-based (sync fs.writeFileSync, blocks event loop) to a custom PostgresTokenStorage backed by a new ShopeeAuthToken table. New model added to data model section.
  9. Rate limit / retry confirmation — SDK does no 429 handling; only retries on invalid_access_token. Our 3-retry exponential backoff in ConnectorException handling is necessary, not redundant.

Items still not verifiable from the SDK alone (Shopee's docs are login-gated), to confirm during implementation against the live API:

  • Shopee's actual webhook retry count + window if we 5xx (we treat lost-push API as authoritative recovery regardless).
  • Exact rate limits on getOrdersDetail / getEscrowDetail (the SDK has no constants — we'll measure during the manual end-to-end and tune retry timing if needed).
  • Whether the compiled lib/sdk.js exposes more region values than the source file (README claims 12 regions; source has 5).

References