Skip to content

Phase 3: Shopee Slice (Inbound) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship src/modules/connectors/shopee/ on ExzenTCG/tcg-platform with end-to-end inbound Shopee order ingestion (webhook + lost-push recovery + reconciliation), order status sync to Medusa, escrow income capture, all backed by Postgres-backed token storage and Telegram failure alerting.

Architecture: Connector module wrapping @congminh1254/shopee-sdk with custom PostgresTokenStorage. Webhook handler → raw event store → subscriber → normalize + map SKU → Medusa workflow. Failures → ConnectorException row + Telegram alert via n8n webhook.

Tech Stack: Medusa 2.13.6, TypeScript, Postgres 16, Redis 7, @congminh1254/shopee-sdk v1.5.5+, crypto (Node built-in for HMAC), Jest (existing test runner from Phase 2).

Predecessor: Phase 3 Kickoff Plan — read it first; this plan implements that design and incorporates the audit corrections from /tmp/shopee-audit-2026-04-23.md.


Plan-wide conventions

These apply to every PR and task. Don't restate them per-task — the executor knows the rhythm.

Branch + commit + PR cadence (per PR):

# Start of every PR
cd /tmp/tcg-platform-pr<X>
git fetch origin && git checkout main && git pull --ff-only
git checkout -b draft/claude-phase-3-pr-<X>-<short>

# After every task: commit on the PR branch
git add <files>
git commit -m "<conventional message>"

# At end of PR
git push -u origin draft/claude-phase-3-pr-<X>-<short>
gh pr create --base main --head draft/claude-phase-3-pr-<X>-<short> \
  --title "<title>" --body "$(cat <<'EOF'
<body>
EOF
)"

Working directories:

  • All tcg-platform PRs (A–E): clone fresh to /tmp/tcg-platform-pr<X> for each PR. Do NOT reuse worktrees across PRs.
  • PR F (homelab repo) work: /home/xkenchi/Documents/ExzenTCG-Homelab directly.

Test commands:

  • Unit/module integration: yarn jest <path>
  • HTTP integration (webhook): yarn jest integration-tests/http/<name>.spec.ts
  • Module CRUD via auto-generated MedusaService methods, tested with moduleIntegrationTestRunner (see Phase 2's src/modules/tcg/__tests__/service.spec.ts for the canonical pattern).

TDD discipline (every task):

  1. Write the failing test
  2. Run the test, confirm it fails for the expected reason (not a typo or import error)
  3. Write the minimum implementation that makes the test pass
  4. Run the test, confirm it passes
  5. Commit (test + impl together)

For data-only models with no business logic: the "test" is creating a row via the auto-generated create<Model>s method and asserting the returned shape. Don't skip.

Audit-driven hard constants (use these verbatim — no substitutions):

Concern Correct value Wrong value to avoid
SDK region for SG ShopeeRegion.GLOBAL 'sg', ShopeeRegion.SG
Webhook HMAC input JSON.stringify(body) (or the raw body buffer) url + body, url + "|" + body
Auth header format Authorization: SHA256 <hex> X-Signature, plain hex
Escrow voucher fields voucher_from_seller, voucher_from_shopee seller_voucher, shopee_voucher
Escrow transaction fee seller_transaction_fee transaction_fee
Escrow buyer total buyer_total_amount buyer_paid_amount
Escrow currency source Order.currency or hardcoded 'SGD' payout_currency (does not exist)
Escrow release time Only via getEscrowList(), null from getEscrowDetail() Don't expect from detail
Token storage Custom PostgresTokenStorage Default file-based (sync I/O blocks event loop)
SDK response field .response .result
Order statuses (10) UNPAID, READY_TO_SHIP, PROCESSED, RETRY_SHIP, SHIPPED, TO_CONFIRM_RECEIVE, IN_CANCEL, CANCELLED, TO_RETURN, COMPLETED INVOICE_PENDING (region-restricted)
Push code 3 Order status update — primary inbound trigger (don't ignore)
Push code 4 Tracking — log only
Push code 12 OpenAPI auth expiry — Telegram alert
Recovery API sdk.push.getLostPushMessage() paginated cursor Generic get_order_list polling
Webhook signature helper Write our own (SDK doesn't ship one) Don't expect PushManager.verifySignature()

When ANY task touches one of these areas, restate the correct constant inline. Don't reference back to this table from a task.


File structure

This is the complete file inventory for the Phase 3 implementation. Each file's responsibility is one line; full code lives in the per-task detail. Files are grouped by introducing PR.

Created in PR A — Models, migrations, module registration

src/modules/connectors/index.ts                           umbrella module re-export (no service)
src/modules/connectors/README.md                          (already exists — append connector inventory)

src/modules/connectors/shared/index.ts                    shared module registration (CONNECTOR_SHARED_MODULE)
src/modules/connectors/shared/service.ts                  ConnectorExceptionService extends MedusaService
src/modules/connectors/shared/types/enums.ts              ConnectorName, ErrorClass, ExceptionStatus
src/modules/connectors/shared/models/connector-exception.ts   ConnectorException DML model
src/modules/connectors/shared/__tests__/service.spec.ts   moduleIntegrationTestRunner: row CRUD

src/modules/connectors/shopee/index.ts                    shopee module registration (SHOPEE_CONNECTOR_MODULE)
src/modules/connectors/shopee/service.ts                  ShopeeConnectorService extends MedusaService (skeleton; PR D adds processEvent)
src/modules/connectors/shopee/types/enums.ts              PushCode, OrderStatus, PushSource, OrderSyncStatus
src/modules/connectors/shopee/models/shopee-raw-event.ts
src/modules/connectors/shopee/models/shopee-order-sync.ts
src/modules/connectors/shopee/models/shopee-escrow.ts
src/modules/connectors/shopee/models/shopee-auth-token.ts
src/modules/connectors/shopee/__tests__/service.spec.ts   moduleIntegrationTestRunner: row CRUD across all 4 models

src/modules/tcg/types/enums.ts                            (modify — add Channel, ListingStatus enums)
src/modules/tcg/models/channel-listing.ts                 TCGChannelListing DML model (Phase 2 deferral lands here)
src/modules/tcg/service.ts                                (modify — add TCGChannelListing to MedusaService factory)
src/modules/tcg/__tests__/service.spec.ts                 (modify — add CHECK constraint test for channel_listing)

src/links/shopee-order-sync-medusa-order.ts               1:1 ShopeeOrderSync ↔ Medusa order
src/links/tcg-channel-listing-product-variant.ts          N:1 TCGChannelListing → variant (when variant_id set)
src/links/tcg-channel-listing-serialized-item.ts          N:1 TCGChannelListing → serialized item

medusa-config.ts                                          (modify — register shared + shopee modules)

src/modules/connectors/shopee/migrations/<auto>.ts        4 model tables (auto-generated)
src/modules/connectors/shopee/migrations/<hand>.ts        CHECK constraint on tcg_channel_listing (one_target)
src/modules/connectors/shared/migrations/<auto>.ts        connector_exception table
src/modules/tcg/migrations/<auto>.ts                      (additional migration for tcg_channel_listing)

Created in PR B — SDK + Postgres token storage + OAuth bootstrap

src/modules/connectors/shopee/sdk.ts                      ShopeeSdk singleton wrapper
src/modules/connectors/shopee/auth/postgres-token-storage.ts   PostgresTokenStorage implements TokenStorage
src/modules/connectors/shopee/__tests__/postgres-token-storage.spec.ts   round-trip test

src/scripts/shopee-oauth-bootstrap.ts                     one-shot OAuth code → token exchange
src/scripts/shopee-oauth-status.ts                        diagnostic: print current token state

.env.development.example                                  (modify — add SHOPEE_* vars)
.env.example                                              (modify — same)
docker-compose.yml                                        (modify — add SHOPEE_* env passthrough)
README.md                                                 (modify — append "Shopee OAuth bootstrap" section)
package.json                                              (no change — yarn medusa exec is already wired)

Created in PR C — Webhook endpoint + signature verification + alerting

src/modules/connectors/shared/alerting.ts                 publishConnectorAlert(payload) → POST to n8n
src/modules/connectors/shared/__tests__/alerting.spec.ts  payload shape + error handling

src/modules/connectors/shopee/webhook/verify-signature.ts   verifyWebhookSignature helper
src/modules/connectors/shopee/__tests__/verify-signature.spec.ts   good + tampered + missing header

src/api/connectors/shopee/webhook/route.ts                POST handler (Medusa file-based routing)
src/api/connectors/shopee/webhook/middleware.ts           express.raw() to capture raw body
src/api/middlewares.ts                                    (modify — register webhook raw-body middleware)

integration-tests/http/shopee-webhook.spec.ts             POST → 200 + raw event row + signature handling

.env.development.example                                  (modify — add SHOPEE_ALERT_WEBHOOK_URL)
.env.example                                              (modify — same)
docker-compose.yml                                        (modify — add SHOPEE_ALERT_WEBHOOK_URL passthrough)

Created in PR D — Subscriber + normalizers + mappers + workflow dispatch

src/modules/connectors/shopee/mappers/sku-to-variant.ts   model_sku → Medusa variant
src/modules/connectors/shopee/__tests__/sku-to-variant.spec.ts

src/modules/connectors/shopee/normalizers/order.ts        Shopee Order → canonical line items + customer
src/modules/connectors/shopee/__tests__/normalizers/order.spec.ts

src/modules/connectors/shopee/normalizers/address.ts      Shopee shipping address → Medusa shape
src/modules/connectors/shopee/__tests__/normalizers/address.spec.ts

src/modules/connectors/shopee/normalizers/status.ts       OrderStatus → Medusa workflow dispatch (handler matrix)
src/modules/connectors/shopee/__tests__/normalizers/status.spec.ts

src/modules/connectors/shopee/normalizers/escrow.ts       OrderIncome → ShopeeEscrow row shape
src/modules/connectors/shopee/__tests__/normalizers/escrow.spec.ts

src/modules/connectors/shopee/service.ts                  (modify — add processEvent orchestration method)
src/subscribers/shopee-raw-event-created.ts               subscriber listening for "shopee_raw_event.created"

src/modules/connectors/shopee/__tests__/process-event.spec.ts   end-to-end with mocked SDK + Medusa workflow stubs

integration-tests/http/shopee-order-flow.spec.ts          full webhook → Medusa order created

Created in PR E — Lost-push recovery + reconciliation + escrow fetch jobs

src/jobs/shopee-lost-push-recovery.ts                     hourly scheduled job
src/jobs/shopee-reconcile-daily.ts                        03:00 SGT scheduled job
src/jobs/shopee-fetch-escrow.ts                           on-demand job (triggered by COMPLETED status from PR D)
src/jobs/shopee-escrow-backstop-daily.ts                  04:00 SGT scheduled job

src/modules/connectors/shopee/__tests__/jobs/lost-push-recovery.spec.ts
src/modules/connectors/shopee/__tests__/jobs/reconcile-daily.spec.ts
src/modules/connectors/shopee/__tests__/jobs/fetch-escrow.spec.ts
src/modules/connectors/shopee/__tests__/jobs/escrow-backstop-daily.spec.ts

Created in PR F — Findings doc + n8n workflow + runbook updates

tcg-platform/phase-3-findings.md                          (new — homelab repo)
homelab/04_n8n_workflows/shopee-alert-formatter.json      (new — homelab repo)
homelab/04_n8n_workflows/3. Shopee Alert Formatter.md     (new — doc page for the workflow)
homelab/05_service_deployments/tcg-staging.md             (modify — Phase 3 applied row + new snapshot)
mkdocs.yml                                                (modify — add new pages to nav)

PR overview

PR Title Repo Tasks Commits Focus
A scaffold + models + links + migrations tcg-platform 12 ~12 Data foundation; nothing networked yet
B SDK wrapper + Postgres token storage + OAuth bootstrap tcg-platform 8 ~8 Auth working; no flows yet
C Webhook endpoint + signature verification + alerting tcg-platform 7 ~7 Inbound surface opens; raw events stored, alerts wired
D Subscriber + normalizers + mappers + workflow dispatch tcg-platform 11 ~11 First Medusa orders created from Shopee data
E Lost-push recovery + reconciliation + escrow fetch jobs tcg-platform 6 ~6 Robustness layer; status updates complete
F Phase 3 findings template + n8n workflow + runbook ExzenTCG-Homelab 4 ~4 Operational artefacts

PRs A → E must merge in order on tcg-platform/main. PR F can ship in parallel with PR E since it's a docs PR in a different repo.


Goal: Lay the data foundation. After this PR, tcg-platform has 5 new tables (connector_exception, shopee_raw_event, shopee_order_sync, shopee_escrow, shopee_auth_token, plus tcg_channel_listing from the Phase 2 deferral), 3 cross-module links, and 2 new modules registered. No business logic yet — services are factory-only skeletons.

Branch: draft/claude-phase-3-pr-a-scaffold from tcg-platform/main.

Task A.1: Branch + scaffold directory structure

Files: - Modify: src/modules/connectors/README.md (already exists with placeholder content; replace) - Create: src/modules/connectors/index.ts - Create: src/modules/connectors/shared/ (dir) - Create: src/modules/connectors/shopee/ (dir)

  • [ ] Step 1: Branch from main
cd /tmp && rm -rf tcg-platform-pra
git clone https://github.com/ExzenTCG/tcg-platform.git tcg-platform-pra
cd tcg-platform-pra
git checkout -b draft/claude-phase-3-pr-a-scaffold
  • [ ] Step 2: Scaffold dirs and umbrella index
mkdir -p src/modules/connectors/shared/{models,types,migrations,__tests__}
mkdir -p src/modules/connectors/shopee/{models,types,migrations,__tests__}
// src/modules/connectors/index.ts
//
// Umbrella file. The `connectors/` namespace aggregates per-channel
// modules under one top-level folder. Each connector (`shared/`,
// `shopee/`, future `lazada/`, etc.) is its own Medusa module with
// its own service. This file does NOT register a Medusa module; it
// only re-exports symbols for ergonomic imports.

export { default as SharedConnectorsModule, CONNECTOR_SHARED_MODULE } from "./shared"
export { default as ShopeeConnectorModule, SHOPEE_CONNECTOR_MODULE } from "./shopee"
  • [ ] Step 3: Replace the connector README
# Connectors

Per-channel marketplace + social-channel connectors live here, each as
its own Medusa module under `src/modules/connectors/<channel>/`.

| Module | Purpose | Status |
|---|---|---|
| `shared/` | Cross-connector primitives: `ConnectorException` model + Telegram alerting helper | Phase 3 ✅ |
| `shopee/` | Shopee Open Platform v2 inbound order sync | Phase 3 ✅ |
| `lazada/` | Lazada Open Platform inbound + outbound | Phase 6 (planned) |
| `telegram/` | Telegram Bot + Mini App order entry | Phase 7 (planned) |
| `carousell/` | Carousell manual order entry assist | Phase 7 (planned) |

See [Phase 3 Kickoff Plan](../../../tcg-platform/phase-3-plan.md) for design.
  • [ ] Step 4: Commit
git add src/modules/connectors/
git commit -m "feat(connectors): scaffold connectors namespace dirs + umbrella index"

Task A.2: shared enums + ConnectorException model

Files: - Create: src/modules/connectors/shared/types/enums.ts - Create: src/modules/connectors/shared/models/connector-exception.ts

  • [ ] Step 1: Write the failing model test
// src/modules/connectors/shared/__tests__/service.spec.ts
jest.setTimeout(60 * 1000)

import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { CONNECTOR_SHARED_MODULE } from ".."
import ConnectorExceptionService from "../service"
import ConnectorException from "../models/connector-exception"
import { ConnectorName, ErrorClass, ExceptionStatus } from "../types/enums"

moduleIntegrationTestRunner<ConnectorExceptionService>({
  moduleName: CONNECTOR_SHARED_MODULE,
  moduleModels: [ConnectorException],
  resolve: "./src/modules/connectors/shared",
  testSuite: ({ service }) => {
    describe("ConnectorExceptionService — model CRUD", () => {
      it("creates and lists a ConnectorException row", async () => {
        const [row] = await service.createConnectorExceptions([
          {
            connector: ConnectorName.SHOPEE,
            raw_event_id: "srev_test_001",
            error_class: ErrorClass.SKU_NOT_FOUND,
            error_message: "SKU 'X' not found",
            retry_count: 0,
            status: ExceptionStatus.OPEN,
          },
        ])

        expect(row.id).toMatch(/^cex_/)
        expect(row.connector).toBe(ConnectorName.SHOPEE)
        expect(row.error_class).toBe(ErrorClass.SKU_NOT_FOUND)

        const listed = await service.listConnectorExceptions({ id: row.id })
        expect(listed).toHaveLength(1)
      })
    })
  },
})
  • [ ] Step 2: Run — should FAIL (module not found yet)
yarn jest src/modules/connectors/shared/__tests__/service.spec.ts

Expected: Cannot find module '..' — the shared module index doesn't exist yet.

  • [ ] Step 3: Implement enums
// src/modules/connectors/shared/types/enums.ts

/** Channels we connect to. Add new values when a new connector ships. */
export enum ConnectorName {
  SHOPEE = "shopee",
  LAZADA = "lazada",
  TELEGRAM = "telegram",
  CAROUSELL = "carousell",
  INSTAGRAM = "instagram",
  IN_STORE = "in_store",
  EVENT = "event",
}

/**
 * Failure taxonomy. Stable values — these are written to the database
 * and consumed by Telegram alerts and (future) the Phase 5 exception UI.
 * Adding a new value is fine; renaming or removing a value requires a
 * migration.
 */
export enum ErrorClass {
  SIGNATURE_MISMATCH = "signature_mismatch",
  SKU_NOT_FOUND = "sku_not_found",
  AMBIGUOUS_SKU = "ambiguous_sku",
  NORMALIZATION_FAILED = "normalization_failed",
  MEDUSA_WORKFLOW_FAILED = "medusa_workflow_failed",
  ESCROW_FETCH_FAILED = "escrow_fetch_failed",
  SDK_TRANSIENT = "sdk_transient",
  TOKEN_EXPIRED = "token_expired",
  RETRY_EXHAUSTED = "retry_exhausted",
  UNKNOWN = "unknown",
}

export enum ExceptionStatus {
  OPEN = "open",
  RESOLVED = "resolved",
  WONT_FIX = "wont_fix",
}
  • [ ] Step 4: Implement the model
// src/modules/connectors/shared/models/connector-exception.ts

import { model } from "@medusajs/framework/utils"
import { ConnectorName, ErrorClass, ExceptionStatus } from "../types/enums"

const ConnectorException = model.define("connector_exception", {
  id: model.id({ prefix: "cex" }).primaryKey(),
  connector: model.enum(ConnectorName),

  // Polymorphic — references the per-connector raw event table by ID.
  // Not a real FK because the target table varies per connector.
  raw_event_id: model.text(),

  error_class: model.enum(ErrorClass),
  error_message: model.text(),
  retry_count: model.number().default(0),

  status: model.enum(ExceptionStatus).default(ExceptionStatus.OPEN),
  resolved_at: model.dateTime().nullable(),
  resolved_by: model.text().nullable(),
  operator_note: model.text().nullable(),
})

export default ConnectorException
  • [ ] Step 5: Implement service skeleton + module index
// src/modules/connectors/shared/service.ts

import { MedusaService } from "@medusajs/framework/utils"
import ConnectorException from "./models/connector-exception"

class ConnectorExceptionService extends MedusaService({
  ConnectorException,
}) {}

export default ConnectorExceptionService
// src/modules/connectors/shared/index.ts

import { Module } from "@medusajs/framework/utils"
import ConnectorExceptionService from "./service"

export const CONNECTOR_SHARED_MODULE = "connectorSharedService"

export default Module(CONNECTOR_SHARED_MODULE, {
  service: ConnectorExceptionService,
})
  • [ ] Step 6: Register module + generate migration

Edit medusa-config.ts — add to the modules array:

{
  resolve: "./src/modules/connectors/shared",
},

Run:

yarn medusa db:generate connectorSharedService

Expected: a new migration file appears in src/modules/connectors/shared/migrations/Migration<TIMESTAMP>.ts creating the connector_exception table.

  • [ ] Step 7: Apply migration + run test
yarn medusa db:migrate
yarn jest src/modules/connectors/shared/__tests__/service.spec.ts

Expected: migration prints ✔ Migrated Migration<TIMESTAMP>. Test passes.

  • [ ] Step 8: Commit
git add src/modules/connectors/shared/ medusa-config.ts
git commit -m "feat(connectors/shared): ConnectorException model + service"

Task A.3: Shopee enums

Files: - Create: src/modules/connectors/shopee/types/enums.ts

  • [ ] Step 1: Write the enums
// src/modules/connectors/shopee/types/enums.ts

/**
 * Shopee Push notification codes. Sourced from
 * `@congminh1254/shopee-sdk` docs `[SDK:docs/managers/push.md]` and
 * the audit at /tmp/shopee-audit-2026-04-23.md.
 *
 * Only ORDER_STATUS_UPDATE (3), TRACKING_NO (4), and AUTH_EXPIRY (12)
 * are actionable in Phase 3. The others are logged + ack'd.
 */
export enum PushCode {
  SHOP_AUTHORIZED = 1,
  SHOP_DEAUTHORIZED = 2,
  ORDER_STATUS_UPDATE = 3,
  TRACKING_NO = 4,
  SHOPEE_UPDATES = 5,
  BANNED_ITEM = 6,
  ITEM_PROMOTION = 7,
  RESERVED_STOCK_CHANGE = 8,
  PROMOTION_UPDATE = 9,
  WEBCHAT = 10,
  VIDEO_UPLOAD = 11,
  AUTH_EXPIRY = 12,
  BRAND_REGISTER_RESULT = 13,
}

/**
 * The 10 valid `OrderStatus` values for the SG (GLOBAL) endpoint per
 * the SDK source `src/schemas/order.ts`. INVOICE_PENDING is region-
 * restricted to PH/BR/VN/TH and is NOT an order status — it appears
 * only as a package-filter boolean in those regions.
 */
export enum ShopeeOrderStatus {
  UNPAID = "UNPAID",
  READY_TO_SHIP = "READY_TO_SHIP",
  PROCESSED = "PROCESSED",
  RETRY_SHIP = "RETRY_SHIP",
  SHIPPED = "SHIPPED",
  TO_CONFIRM_RECEIVE = "TO_CONFIRM_RECEIVE",
  IN_CANCEL = "IN_CANCEL",
  CANCELLED = "CANCELLED",
  TO_RETURN = "TO_RETURN",
  COMPLETED = "COMPLETED",
}

/** How a raw event entered our pipeline. */
export enum RawEventSource {
  WEBHOOK = "webhook",
  POLL = "poll",         // from getLostPushMessage
  RECONCILE = "reconcile", // from daily get_order_list backstop
}

/** Lifecycle of a ShopeeOrderSync row. */
export enum OrderSyncStatus {
  PENDING = "pending",
  CREATED = "created",
  FAILED = "failed",
}
  • [ ] Step 2: Commit (no test — pure enum constants)
git add src/modules/connectors/shopee/types/enums.ts
git commit -m "feat(connectors/shopee): enums (PushCode, OrderStatus, RawEventSource, OrderSyncStatus)"

Task A.4: ShopeeRawEvent + ShopeeOrderSync models

Files: - Create: src/modules/connectors/shopee/models/shopee-raw-event.ts - Create: src/modules/connectors/shopee/models/shopee-order-sync.ts

  • [ ] Step 1: Write failing test (deferred — both models tested in Task A.6 once service exists)

Add a placeholder TODO comment in the spec file you're about to write — actual test in Task A.6.

  • [ ] Step 2: ShopeeRawEvent model
// src/modules/connectors/shopee/models/shopee-raw-event.ts

import { model } from "@medusajs/framework/utils"
import { RawEventSource } from "../types/enums"

const ShopeeRawEvent = model.define("shopee_raw_event", {
  id: model.id({ prefix: "srev" }).primaryKey(),

  // Push code from Shopee (e.g. 3 = ORDER_STATUS_UPDATE). Stored as
  // raw int — we don't constrain to a Postgres enum because Shopee
  // may add new codes and we still want to log them.
  event_type: model.number(),

  // Shopee's per-request unique id (idempotency key for webhooks).
  // Optional because polled / reconciled events don't have one.
  shopee_event_id: model.text().index({ unique: true }).nullable(),

  shop_id: model.bigNumber(),

  // Order serial number — present for order/tracking events.
  ordersn: model.text().index().nullable(),

  // Verbatim payload as received (already JSON.parse()'d for storage).
  payload: model.json(),

  // Raw Authorization header from the webhook. Null for poll/reconcile.
  signature: model.text().nullable(),
  signature_ok: model.boolean().nullable(),

  source: model.enum(RawEventSource),

  processed: model.boolean().default(false),
  processed_at: model.dateTime().nullable(),
}).indexes([
  // Subscriber lookup: find unprocessed events to drain.
  { on: ["event_type", "processed"], where: "processed = false" },
])

export default ShopeeRawEvent
  • [ ] Step 3: ShopeeOrderSync model
// src/modules/connectors/shopee/models/shopee-order-sync.ts

import { model } from "@medusajs/framework/utils"
import { OrderSyncStatus, ShopeeOrderStatus } from "../types/enums"

const ShopeeOrderSync = model.define("shopee_order_sync", {
  id: model.id({ prefix: "sosync" }).primaryKey(),

  // One row per Shopee order.
  ordersn: model.text().index({ unique: true }),

  shop_id: model.bigNumber(),

  // Linked to Medusa `order` via Module Link (src/links/...).
  // Stored here as a text reference to avoid cross-module FK.
  medusa_order_id: model.text().nullable(),

  status: model.enum(OrderSyncStatus).default(OrderSyncStatus.PENDING).index(),

  // Last-known Shopee status. Stored as text not enum because Shopee
  // may surface region-specific values we don't model. We map to the
  // ShopeeOrderStatus enum at read time.
  shopee_status: model.text().index(),

  // Most recent ShopeeRawEvent that updated this row.
  last_raw_event_id: model.text().nullable(),
})

export default ShopeeOrderSync
  • [ ] Step 4: Commit
git add src/modules/connectors/shopee/models/shopee-raw-event.ts \
        src/modules/connectors/shopee/models/shopee-order-sync.ts
git commit -m "feat(connectors/shopee): ShopeeRawEvent + ShopeeOrderSync models"

Task A.5: ShopeeEscrow + ShopeeAuthToken models

Files: - Create: src/modules/connectors/shopee/models/shopee-escrow.ts - Create: src/modules/connectors/shopee/models/shopee-auth-token.ts

  • [ ] Step 1: ShopeeEscrow model

Field names match the SDK's OrderIncome schema verbatim per audit. The originally-drafted names (seller_voucher, shopee_voucher, transaction_fee, buyer_paid_amount) do NOT exist in the SDK response; using them would cause normalization to silently miss data.

// src/modules/connectors/shopee/models/shopee-escrow.ts

import { model } from "@medusajs/framework/utils"

const ShopeeEscrow = model.define("shopee_escrow", {
  id: model.id({ prefix: "sesc" }).primaryKey(),

  // 1:1 with ShopeeOrderSync.ordersn.
  ordersn: model.text().index({ unique: true }),

  // Key field for Phase 4 DCA: what the merchant actually receives.
  escrow_amount: model.bigNumber(),

  original_price: model.bigNumber(),
  seller_discount: model.bigNumber(),
  shopee_discount: model.bigNumber(),

  // Renamed from seller_voucher / shopee_voucher per audit (SDK schema).
  voucher_from_seller: model.bigNumber(),
  voucher_from_shopee: model.bigNumber(),

  commission_fee: model.bigNumber(),
  service_fee: model.bigNumber(),

  // Renamed from transaction_fee per audit.
  seller_transaction_fee: model.bigNumber(),

  actual_shipping_fee: model.bigNumber(),
  seller_lost_compensation: model.bigNumber(),

  // Renamed from buyer_paid_amount per audit.
  buyer_total_amount: model.bigNumber(),

  // Sourced from Order.currency (or hardcoded 'SGD' for the SG shop).
  // Shopee's getEscrowDetail does NOT return a currency field.
  currency: model.text(),

  // Only populated when fetched via getEscrowList(). Null when populated
  // from getEscrowDetail() (the primary path).
  escrow_release_time: model.dateTime().nullable(),

  // Full OrderIncome payload — Phase 4 mines additional fields from
  // here (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.).
  raw_escrow_payload: model.json(),

  fetched_at: model.dateTime(),
})

export default ShopeeEscrow
  • [ ] Step 2: ShopeeAuthToken model
// src/modules/connectors/shopee/models/shopee-auth-token.ts

import { model } from "@medusajs/framework/utils"

/**
 * Backs the custom PostgresTokenStorage in PR B. One row per shop_id.
 * For the single-tenant MVP there will only ever be one row.
 *
 * Replaces the SDK's default file-based storage (which uses
 * synchronous fs.writeFileSync and would block the Medusa event loop
 * on every refresh).
 */
const ShopeeAuthToken = model.define("shopee_auth_token", {
  id: model.id({ prefix: "sat" }).primaryKey(),
  shop_id: model.bigNumber().index({ unique: true }),

  access_token: model.text(),
  refresh_token: model.text(),

  // Seconds until expiry from Shopee response.
  expire_in: model.number(),

  // Epoch ms — set by SDK at write time. SDK uses this for refresh
  // detection (refreshes when expired_at < Date.now()).
  expired_at: model.bigNumber(),

  merchant_id_list: model.json().nullable(),
  shop_id_list: model.json().nullable(),
  supplier_id_list: model.json().nullable(),

  // Verbatim AccessToken object — forward compat for new SDK fields.
  raw_token_payload: model.json(),
})

export default ShopeeAuthToken
  • [ ] Step 3: Commit
git add src/modules/connectors/shopee/models/shopee-escrow.ts \
        src/modules/connectors/shopee/models/shopee-auth-token.ts
git commit -m "feat(connectors/shopee): ShopeeEscrow + ShopeeAuthToken models"

Task A.6: ShopeeConnectorService skeleton + module registration + tests

Files: - Create: src/modules/connectors/shopee/service.ts - Create: src/modules/connectors/shopee/index.ts - Create: src/modules/connectors/shopee/__tests__/service.spec.ts - Modify: medusa-config.ts

  • [ ] Step 1: Write failing test (CRUD across all 4 models)
// src/modules/connectors/shopee/__tests__/service.spec.ts
jest.setTimeout(60 * 1000)

import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { SHOPEE_CONNECTOR_MODULE } from ".."
import ShopeeConnectorService from "../service"
import ShopeeRawEvent from "../models/shopee-raw-event"
import ShopeeOrderSync from "../models/shopee-order-sync"
import ShopeeEscrow from "../models/shopee-escrow"
import ShopeeAuthToken from "../models/shopee-auth-token"
import {
  PushCode,
  RawEventSource,
  OrderSyncStatus,
  ShopeeOrderStatus,
} from "../types/enums"

moduleIntegrationTestRunner<ShopeeConnectorService>({
  moduleName: SHOPEE_CONNECTOR_MODULE,
  moduleModels: [ShopeeRawEvent, ShopeeOrderSync, ShopeeEscrow, ShopeeAuthToken],
  resolve: "./src/modules/connectors/shopee",
  testSuite: ({ service }) => {
    describe("ShopeeConnectorService — model CRUD", () => {
      it("creates ShopeeRawEvent rows", async () => {
        const [row] = await service.createShopeeRawEvents([
          {
            event_type: PushCode.ORDER_STATUS_UPDATE,
            shopee_event_id: "evt_test_001",
            shop_id: 1234567890n,
            ordersn: "240422TEST001",
            payload: { code: 3, data: '{"ordersn":"240422TEST001"}' },
            signature: "SHA256 abc123",
            signature_ok: true,
            source: RawEventSource.WEBHOOK,
          },
        ])
        expect(row.id).toMatch(/^srev_/)
        expect(row.processed).toBe(false)
      })

      it("creates ShopeeOrderSync rows", async () => {
        const [row] = await service.createShopeeOrderSyncs([
          {
            ordersn: "240422TEST002",
            shop_id: 1234567890n,
            shopee_status: ShopeeOrderStatus.UNPAID,
          },
        ])
        expect(row.id).toMatch(/^sosync_/)
        expect(row.status).toBe(OrderSyncStatus.PENDING)
      })

      it("creates ShopeeEscrow rows with renamed fields", async () => {
        const [row] = await service.createShopeeEscrows([
          {
            ordersn: "240422TEST003",
            escrow_amount: 1000,
            original_price: 1200,
            seller_discount: 100,
            shopee_discount: 50,
            voucher_from_seller: 0,
            voucher_from_shopee: 50,
            commission_fee: 30,
            service_fee: 20,
            seller_transaction_fee: 10,
            actual_shipping_fee: 50,
            seller_lost_compensation: 0,
            buyer_total_amount: 1100,
            currency: "SGD",
            raw_escrow_payload: { full: "object" },
            fetched_at: new Date(),
          },
        ])
        expect(row.id).toMatch(/^sesc_/)
        expect(row.voucher_from_seller).toBeDefined()
        expect(row.voucher_from_shopee).toBeDefined()
        expect(row.seller_transaction_fee).toBeDefined()
        expect(row.buyer_total_amount).toBeDefined()
      })

      it("creates ShopeeAuthToken rows", async () => {
        const [row] = await service.createShopeeAuthTokens([
          {
            shop_id: 1234567890n,
            access_token: "tok_access",
            refresh_token: "tok_refresh",
            expire_in: 14400,
            expired_at: BigInt(Date.now() + 14400 * 1000),
            raw_token_payload: { whole: "thing" },
          },
        ])
        expect(row.id).toMatch(/^sat_/)
      })
    })
  },
})
  • [ ] Step 2: Run — should FAIL (module not found)
yarn jest src/modules/connectors/shopee/__tests__/service.spec.ts

Expected: Cannot find module '..'.

  • [ ] Step 3: Implement service skeleton
// src/modules/connectors/shopee/service.ts

import { MedusaService } from "@medusajs/framework/utils"
import ShopeeRawEvent from "./models/shopee-raw-event"
import ShopeeOrderSync from "./models/shopee-order-sync"
import ShopeeEscrow from "./models/shopee-escrow"
import ShopeeAuthToken from "./models/shopee-auth-token"

/**
 * ShopeeConnectorService is the orchestration entry point for the
 * Shopee connector. PR A: factory-only skeleton — gives us the
 * auto-generated CRUD methods (createShopee*, listShopee*, etc.).
 *
 * Business logic methods (processEvent, fetchEscrow, ...) land in
 * later PRs that need them. Keeping the skeleton minimal here lets
 * PR A merge cleanly without dragging in unfinished orchestration.
 */
class ShopeeConnectorService extends MedusaService({
  ShopeeRawEvent,
  ShopeeOrderSync,
  ShopeeEscrow,
  ShopeeAuthToken,
}) {}

export default ShopeeConnectorService
  • [ ] Step 4: Implement module index
// src/modules/connectors/shopee/index.ts

import { Module } from "@medusajs/framework/utils"
import ShopeeConnectorService from "./service"

export const SHOPEE_CONNECTOR_MODULE = "shopeeConnectorService"

export default Module(SHOPEE_CONNECTOR_MODULE, {
  service: ShopeeConnectorService,
})
  • [ ] Step 5: Register module in medusa-config

Edit medusa-config.ts — append to the modules array (after the connectors/shared entry from Task A.2):

{
  resolve: "./src/modules/connectors/shopee",
},
  • [ ] Step 6: Generate migration
yarn medusa db:generate shopeeConnectorService

Expected: a new migration file in src/modules/connectors/shopee/migrations/Migration<TIMESTAMP>.ts creating the four tables (shopee_raw_event, shopee_order_sync, shopee_escrow, shopee_auth_token).

  • [ ] Step 7: Apply + run test
yarn medusa db:migrate
yarn jest src/modules/connectors/shopee/__tests__/service.spec.ts

Expected: 4 migration entries logged, all 4 tests pass.

  • [ ] Step 8: Commit
git add src/modules/connectors/shopee/ medusa-config.ts
git commit -m "feat(connectors/shopee): module registration + service skeleton + 4 model migrations"

Task A.7: TCGChannelListing — enums + model

The Phase 2 findings doc reserved this table for Phase 3 to be shaped by connector needs. Phase 3 only populates it during inbound (cheap upsert when we already have the variant); outbound consumers come in Phase 6.

Files: - Modify: src/modules/tcg/types/enums.ts — add Channel and ListingStatus - Create: src/modules/tcg/models/channel-listing.ts - Modify: src/modules/tcg/service.ts — add TcgChannelListing to factory - Modify: src/modules/tcg/__tests__/service.spec.ts — append CRUD test

  • [ ] Step 1: Add the failing test

Append to src/modules/tcg/__tests__/service.spec.ts inside the existing testSuite:

describe("TcgModuleService.TcgChannelListing — model CRUD", () => {
  it("creates a row scoped to a variant", async () => {
    const [row] = await service.createTcgChannelListings([
      {
        variant_id: "variant_test_001",
        serialized_item_id: null,
        channel: "shopee",
        external_listing_id: "shopee_item_999",
        external_variation_id: "shopee_model_88",
        status: "active",
        last_synced_at: new Date(),
      },
    ])
    expect(row.id).toMatch(/^tcl_/)
    expect(row.channel).toBe("shopee")
  })

  it("creates a row scoped to a serialized item", async () => {
    const [row] = await service.createTcgChannelListings([
      {
        variant_id: null,
        serialized_item_id: "tsi_test_001",
        channel: "telegram",
        external_listing_id: "tg_post_456",
        external_variation_id: null,
        status: "active",
        last_synced_at: new Date(),
      },
    ])
    expect(row.id).toMatch(/^tcl_/)
  })
})

The CHECK constraint test (rejecting both targets set or neither set) lands in Task A.8 since it requires the hand-written migration.

You also need to remember to import the new model at the top of the spec file:

import TcgChannelListing from "../models/channel-listing"

…and add it to the moduleModels array in the runner:

moduleModels: [TcgVariantMetadata, TcgSerializedItem, TcgChannelListing],
  • [ ] Step 2: Add enums

Append to src/modules/tcg/types/enums.ts:

export enum Channel {
  SHOPEE = "shopee",
  LAZADA = "lazada",
  TELEGRAM = "telegram",
  CAROUSELL = "carousell",
  INSTAGRAM = "instagram",
  IN_STORE = "in_store",
  EVENT = "event",
}

export enum ListingStatus {
  ACTIVE = "active",
  INACTIVE = "inactive",
  OUT_OF_STOCK = "out_of_stock",
  PAUSED = "paused",
}
  • [ ] Step 3: Implement the model
// src/modules/tcg/models/channel-listing.ts

import { model } from "@medusajs/framework/utils"
import { Channel, ListingStatus } from "../types/enums"

/**
 * Polymorphic listing record — one row per (channel, target). Target
 * is either a fungible variant or a serialized item, never both. The
 * CHECK constraint that enforces this lives in the hand-written
 * migration in Task A.8 (model.define cannot express it).
 *
 * Phase 3 populates this during inbound. Phase 6 outbound (push price
 * / stock to channels) reads it. Inbound matching uses model_sku
 * directly — this table is NOT consulted to resolve incoming orders.
 */
const TcgChannelListing = model.define("tcg_channel_listing", {
  id: model.id({ prefix: "tcl" }).primaryKey(),

  // Exactly one of these is non-null. CHECK constraint enforces.
  variant_id: model.text().nullable(),
  serialized_item_id: model.text().nullable(),

  channel: model.enum(Channel),

  // External marketplace IDs — for Shopee these are item_id and model_id.
  external_listing_id: model.text(),
  external_variation_id: model.text().nullable(),

  listing_url: model.text().nullable(),

  status: model.enum(ListingStatus).default(ListingStatus.ACTIVE),

  last_synced_at: model.dateTime(),
}).indexes([
  // Lookup by marketplace ID — used by future outbound code in Phase 6.
  { on: ["channel", "external_listing_id", "external_variation_id"] },
])

export default TcgChannelListing
  • [ ] Step 4: Wire into TcgModuleService

Edit src/modules/tcg/service.ts:

import TcgChannelListing from "./models/channel-listing"

class TcgModuleService extends MedusaService({
  TcgVariantMetadata,
  TcgSerializedItem,
  TcgChannelListing,
}) {
  // existing reserveSerializedItem method unchanged
  ...
}
  • [ ] Step 5: Generate migration + run test (CHECK constraint test still fails — comes in A.8)
yarn medusa db:generate tcgModuleService
yarn medusa db:migrate
yarn jest src/modules/tcg/__tests__/service.spec.ts

Expected: 1 new migration file in src/modules/tcg/migrations/ creating tcg_channel_listing. Both new test cases pass.

  • [ ] Step 6: Commit
git add src/modules/tcg/
git commit -m "feat(tcg): TcgChannelListing model (Phase 2 deferral)"

Task A.8: TCGChannelListing CHECK constraint (hand-written migration)

Files: - Create: src/modules/tcg/migrations/Migration<TIMESTAMP>_add_channel_listing_check.ts - Modify: src/modules/tcg/__tests__/service.spec.ts — append constraint enforcement tests

  • [ ] Step 1: Add the failing constraint tests

Append inside the existing TcgChannelListing describe:

it("rejects rows with BOTH variant_id and serialized_item_id set", async () => {
  await expect(
    service.createTcgChannelListings([
      {
        variant_id: "variant_x",
        serialized_item_id: "tsi_y",
        channel: "shopee",
        external_listing_id: "should_fail",
        external_variation_id: null,
        status: "active",
        last_synced_at: new Date(),
      },
    ])
  ).rejects.toThrow(/check constraint|exactly one/i)
})

it("rejects rows with NEITHER variant_id nor serialized_item_id set", async () => {
  await expect(
    service.createTcgChannelListings([
      {
        variant_id: null,
        serialized_item_id: null,
        channel: "shopee",
        external_listing_id: "also_fails",
        external_variation_id: null,
        status: "active",
        last_synced_at: new Date(),
      },
    ])
  ).rejects.toThrow(/check constraint|exactly one/i)
})
  • [ ] Step 2: Run — should FAIL (constraint not added yet)
yarn jest src/modules/tcg/__tests__/service.spec.ts

Expected: both new tests fail because the rows insert successfully (no constraint).

  • [ ] Step 3: Hand-write the constraint migration

Find the timestamp the previous auto-migration used (look in src/modules/tcg/migrations/), increment by one second for ordering. Example timestamp: Migration20260423120001.

// src/modules/tcg/migrations/Migration20260423120001_add_channel_listing_check.ts

import { Migration } from "@mikro-orm/migrations"

export class Migration20260423120001 extends Migration {
  override async up(): Promise<void> {
    this.addSql(`
      ALTER TABLE "tcg_channel_listing"
      ADD CONSTRAINT "tcg_channel_listing_one_target_check"
      CHECK ((variant_id IS NULL) != (serialized_item_id IS NULL))
    `)
  }

  override async down(): Promise<void> {
    this.addSql(`
      ALTER TABLE "tcg_channel_listing"
      DROP CONSTRAINT "tcg_channel_listing_one_target_check"
    `)
  }
}
  • [ ] Step 4: Apply + run tests
yarn medusa db:migrate
yarn jest src/modules/tcg/__tests__/service.spec.ts

Expected: migration applies cleanly. All TcgChannelListing tests pass (including the two constraint enforcement cases).

  • [ ] Step 5: Commit
git add src/modules/tcg/migrations/ src/modules/tcg/__tests__/service.spec.ts
git commit -m "feat(tcg): CHECK constraint for TcgChannelListing.exactly_one_target"

Files: - Create: src/links/shopee-order-sync-medusa-order.ts

  • [ ] Step 1: Implement the link (1:1)

Pattern follows src/links/product-variant-tcg-metadata.ts:

// src/links/shopee-order-sync-medusa-order.ts

import ShopeeConnectorModule from "../modules/connectors/shopee"
import OrderModule from "@medusajs/medusa/order"
import { defineLink } from "@medusajs/framework/utils"

export default defineLink(
  {
    linkable: ShopeeConnectorModule.linkable.shopeeOrderSync,
    deleteCascade: false,  // deleting a Medusa order shouldn't auto-delete sync history
  },
  OrderModule.linkable.order
)
  • [ ] Step 2: Sync links + verify
yarn medusa db:sync-links
yarn medusa db:migrate

Expected: log line Created following links tables ... shopeeConnectorService.shopee_order_sync <> order.order ....

  • [ ] Step 3: Commit
git add src/links/shopee-order-sync-medusa-order.ts
git commit -m "feat(links): ShopeeOrderSync <> Medusa order (1:1)"

Files: - Create: src/links/tcg-channel-listing-product-variant.ts

  • [ ] Step 1: Implement the link (N:1)
// src/links/tcg-channel-listing-product-variant.ts

import TcgModule from "../modules/tcg"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"

/**
 * N:1 link: many TcgChannelListing rows can point to one productVariant.
 * Phase 6 outbound code reads variant → channel listings via this link to
 * know which marketplace IDs to push price/stock changes to.
 *
 * Note: variant_id is nullable on TcgChannelListing (the row may instead
 * point at a TcgSerializedItem via the link in Task A.11). This link
 * applies only when variant_id is set.
 */
export default defineLink(
  {
    linkable: TcgModule.linkable.tcgChannelListing,
    isList: true,  // many listings per variant
  },
  ProductModule.linkable.productVariant
)
  • [ ] Step 2: Sync + verify
yarn medusa db:sync-links
  • [ ] Step 3: Commit
git add src/links/tcg-channel-listing-product-variant.ts
git commit -m "feat(links): TcgChannelListing -> productVariant (N:1)"

Files: - Create: src/links/tcg-channel-listing-serialized-item.ts

  • [ ] Step 1: Implement the link
// src/links/tcg-channel-listing-serialized-item.ts

import TcgModule from "../modules/tcg"
import { defineLink } from "@medusajs/framework/utils"

/**
 * N:1 link: many TcgChannelListing rows can point to one TcgSerializedItem
 * (e.g. the same graded slab listed on Telegram + Carousell). Both ends
 * of this link live in the tcg module — this is intra-module rather than
 * cross-module. Still defined as a link for consistency with the variant
 * link, and so query.graph traversal works uniformly.
 */
export default defineLink(
  {
    linkable: TcgModule.linkable.tcgChannelListing,
    isList: true,
  },
  TcgModule.linkable.tcgSerializedItem
)
  • [ ] Step 2: Sync + verify
yarn medusa db:sync-links
  • [ ] Step 3: Commit
git add src/links/tcg-channel-listing-serialized-item.ts
git commit -m "feat(links): TcgChannelListing -> TcgSerializedItem (N:1)"

Task A.12: PR A — verify, push, open PR

  • [ ] Step 1: Final fresh-DB validation
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.dev.yml up -d
# wait ~5s for postgres to be ready
yarn medusa db:migrate
yarn medusa db:sync-links
yarn jest src/modules/connectors/ src/modules/tcg/

Expected: - All 5 new migrations apply cleanly (connector_exception, 4 shopee tables, tcg_channel_listing). - The hand-written CHECK constraint migration applies. - All link tables created (logged in Created following links tables ...). - All shared + shopee + tcg tests pass.

  • [ ] Step 2: Verify schema in Postgres directly
docker compose -f docker-compose.dev.yml exec postgres \
  psql -U medusa -d medusa -c "\dt connector_*"
docker compose -f docker-compose.dev.yml exec postgres \
  psql -U medusa -d medusa -c "\dt shopee_*"
docker compose -f docker-compose.dev.yml exec postgres \
  psql -U medusa -d medusa -c "\dt tcg_channel*"
docker compose -f docker-compose.dev.yml exec postgres \
  psql -U medusa -d medusa -c \
    "SELECT conname FROM pg_constraint WHERE conname = 'tcg_channel_listing_one_target_check';"

Expected: 1 connector_exception table, 4 shopee tables, 1 tcg_channel_listing table, the CHECK constraint exists.

  • [ ] Step 3: Push + open PR
git push -u origin draft/claude-phase-3-pr-a-scaffold

gh pr create --base main --head draft/claude-phase-3-pr-a-scaffold \
  --title "feat(connectors): Phase 3 PR A — scaffold + models + module links + migrations" \
  --body "$(cat <<'EOF'
## Summary

First of 5 PRs implementing the Phase 3 Shopee inbound connector. This PR lays the data foundation — no business logic yet.

Spec: [Phase 3 Kickoff Plan](https://github.com/ExzenTCG/ExzenTCG-Homelab/blob/main/tcg-platform/phase-3-plan.md)
Implementation plan: [phase-3-implementation.md](https://github.com/ExzenTCG/ExzenTCG-Homelab/blob/main/tcg-platform/phase-3-implementation.md) (PR A section)

## What landed

- New module \`src/modules/connectors/shared/\` — \`ConnectorException\` model + service
- New module \`src/modules/connectors/shopee/\` — 4 models (\`ShopeeRawEvent\`, \`ShopeeOrderSync\`, \`ShopeeEscrow\`, \`ShopeeAuthToken\`) + service skeleton
- \`src/modules/tcg/\` extended with \`TcgChannelListing\` (Phase 2 deferral)
- 3 Module Links: \`ShopeeOrderSync ↔ order\`, \`TcgChannelListing → variant\`, \`TcgChannelListing → serialized item\`
- 5 auto-generated migrations + 1 hand-written CHECK constraint
- Module CRUD tests for all new models
- \`medusa-config.ts\` registers the two new modules

## Field name notes (audit-driven)

\`ShopeeEscrow\` field names match the SDK's \`OrderIncome\` schema verbatim per the audit at \`/tmp/shopee-audit-2026-04-23.md\`. Specifically: \`voucher_from_seller\`, \`voucher_from_shopee\`, \`seller_transaction_fee\`, \`buyer_total_amount\` (NOT the originally-drafted \`seller_voucher\` / \`shopee_voucher\` / \`transaction_fee\` / \`buyer_paid_amount\`). \`payout_currency\` is absent from the response — the model has a generic \`currency\` field sourced from \`Order.currency\` instead.

## Test plan

- [ ] Fresh-DB rebuild: \`docker compose -f docker-compose.dev.yml down -v && docker compose -f docker-compose.dev.yml up -d\` then \`yarn medusa db:migrate && yarn medusa db:sync-links\` runs cleanly.
- [ ] \`yarn jest src/modules/connectors/ src/modules/tcg/\` passes.
- [ ] Schema verification queries (\`\\dt shopee_*\`, etc.) return expected tables.
- [ ] CHECK constraint enforced (test cases in \`tcg/__tests__/service.spec.ts\`).
- [ ] CI green.

## Next

PR B — SDK wrapper + \`PostgresTokenStorage\` + OAuth bootstrap.
EOF
)"
  • [ ] Step 2 (after PR opens): Wait for CI + merge
gh pr checks <pr-number> --watch
gh pr merge <pr-number> --merge --delete-branch

PR A done. Move to PR B in a fresh worktree.


PR B — SDK wrapper + Postgres token storage + OAuth bootstrap

Goal: SDK is callable; tokens persist across restarts. After this PR, an operator can run yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code> and the connector authenticates against Shopee on every subsequent boot without re-running OAuth.

Branch: draft/claude-phase-3-pr-b-sdk-auth from tcg-platform/main (which now has PR A).

Setup: cd /tmp && rm -rf tcg-platform-prb && git clone https://github.com/ExzenTCG/tcg-platform.git tcg-platform-prb && cd tcg-platform-prb && git checkout -b draft/claude-phase-3-pr-b-sdk-auth

Task B.1: Add SDK dependency

Files: - Modify: package.json

  • [ ] Step 1: Install + verify
yarn add @congminh1254/shopee-sdk@^1.5.5

Verify in package.json dependencies. Re-run yarn install --immutable to confirm lock-file is consistent.

  • [ ] Step 2: Smoke test that the import resolves
node -e "const sdk = require('@congminh1254/shopee-sdk'); console.log(Object.keys(sdk).slice(0, 10))"

Expected: an array including default (the ShopeeSDK class), ShopeeRegion, TokenStorage (re-exported types), etc. If require fails: SDK is CJS-only; the workspace must consume via require/dynamic-import — flag for risk register.

  • [ ] Step 3: Commit
git add package.json yarn.lock
git commit -m "build: add @congminh1254/shopee-sdk dependency"

Task B.2: PostgresTokenStorage — failing test

Files: - Create: src/modules/connectors/shopee/__tests__/postgres-token-storage.spec.ts

  • [ ] Step 1: Write the failing round-trip test
// src/modules/connectors/shopee/__tests__/postgres-token-storage.spec.ts
jest.setTimeout(60 * 1000)

import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { SHOPEE_CONNECTOR_MODULE } from ".."
import ShopeeConnectorService from "../service"
import ShopeeRawEvent from "../models/shopee-raw-event"
import ShopeeOrderSync from "../models/shopee-order-sync"
import ShopeeEscrow from "../models/shopee-escrow"
import ShopeeAuthToken from "../models/shopee-auth-token"
import { PostgresTokenStorage } from "../auth/postgres-token-storage"
import type { AccessToken } from "@congminh1254/shopee-sdk"

const SHOP_ID = 1234567890

moduleIntegrationTestRunner<ShopeeConnectorService>({
  moduleName: SHOPEE_CONNECTOR_MODULE,
  moduleModels: [ShopeeRawEvent, ShopeeOrderSync, ShopeeEscrow, ShopeeAuthToken],
  resolve: "./src/modules/connectors/shopee",
  testSuite: ({ service }) => {
    describe("PostgresTokenStorage", () => {
      it("get() returns null when no token stored", async () => {
        const storage = new PostgresTokenStorage(service, SHOP_ID)
        const token = await storage.get()
        expect(token).toBeNull()
      })

      it("store() then get() returns the same token shape", async () => {
        const storage = new PostgresTokenStorage(service, SHOP_ID)
        const input: AccessToken = {
          access_token: "atok_abc123",
          refresh_token: "rtok_def456",
          expire_in: 14400,
          request_id: "req_xyz",
          error: "",
          message: "",
          shop_id: SHOP_ID,
          expired_at: Date.now() + 14400 * 1000,
        }

        await storage.store(input)
        const out = await storage.get()

        expect(out).not.toBeNull()
        expect(out!.access_token).toBe("atok_abc123")
        expect(out!.refresh_token).toBe("rtok_def456")
        expect(out!.shop_id).toBe(SHOP_ID)
      })

      it("store() upserts (a second store overwrites the first)", async () => {
        const storage = new PostgresTokenStorage(service, SHOP_ID)
        await storage.store({
          access_token: "first",
          refresh_token: "first_r",
          expire_in: 100,
          request_id: "r1",
          error: "",
          message: "",
          shop_id: SHOP_ID,
          expired_at: Date.now(),
        })
        await storage.store({
          access_token: "second",
          refresh_token: "second_r",
          expire_in: 200,
          request_id: "r2",
          error: "",
          message: "",
          shop_id: SHOP_ID,
          expired_at: Date.now(),
        })
        const out = await storage.get()
        expect(out!.access_token).toBe("second")
      })

      it("clear() removes the token", async () => {
        const storage = new PostgresTokenStorage(service, SHOP_ID)
        await storage.store({
          access_token: "doomed",
          refresh_token: "doomed_r",
          expire_in: 100,
          request_id: "r",
          error: "",
          message: "",
          shop_id: SHOP_ID,
          expired_at: Date.now(),
        })
        await storage.clear()
        const out = await storage.get()
        expect(out).toBeNull()
      })
    })
  },
})
  • [ ] Step 2: Run — should FAIL
yarn jest src/modules/connectors/shopee/__tests__/postgres-token-storage.spec.ts

Expected: Cannot find module '../auth/postgres-token-storage'.

Task B.3: Implement PostgresTokenStorage

Files: - Create: src/modules/connectors/shopee/auth/postgres-token-storage.ts

  • [ ] Step 1: Implement
// src/modules/connectors/shopee/auth/postgres-token-storage.ts

import type { AccessToken, TokenStorage } from "@congminh1254/shopee-sdk"
import type ShopeeConnectorService from "../service"

/**
 * Postgres-backed implementation of the SDK's TokenStorage interface.
 *
 * The SDK's default storage uses synchronous fs.writeFileSync — this
 * would block the Medusa event loop on every refresh (refreshes happen
 * implicitly before each authenticated API call when the token is
 * within its expiry window). Postgres-backed storage is async all the
 * way down and persists across container rebuilds without volume
 * management.
 *
 * One row per shop_id in the `shopee_auth_token` table. For the
 * single-tenant MVP there's only ever one row.
 */
export class PostgresTokenStorage implements TokenStorage {
  constructor(
    private readonly service: ShopeeConnectorService,
    private readonly shopId: number,
  ) {}

  async store(token: AccessToken): Promise<void> {
    const existing = await this.findRow()
    const row = {
      shop_id: BigInt(this.shopId),
      access_token: token.access_token,
      refresh_token: token.refresh_token,
      expire_in: token.expire_in,
      expired_at: BigInt(token.expired_at ?? Date.now() + token.expire_in * 1000),
      merchant_id_list: token.merchant_id_list ?? null,
      shop_id_list: token.shop_id_list ?? null,
      supplier_id_list: token.supplier_id_list ?? null,
      raw_token_payload: token,
    }

    if (existing) {
      await this.service.updateShopeeAuthTokens([{ id: existing.id, ...row }])
    } else {
      await this.service.createShopeeAuthTokens([row])
    }
  }

  async get(): Promise<AccessToken | null> {
    const row = await this.findRow()
    if (!row) return null

    // Reconstitute AccessToken from the raw payload — preserves any
    // SDK-internal fields we don't model as columns.
    return {
      ...(row.raw_token_payload as AccessToken),
      access_token: row.access_token,
      refresh_token: row.refresh_token,
      expire_in: row.expire_in,
      expired_at: Number(row.expired_at),
      shop_id: Number(row.shop_id),
    }
  }

  async clear(): Promise<void> {
    const row = await this.findRow()
    if (row) {
      await this.service.deleteShopeeAuthTokens([row.id])
    }
  }

  private async findRow() {
    const rows = await this.service.listShopeeAuthTokens(
      { shop_id: BigInt(this.shopId) },
      { take: 1 },
    )
    return rows[0]
  }
}
  • [ ] Step 2: Run — should PASS
yarn jest src/modules/connectors/shopee/__tests__/postgres-token-storage.spec.ts

Expected: all 4 cases pass.

  • [ ] Step 3: Commit
git add src/modules/connectors/shopee/auth/ src/modules/connectors/shopee/__tests__/postgres-token-storage.spec.ts
git commit -m "feat(connectors/shopee): PostgresTokenStorage backed by ShopeeAuthToken"

Task B.4: ShopeeSdk singleton wrapper

Files: - Create: src/modules/connectors/shopee/sdk.ts

  • [ ] Step 1: Implement the wrapper
// src/modules/connectors/shopee/sdk.ts

import ShopeeSDK, { ShopeeRegion } from "@congminh1254/shopee-sdk"
import { PostgresTokenStorage } from "./auth/postgres-token-storage"
import type ShopeeConnectorService from "./service"

let sdkInstance: ShopeeSDK | null = null

/**
 * Lazy singleton — initialized on first call. Construction needs the
 * ShopeeConnectorService (for PostgresTokenStorage), so it can't be a
 * module-level const.
 *
 * Region note: SG uses ShopeeRegion.GLOBAL. The SDK source defines only
 * 5 regions (GLOBAL, CHINA, BRAZIL, TEST_GLOBAL, TEST_CHINA) — no
 * ShopeeRegion.SG. The README claims 12 regions but the source is the
 * authoritative reference; for SG the GLOBAL endpoint is correct. See
 * audit at /tmp/shopee-audit-2026-04-23.md section 8.
 */
export function getShopeeSdk(service: ShopeeConnectorService): ShopeeSDK {
  if (sdkInstance) return sdkInstance

  const partnerId = Number(process.env.SHOPEE_PARTNER_ID)
  const partnerKey = process.env.SHOPEE_PARTNER_KEY
  const shopId = Number(process.env.SHOPEE_SHOP_ID)

  if (!partnerId || !partnerKey || !shopId) {
    throw new Error(
      "Shopee SDK requires SHOPEE_PARTNER_ID, SHOPEE_PARTNER_KEY, " +
      "SHOPEE_SHOP_ID env vars (set in .env.development for dev, " +
      "docker-compose.yml environment block for staging)",
    )
  }

  sdkInstance = new ShopeeSDK(
    {
      partner_id: partnerId,
      partner_key: partnerKey,
      region: ShopeeRegion.GLOBAL,  // SG = GLOBAL endpoint
      shop_id: shopId,
    },
    new PostgresTokenStorage(service, shopId),
  )

  return sdkInstance
}

/** Test-only — resets the singleton between integration test runs. */
export function __resetShopeeSdkForTesting(): void {
  sdkInstance = null
}
  • [ ] Step 2: Add env var template entries

Edit .env.development.example (and .env.example) — append:

# Shopee Open Platform — in-house seller app credentials.
# Get from the merchant's Shopee partner portal.
SHOPEE_PARTNER_ID=
SHOPEE_PARTNER_KEY=
SHOPEE_SHOP_ID=

Edit docker-compose.yml medusa.environment — add the three vars (use ${SHOPEE_PARTNER_ID} substitution so values come from the host environment, not committed).

  • [ ] Step 3: Commit
git add src/modules/connectors/shopee/sdk.ts .env.development.example .env.example docker-compose.yml
git commit -m "feat(connectors/shopee): SDK singleton wrapper with PostgresTokenStorage + region GLOBAL"

Task B.5: OAuth bootstrap script

Files: - Create: src/scripts/shopee-oauth-bootstrap.ts

  • [ ] Step 1: Implement
// src/scripts/shopee-oauth-bootstrap.ts

import { ExecArgs } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { SHOPEE_CONNECTOR_MODULE } from "../modules/connectors/shopee"
import { getShopeeSdk, __resetShopeeSdkForTesting } from "../modules/connectors/shopee/sdk"

/**
 * One-shot OAuth bootstrap. Run after the operator completes Shopee's
 * OAuth dance using the in-house seller app and obtains an `auth_code`:
 *
 *   yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code>
 *
 * The auth_code is single-use and short-lived (~10 minutes per Shopee).
 * This script exchanges it for an access_token + refresh_token pair via
 * the SDK, which writes them to the `shopee_auth_token` table via
 * PostgresTokenStorage. Subsequent SDK calls auto-refresh from this row.
 */
export default async function shopeeOauthBootstrap({ args, container }: ExecArgs) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const [authCode] = args

  if (!authCode) {
    logger.error(
      "[shopee-oauth-bootstrap] Missing auth_code argument.\n" +
      "Usage: yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code>"
    )
    process.exit(1)
  }

  const service = container.resolve(SHOPEE_CONNECTOR_MODULE)

  // Reset any stale singleton (matters if this script is re-run).
  __resetShopeeSdkForTesting()
  const sdk = getShopeeSdk(service)

  logger.info(`[shopee-oauth-bootstrap] Exchanging auth_code for tokens (shop_id=${process.env.SHOPEE_SHOP_ID})...`)

  try {
    const result = await sdk.authenticateWithCode(authCode)
    // result is the AccessToken — already persisted by PostgresTokenStorage
    // as a side effect of authenticateWithCode.
    logger.info(
      `[shopee-oauth-bootstrap] Success. access_token expires in ${result.expire_in}s. ` +
      `Token persisted to shopee_auth_token (shop_id=${result.shop_id ?? "(none)"}).`
    )
  } catch (err: any) {
    logger.error(`[shopee-oauth-bootstrap] FAILED: ${err.message ?? err}`)
    process.exit(1)
  }
}
  • [ ] Step 2: Diagnostic / status script

Also create src/scripts/shopee-oauth-status.ts:

// src/scripts/shopee-oauth-status.ts

import { ExecArgs } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { SHOPEE_CONNECTOR_MODULE } from "../modules/connectors/shopee"

/**
 * Diagnostic — prints the current ShopeeAuthToken row state without
 * touching the live API.
 *
 *   yarn medusa exec ./src/scripts/shopee-oauth-status.ts
 */
export default async function shopeeOauthStatus({ container }: ExecArgs) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const service: any = container.resolve(SHOPEE_CONNECTOR_MODULE)

  const rows = await service.listShopeeAuthTokens({})
  if (rows.length === 0) {
    logger.info("[shopee-oauth-status] No ShopeeAuthToken rows. Bootstrap with shopee-oauth-bootstrap.ts <auth_code>.")
    return
  }

  for (const row of rows) {
    const expiresAtMs = Number(row.expired_at)
    const remainingS = Math.floor((expiresAtMs - Date.now()) / 1000)
    logger.info(
      `[shopee-oauth-status] shop_id=${row.shop_id} ` +
      `access_token=${row.access_token.slice(0, 8)}... ` +
      `refresh_token=${row.refresh_token.slice(0, 8)}... ` +
      `expires_in=${remainingS}s (${remainingS < 0 ? "EXPIRED" : "valid"})`
    )
  }
}
  • [ ] Step 3: Commit
git add src/scripts/shopee-oauth-bootstrap.ts src/scripts/shopee-oauth-status.ts
git commit -m "feat(scripts): Shopee OAuth bootstrap + status diagnostic scripts"

Task B.6: README — Shopee OAuth bootstrap section

Files: - Modify: README.md

  • [ ] Step 1: Append section

Add a new section after the existing "Local development" section:

### Shopee OAuth bootstrap

The Shopee connector requires three env vars (set in `.env.development` for local dev or in `docker-compose.yml` env block for staging):
SHOPEE_PARTNER_ID=... SHOPEE_PARTNER_KEY=... SHOPEE_SHOP_ID=...
After setting them, complete Shopee's OAuth dance (use the in-house seller app's authorization URL — see Shopee Open Platform docs) to obtain an `auth_code` (single-use, ~10 minutes). Then:

```bash
yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code>

This exchanges the code for an access_token + refresh_token pair via the SDK and persists them to the shopee_auth_token table. Subsequent SDK calls (webhooks, polling, escrow fetch) auto-refresh from this row — no further OAuth action needed unless the refresh_token itself expires (Shopee documents this as ~30 days but in practice tokens stay valid as long as the connector keeps refreshing).

To check current token state:

yarn medusa exec ./src/scripts/shopee-oauth-status.ts
- [ ] **Step 2: Commit**

```bash
git add README.md
git commit -m "docs: Shopee OAuth bootstrap section in README"

Task B.7: PR B — verify, push, open PR

  • [ ] Step 1: Test full PR
yarn jest src/modules/connectors/shopee/

Expected: token-storage tests + service CRUD tests all pass.

  • [ ] Step 2: Smoke test the singleton
# requires SHOPEE_* env vars set (use placeholder values for the smoke test)
SHOPEE_PARTNER_ID=999 SHOPEE_PARTNER_KEY=test SHOPEE_SHOP_ID=111 \
  node -e "
    const { getShopeeSdk } = require('./.medusa/server/src/modules/connectors/shopee/sdk');
    console.log('sdk module exports:', Object.keys(require('./.medusa/server/src/modules/connectors/shopee/sdk')));
  "

(Note: requires yarn medusa build first if running outside yarn dev. Skip this step if it's awkward — the integration tests cover the singleton path.)

  • [ ] Step 3: Push + open PR
git push -u origin draft/claude-phase-3-pr-b-sdk-auth
gh pr create --base main --head draft/claude-phase-3-pr-b-sdk-auth \
  --title "feat(connectors/shopee): Phase 3 PR B — SDK wrapper + Postgres token storage + OAuth bootstrap" \
  --body "$(cat <<'EOF'
## Summary

Second of 5 PRs implementing Phase 3. Wires the SDK and replaces its default file-based token storage (sync I/O) with Postgres-backed storage. After this PR, an operator can OAuth-bootstrap once and the connector authenticates from then on with no further manual intervention.

## What landed

- \`@congminh1254/shopee-sdk\` v1.5.5+ added as dep
- \`PostgresTokenStorage\` implements the SDK's \`TokenStorage\` interface (CRUD against \`ShopeeAuthToken\` table from PR A)
- \`getShopeeSdk()\` lazy singleton with \`region: ShopeeRegion.GLOBAL\` (SG uses GLOBAL endpoint per SDK source)
- \`shopee-oauth-bootstrap.ts\` script — single-shot \`auth_code\` → token exchange
- \`shopee-oauth-status.ts\` script — diagnostic for current token state
- \`.env.development.example\` + \`.env.example\` + \`docker-compose.yml\` updated with \`SHOPEE_PARTNER_ID\` / \`SHOPEE_PARTNER_KEY\` / \`SHOPEE_SHOP_ID\` (values not committed — set per environment)
- README OAuth bootstrap section

## Test plan

- [ ] \`yarn jest src/modules/connectors/shopee/\` passes (token storage round-trip + upsert + clear)
- [ ] Manual smoke: with valid env vars, \`yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <code>\` writes a row; \`shopee-oauth-status.ts\` shows it
- [ ] CI green

## Next

PR C — webhook endpoint + signature verification + alerting.
EOF
)"
  • [ ] Step 4: Wait for CI + merge
gh pr checks <pr-number> --watch
gh pr merge <pr-number> --merge --delete-branch

PR C — Webhook endpoint + signature verification + alerting

Goal: Inbound surface opens. After this PR, Shopee can POST to https://tcg-staging.exzentcg.com/connectors/shopee/webhook, signature is verified, raw event lands in shopee_raw_event. No order creation yet (that's PR D); just storage + alert plumbing.

Branch: draft/claude-phase-3-pr-c-webhook from tcg-platform/main (which now has PRs A and B merged).

Setup: cd /tmp && rm -rf tcg-platform-prc && git clone https://github.com/ExzenTCG/tcg-platform.git tcg-platform-prc && cd tcg-platform-prc && git checkout -b draft/claude-phase-3-pr-c-webhook

Task C.1: verifyWebhookSignature helper — failing test

Files: - Create: src/modules/connectors/shopee/__tests__/verify-signature.spec.ts

  • [ ] Step 1: Write tests
// src/modules/connectors/shopee/__tests__/verify-signature.spec.ts

import crypto from "node:crypto"
import { verifyWebhookSignature } from "../webhook/verify-signature"

const PARTNER_KEY = "test-partner-key-do-not-use-in-prod"

function signBody(body: string, key: string = PARTNER_KEY): string {
  return crypto.createHmac("sha256", key).update(body).digest("hex")
}

describe("verifyWebhookSignature", () => {
  const body = JSON.stringify({ code: 3, shop_id: 123, timestamp: 1700000000, data: '{"ordersn":"abc"}' })

  it("returns true for a correctly signed body", () => {
    const sig = `SHA256 ${signBody(body)}`
    expect(verifyWebhookSignature(body, sig, PARTNER_KEY)).toBe(true)
  })

  it("returns false when the body is tampered", () => {
    const tampered = body.replace("123", "999")
    const sig = `SHA256 ${signBody(body)}`
    expect(verifyWebhookSignature(tampered, sig, PARTNER_KEY)).toBe(false)
  })

  it("returns false when the partner_key is wrong", () => {
    const sig = `SHA256 ${signBody(body, "wrong-key")}`
    expect(verifyWebhookSignature(body, sig, PARTNER_KEY)).toBe(false)
  })

  it("returns false when the Authorization header is missing the SHA256 prefix", () => {
    const sig = signBody(body)  // no prefix
    expect(verifyWebhookSignature(body, sig, PARTNER_KEY)).toBe(false)
  })

  it("returns false when the Authorization header is undefined", () => {
    expect(verifyWebhookSignature(body, undefined, PARTNER_KEY)).toBe(false)
  })

  it("returns false when the Authorization header is empty string", () => {
    expect(verifyWebhookSignature(body, "", PARTNER_KEY)).toBe(false)
  })
})
  • [ ] Step 2: Run — should FAIL
yarn jest src/modules/connectors/shopee/__tests__/verify-signature.spec.ts

Expected: Cannot find module '../webhook/verify-signature'.

Task C.2: Implement verifyWebhookSignature

Files: - Create: src/modules/connectors/shopee/webhook/verify-signature.ts

  • [ ] Step 1: Implement
// src/modules/connectors/shopee/webhook/verify-signature.ts

import crypto from "node:crypto"

/**
 * Verifies a Shopee Push webhook signature.
 *
 * Algorithm (per audit): HMAC-SHA256(partner_key, raw_body). Body only —
 * NOT url+body. Returned in the `Authorization` header in the format
 * `SHA256 <hex_digest>`.
 *
 * The SDK's PushManager does NOT ship a verification helper, so we
 * implement it here. Constant-time comparison prevents timing attacks.
 */
export function verifyWebhookSignature(
  rawBody: string,
  authorizationHeader: string | undefined,
  partnerKey: string,
): boolean {
  if (!authorizationHeader || !authorizationHeader.startsWith("SHA256 ")) {
    return false
  }

  const provided = authorizationHeader.slice("SHA256 ".length)
  const expected = crypto
    .createHmac("sha256", partnerKey)
    .update(rawBody)
    .digest("hex")

  // Length check first — timingSafeEqual throws on length mismatch.
  if (provided.length !== expected.length) return false

  try {
    return crypto.timingSafeEqual(
      Buffer.from(provided, "hex"),
      Buffer.from(expected, "hex"),
    )
  } catch {
    return false
  }
}
  • [ ] Step 2: Run — should PASS
yarn jest src/modules/connectors/shopee/__tests__/verify-signature.spec.ts

Expected: all 6 cases pass.

  • [ ] Step 3: Commit
git add src/modules/connectors/shopee/webhook/ src/modules/connectors/shopee/__tests__/verify-signature.spec.ts
git commit -m "feat(connectors/shopee): verifyWebhookSignature (HMAC-SHA256 of body, body only)"

Task C.3: Connector alerting helper — failing test

Files: - Create: src/modules/connectors/shared/__tests__/alerting.spec.ts

  • [ ] Step 1: Write tests
// src/modules/connectors/shared/__tests__/alerting.spec.ts

import { publishConnectorAlert, AlertPayload } from "../alerting"

const ORIGINAL_ENV = process.env

describe("publishConnectorAlert", () => {
  let fetchMock: jest.Mock

  beforeEach(() => {
    fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 })
    global.fetch = fetchMock as any
    process.env = { ...ORIGINAL_ENV }
  })

  afterEach(() => {
    process.env = ORIGINAL_ENV
  })

  const samplePayload: AlertPayload = {
    connector: "shopee",
    error_class: "sku_not_found",
    ordersn: "240422TEST",
    shop_id: 1234567890,
    message: "SKU 'X' not found",
    exception_id: "cex_test",
    raw_event_id: "srev_test",
    occurred_at: "2026-04-23T08:15:30Z",
  }

  it("POSTs JSON to SHOPEE_ALERT_WEBHOOK_URL when set", async () => {
    process.env.SHOPEE_ALERT_WEBHOOK_URL = "https://n8n.example/webhook/shopee"

    await publishConnectorAlert(samplePayload)

    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(fetchMock).toHaveBeenCalledWith(
      "https://n8n.example/webhook/shopee",
      expect.objectContaining({
        method: "POST",
        headers: expect.objectContaining({ "content-type": "application/json" }),
        body: JSON.stringify(samplePayload),
      }),
    )
  })

  it("logs to stderr without throwing when SHOPEE_ALERT_WEBHOOK_URL is unset", async () => {
    delete process.env.SHOPEE_ALERT_WEBHOOK_URL
    const stderrSpy = jest.spyOn(console, "warn").mockImplementation()

    await publishConnectorAlert(samplePayload)

    expect(fetchMock).not.toHaveBeenCalled()
    expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("[connector-alert]"))
    stderrSpy.mockRestore()
  })

  it("does not throw when n8n is unreachable", async () => {
    process.env.SHOPEE_ALERT_WEBHOOK_URL = "https://n8n.example/webhook/shopee"
    fetchMock.mockRejectedValue(new Error("ECONNREFUSED"))
    const stderrSpy = jest.spyOn(console, "warn").mockImplementation()

    await expect(publishConnectorAlert(samplePayload)).resolves.toBeUndefined()
    expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("ECONNREFUSED"))
    stderrSpy.mockRestore()
  })
})
  • [ ] Step 2: Run — should FAIL
yarn jest src/modules/connectors/shared/__tests__/alerting.spec.ts

Expected: Cannot find module '../alerting'.

Task C.4: Implement alerting

Files: - Create: src/modules/connectors/shared/alerting.ts

  • [ ] Step 1: Implement
// src/modules/connectors/shared/alerting.ts

import type { ConnectorName, ErrorClass } from "./types/enums"

export interface AlertPayload {
  connector: ConnectorName | string
  error_class: ErrorClass | string
  ordersn?: string
  shop_id?: number
  message: string
  exception_id: string
  raw_event_id?: string
  occurred_at: string  // ISO 8601
  /** Free-form context for the operator. */
  details?: Record<string, unknown>
}

/**
 * Posts a structured alert to the n8n webhook URL configured via
 * SHOPEE_ALERT_WEBHOOK_URL. The n8n workflow (lives in
 * homelab/04_n8n_workflows/shopee-alert-formatter.json) formats and
 * forwards to the dedicated Telegram channel.
 *
 * Failures (n8n unreachable, env var unset) degrade to stderr — alert
 * delivery is best-effort. The ConnectorException row is the durable
 * record; Telegram is the active notification.
 */
export async function publishConnectorAlert(payload: AlertPayload): Promise<void> {
  const url = process.env.SHOPEE_ALERT_WEBHOOK_URL

  if (!url) {
    console.warn(
      `[connector-alert] SHOPEE_ALERT_WEBHOOK_URL not set — alert dropped. ` +
      `Payload: ${JSON.stringify(payload)}`,
    )
    return
  }

  try {
    await fetch(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    })
  } catch (err: any) {
    console.warn(
      `[connector-alert] Failed to POST alert to n8n (${err.message ?? err}). ` +
      `Payload: ${JSON.stringify(payload)}`,
    )
  }
}
  • [ ] Step 2: Run — should PASS
yarn jest src/modules/connectors/shared/__tests__/alerting.spec.ts
  • [ ] Step 3: Add env var

Append to .env.development.example, .env.example, and docker-compose.yml env block:

# n8n webhook URL — receives connector failure alerts and forwards
# to the dedicated #tcg-platform-alerts Telegram chat. If unset, alerts
# log to stderr instead of going to Telegram.
SHOPEE_ALERT_WEBHOOK_URL=
  • [ ] Step 4: Commit
git add src/modules/connectors/shared/alerting.ts \
        src/modules/connectors/shared/__tests__/alerting.spec.ts \
        .env.development.example .env.example docker-compose.yml
git commit -m "feat(connectors/shared): publishConnectorAlert helper (n8n webhook -> Telegram)"

Task C.5: Webhook route — failing integration test

Files: - Create: integration-tests/http/shopee-webhook.spec.ts

  • [ ] Step 1: Write integration test
// integration-tests/http/shopee-webhook.spec.ts
jest.setTimeout(60 * 1000)

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import crypto from "node:crypto"

const PARTNER_KEY = "integration-test-partner-key"
const SHOP_ID = 9999999999
const ORDERSN = "240423WEBHOOK01"

// The connector reads SHOPEE_PARTNER_KEY from env at request time.
// Set it before the runner spins up the server.
process.env.SHOPEE_PARTNER_KEY = PARTNER_KEY
process.env.SHOPEE_PARTNER_ID = "1"
process.env.SHOPEE_SHOP_ID = String(SHOP_ID)

function sign(body: string): string {
  return `SHA256 ${crypto.createHmac("sha256", PARTNER_KEY).update(body).digest("hex")}`
}

medusaIntegrationTestRunner({
  inApp: true,
  env: {},
  testSuite: ({ api, getContainer }) => {
    describe("POST /connectors/shopee/webhook", () => {
      it("ACKs 200 with valid signature and stores ShopeeRawEvent (signature_ok=true)", async () => {
        const body = JSON.stringify({
          shop_id: SHOP_ID,
          code: 3,
          timestamp: 1700000000,
          data: JSON.stringify({ ordersn: ORDERSN, status: "READY_TO_SHIP" }),
        })

        const res = await api.post("/connectors/shopee/webhook", body, {
          headers: {
            "content-type": "application/json",
            authorization: sign(body),
            "x-shopee-event-id": "evt_int_001",
          },
          transformRequest: [(d) => d],  // axios: don't re-stringify
        })

        expect(res.status).toBe(200)

        const service: any = getContainer().resolve("shopeeConnectorService")
        const rows = await service.listShopeeRawEvents({ shopee_event_id: "evt_int_001" })
        expect(rows).toHaveLength(1)
        expect(rows[0].signature_ok).toBe(true)
        expect(rows[0].event_type).toBe(3)
        expect(rows[0].ordersn).toBe(ORDERSN)
      })

      it("ACKs 200 even with INVALID signature, but signature_ok=false + ConnectorException row + alert", async () => {
        // Mock fetch so we observe the alert dispatch.
        const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue(new Response("", { status: 200 }))
        process.env.SHOPEE_ALERT_WEBHOOK_URL = "https://n8n.example/test"

        const body = JSON.stringify({ shop_id: SHOP_ID, code: 3, timestamp: 1700000001, data: "{}" })

        const res = await api.post("/connectors/shopee/webhook", body, {
          headers: {
            "content-type": "application/json",
            authorization: "SHA256 0000deadbeef",
            "x-shopee-event-id": "evt_int_002",
          },
          transformRequest: [(d) => d],
        })

        expect(res.status).toBe(200)  // never leak whether sig was right

        const shopeeSvc: any = getContainer().resolve("shopeeConnectorService")
        const rows = await shopeeSvc.listShopeeRawEvents({ shopee_event_id: "evt_int_002" })
        expect(rows).toHaveLength(1)
        expect(rows[0].signature_ok).toBe(false)

        const sharedSvc: any = getContainer().resolve("connectorSharedService")
        const exceptions = await sharedSvc.listConnectorExceptions({ raw_event_id: rows[0].id })
        expect(exceptions).toHaveLength(1)
        expect(exceptions[0].error_class).toBe("signature_mismatch")

        expect(fetchSpy).toHaveBeenCalledWith(
          "https://n8n.example/test",
          expect.objectContaining({ method: "POST" }),
        )
        fetchSpy.mockRestore()
      })

      it("idempotent on shopee_event_id — duplicate POST does not insert a second row", async () => {
        const body = JSON.stringify({ shop_id: SHOP_ID, code: 3, timestamp: 1700000002, data: "{}" })
        const headers = {
          "content-type": "application/json",
          authorization: sign(body),
          "x-shopee-event-id": "evt_int_003_dup",
        }
        const opts = { headers, transformRequest: [(d: any) => d] }

        const a = await api.post("/connectors/shopee/webhook", body, opts as any)
        const b = await api.post("/connectors/shopee/webhook", body, opts as any)

        expect(a.status).toBe(200)
        expect(b.status).toBe(200)

        const service: any = getContainer().resolve("shopeeConnectorService")
        const rows = await service.listShopeeRawEvents({ shopee_event_id: "evt_int_003_dup" })
        expect(rows).toHaveLength(1)
      })
    })
  },
})
  • [ ] Step 2: Run — should FAIL (route handler doesn't exist)
yarn jest integration-tests/http/shopee-webhook.spec.ts

Expected: 404 on POST.

Task C.6: Implement the webhook route

Files: - Create: src/api/connectors/shopee/webhook/route.ts - Create: src/api/connectors/shopee/webhook/middlewares.ts - Modify: src/api/middlewares.ts (register the raw-body middleware for this path)

Medusa 2.x uses file-based API routing under src/api/. The directory hierarchy maps to URL: src/api/connectors/shopee/webhook/route.tsPOST /connectors/shopee/webhook.

  • [ ] Step 1: Raw-body middleware
// src/api/connectors/shopee/webhook/middlewares.ts

import { MedusaRequest, MedusaNextFunction, MedusaResponse } from "@medusajs/framework/http"
import express from "express"

/**
 * Captures the raw body so HMAC verification can run against the exact
 * bytes Shopee signed. The standard JSON parser would reformat the body
 * (whitespace, key order) and break verification.
 *
 * Order-dependent: this MUST run BEFORE Medusa's default JSON parser
 * for the webhook route. Registered in src/api/middlewares.ts with an
 * explicit route filter.
 */
export const captureRawBody = express.raw({
  type: "application/json",
  // 1 MiB is generous — Shopee push payloads are typically <1 KiB.
  limit: "1mb",
})

/**
 * After the raw body is captured, parse it for the route handler to use.
 * The handler will pass the raw bytes to verifyWebhookSignature() AND
 * parse them for storage / processing.
 */
export function attachRawBody(req: MedusaRequest, _res: MedusaResponse, next: MedusaNextFunction) {
  // express.raw left req.body as a Buffer. Stash the raw string and
  // re-parse for the handler.
  if (Buffer.isBuffer(req.body)) {
    ;(req as any).rawBody = req.body.toString("utf8")
    try {
      req.body = JSON.parse((req as any).rawBody)
    } catch {
      // leave req.body as the buffer; route will reject malformed JSON
    }
  }
  next()
}
  • [ ] Step 2: Register middleware

Edit (or create) src/api/middlewares.ts:

// src/api/middlewares.ts

import { defineMiddlewares } from "@medusajs/framework/http"
import { captureRawBody, attachRawBody } from "./connectors/shopee/webhook/middlewares"

export default defineMiddlewares({
  routes: [
    {
      matcher: "/connectors/shopee/webhook",
      method: "POST",
      middlewares: [captureRawBody, attachRawBody],
    },
  ],
})
  • [ ] Step 3: Route handler
// src/api/connectors/shopee/webhook/route.ts

import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { SHOPEE_CONNECTOR_MODULE } from "../../../../modules/connectors/shopee"
import { CONNECTOR_SHARED_MODULE } from "../../../../modules/connectors/shared"
import { verifyWebhookSignature } from "../../../../modules/connectors/shopee/webhook/verify-signature"
import { publishConnectorAlert } from "../../../../modules/connectors/shared/alerting"
import { RawEventSource } from "../../../../modules/connectors/shopee/types/enums"
import { ConnectorName, ErrorClass } from "../../../../modules/connectors/shared/types/enums"

/**
 * POST /connectors/shopee/webhook
 *
 * Always returns 200 within ~100ms. Heavy work (order detail fetch,
 * normalization, Medusa workflow dispatch) is async via the subscriber
 * in PR D — listening on shopee_raw_event.created events emitted by the
 * insert below.
 *
 * Signature verification: HMAC-SHA256(partner_key, raw_body), body only,
 * Authorization header in `SHA256 <hex>` format. Mismatched signature →
 * still inserts the raw event (with signature_ok=false) so we have a
 * forensic trail, but creates a ConnectorException row + Telegram alert
 * and does NOT mark the event for processing. Always returns 200 — never
 * leak to attackers whether signature was right or wrong.
 *
 * Idempotency: webhooks may include an x-shopee-event-id header (Shopee's
 * per-request unique ID). We use it as a unique key on shopee_raw_event.
 * If the SDK observation is wrong and that header isn't reliable, we
 * fall back to (event_type, ordersn, timestamp) as a compound key —
 * tracked as a Phase 3 open question.
 */
export async function POST(req: MedusaRequest, res: MedusaResponse): Promise<void> {
  const shopeeService: any = req.scope.resolve(SHOPEE_CONNECTOR_MODULE)
  const sharedService: any = req.scope.resolve(CONNECTOR_SHARED_MODULE)

  const rawBody = (req as any).rawBody as string | undefined
  const auth = req.headers["authorization"] as string | undefined
  const eventIdHeader = req.headers["x-shopee-event-id"] as string | undefined

  const partnerKey = process.env.SHOPEE_PARTNER_KEY
  if (!partnerKey) {
    // Misconfiguration — we can't verify, so we refuse to process at all.
    // 200 anyway so Shopee doesn't retry; record + alert instead.
    res.status(200).end()
    await publishConnectorAlert({
      connector: ConnectorName.SHOPEE,
      error_class: ErrorClass.UNKNOWN,
      message: "SHOPEE_PARTNER_KEY env var is not set; webhook signature cannot be verified",
      exception_id: "cex_unconfigured",
      occurred_at: new Date().toISOString(),
    })
    return
  }

  const signatureOk = rawBody
    ? verifyWebhookSignature(rawBody, auth, partnerKey)
    : false

  // Idempotency — if we've already stored this shopee_event_id, return 200 + skip.
  if (eventIdHeader) {
    const existing = await shopeeService.listShopeeRawEvents({ shopee_event_id: eventIdHeader })
    if (existing.length > 0) {
      res.status(200).end()
      return
    }
  }

  const body = req.body as any
  const [rawEvent] = await shopeeService.createShopeeRawEvents([
    {
      event_type: typeof body?.code === "number" ? body.code : 0,
      shopee_event_id: eventIdHeader ?? null,
      shop_id: typeof body?.shop_id === "number" ? BigInt(body.shop_id) : 0n,
      ordersn: extractOrdersn(body),
      payload: body,
      signature: auth ?? null,
      signature_ok: signatureOk,
      source: RawEventSource.WEBHOOK,
    },
  ])

  // ACK before any further work — keep the request fast.
  res.status(200).end()

  if (!signatureOk) {
    const [exception] = await sharedService.createConnectorExceptions([
      {
        connector: ConnectorName.SHOPEE,
        raw_event_id: rawEvent.id,
        error_class: ErrorClass.SIGNATURE_MISMATCH,
        error_message: "Webhook HMAC verification failed",
        retry_count: 0,
      },
    ])
    await publishConnectorAlert({
      connector: ConnectorName.SHOPEE,
      error_class: ErrorClass.SIGNATURE_MISMATCH,
      message: "Webhook HMAC verification failed",
      exception_id: exception.id,
      raw_event_id: rawEvent.id,
      occurred_at: new Date().toISOString(),
    })
    // Don't mark for processing — subscriber filters on signature_ok.
    return
  }

  // Subscriber dispatch happens on the model's create event. PR D wires
  // the subscriber. PR C is just storage + signature verification, so
  // events pile up unprocessed until PR D ships — fine for staging.
}

function extractOrdersn(body: any): string | null {
  if (!body) return null
  // Code 3/4: data is a JSON-stringified object containing ordersn.
  if (typeof body.data === "string") {
    try {
      const parsed = JSON.parse(body.data)
      if (typeof parsed.ordersn === "string") return parsed.ordersn
    } catch {
      // fallthrough
    }
  }
  if (typeof body.ordersn === "string") return body.ordersn
  return null
}
  • [ ] Step 4: Run integration test — should PASS
yarn dev:db   # ensure postgres is up
yarn jest integration-tests/http/shopee-webhook.spec.ts

Expected: all 3 cases pass.

  • [ ] Step 5: Commit
git add src/api/ integration-tests/http/shopee-webhook.spec.ts
git commit -m "feat(api): POST /connectors/shopee/webhook with sig verification + idempotency"

Task C.7: PR C — push + open PR

  • [ ] Step 1: Push + open
git push -u origin draft/claude-phase-3-pr-c-webhook
gh pr create --base main --head draft/claude-phase-3-pr-c-webhook \
  --title "feat(connectors/shopee): Phase 3 PR C — webhook + signature verification + alerting" \
  --body "$(cat <<'EOF'
## Summary

Third of 5 PRs implementing Phase 3. Inbound surface opens — Shopee can POST webhooks, signature is verified (HMAC-SHA256 of body, body only, per audit), raw events land in storage. No order processing yet — that's PR D's subscriber.

## What landed

- \`verifyWebhookSignature\` helper (HMAC-SHA256 over raw body, constant-time compare, parses \`Authorization: SHA256 <hex>\` format)
- \`publishConnectorAlert\` helper — POSTs structured payload to \`SHOPEE_ALERT_WEBHOOK_URL\` (n8n)
- \`POST /connectors/shopee/webhook\` route + raw-body middleware
- Always-200 response policy (don't leak signature validity)
- Idempotency on \`x-shopee-event-id\` header
- Failure path: signature mismatch → ConnectorException + Telegram alert
- \`SHOPEE_ALERT_WEBHOOK_URL\` env var across .env.* + docker-compose.yml

## Test plan

- [ ] \`yarn jest src/modules/connectors/shopee/__tests__/verify-signature.spec.ts\` (6 cases)
- [ ] \`yarn jest src/modules/connectors/shared/__tests__/alerting.spec.ts\` (3 cases)
- [ ] \`yarn jest integration-tests/http/shopee-webhook.spec.ts\` (3 cases — happy path, bad sig, idempotency)
- [ ] CI green

## Next

PR D — subscriber + normalizers + Medusa workflow dispatch (the part that actually creates Medusa orders).
EOF
)"
  • [ ] Step 2: Wait for CI + merge

PR D — Subscriber + normalizers + mappers + workflow dispatch

Goal: First Medusa orders created from Shopee data. After this PR, an end-to-end webhook → Medusa order flow works on staging. Status updates and escrow fetch (post-COMPLETED) also wired.

Branch: draft/claude-phase-3-pr-d-flow from tcg-platform/main (which now has PRs A, B, C merged).

Setup: cd /tmp && rm -rf tcg-platform-prd && git clone https://github.com/ExzenTCG/tcg-platform.git tcg-platform-prd && cd tcg-platform-prd && git checkout -b draft/claude-phase-3-pr-d-flow

Task D.1: SKU → variant mapper

Files: - Create: src/modules/connectors/shopee/mappers/sku-to-variant.ts - Create: src/modules/connectors/shopee/__tests__/mappers/sku-to-variant.spec.ts

The merchant has SKU discipline — every Shopee variation's model_sku is the internal Medusa variant SKU. Direct match. No TCGChannelListing lookup needed for inbound.

  • [ ] Step 1: Failing test
// src/modules/connectors/shopee/__tests__/mappers/sku-to-variant.spec.ts
jest.setTimeout(60 * 1000)

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { Modules } from "@medusajs/framework/utils"
import { SkuNotFoundError, AmbiguousSkuError, resolveVariantBySku } from "../../mappers/sku-to-variant"

medusaIntegrationTestRunner({
  inApp: true,
  env: {},
  testSuite: ({ getContainer }) => {
    describe("resolveVariantBySku", () => {
      let productSvc: any
      let variantId: string

      beforeAll(async () => {
        productSvc = getContainer().resolve(Modules.PRODUCT)
        const [prod] = await productSvc.createProducts([
          {
            title: "Sku Resolver Test Product",
            handle: "sku-resolver-test",
            options: [{ title: "version", values: ["v1"] }],
            variants: [{ title: "v1", sku: "POK-OBF-223-NM-EN", options: { version: "v1" } }],
          },
        ])
        variantId = prod.variants[0].id
      })

      it("returns the variant for an existing SKU", async () => {
        const variant = await resolveVariantBySku(getContainer(), "POK-OBF-223-NM-EN")
        expect(variant.id).toBe(variantId)
      })

      it("throws SkuNotFoundError when the SKU doesn't exist", async () => {
        await expect(
          resolveVariantBySku(getContainer(), "NOT-A-REAL-SKU-XYZ"),
        ).rejects.toBeInstanceOf(SkuNotFoundError)
      })
    })
  },
})

AmbiguousSkuError is exported but Medusa enforces variant SKU uniqueness, so we don't add a runtime test for it (would require bypassing the constraint). The class exists for future-proofing.

  • [ ] Step 2: Run — should FAIL
yarn jest src/modules/connectors/shopee/__tests__/mappers/sku-to-variant.spec.ts
  • [ ] Step 3: Implement
// src/modules/connectors/shopee/mappers/sku-to-variant.ts

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { MedusaContainer } from "@medusajs/framework/types"

export class SkuNotFoundError extends Error {
  constructor(public readonly sku: string, public readonly ordersn?: string) {
    super(`SKU '${sku}' not found in Medusa variants${ordersn ? ` (Shopee ordersn=${ordersn})` : ""}`)
    this.name = "SkuNotFoundError"
  }
}

export class AmbiguousSkuError extends Error {
  constructor(public readonly sku: string, public readonly count: number) {
    super(`SKU '${sku}' matched ${count} variants — should be unique`)
    this.name = "AmbiguousSkuError"
  }
}

/**
 * Resolves a Shopee model_sku to a Medusa variant by exact SKU match.
 *
 * The merchant maintains SKU discipline (every Shopee variation has a
 * unique internal SKU set as model_sku). Direct match is sufficient for
 * inbound — no TCGChannelListing lookup needed.
 *
 * Throws SkuNotFoundError if no match (operator action: add the SKU to
 * Medusa OR fix the model_sku on Shopee). Throws AmbiguousSkuError if
 * Medusa somehow has duplicate SKUs (shouldn't happen — Medusa enforces
 * uniqueness — but kept as a safety net).
 */
export async function resolveVariantBySku(
  container: MedusaContainer,
  sku: string,
  ordersn?: string,
): Promise<{ id: string; sku: string; product_id: string }> {
  const query = container.resolve(ContainerRegistrationKeys.QUERY)

  const { data } = await query.graph({
    entity: "product_variant",
    fields: ["id", "sku", "product_id"],
    filters: { sku },
  })

  if (data.length === 0) {
    throw new SkuNotFoundError(sku, ordersn)
  }
  if (data.length > 1) {
    throw new AmbiguousSkuError(sku, data.length)
  }
  return data[0] as any
}
  • [ ] Step 4: Test passes; commit
yarn jest src/modules/connectors/shopee/__tests__/mappers/sku-to-variant.spec.ts
git add src/modules/connectors/shopee/mappers/ src/modules/connectors/shopee/__tests__/mappers/
git commit -m "feat(connectors/shopee): resolveVariantBySku mapper"

Task D.2: Address normalizer

Files: - Create: src/modules/connectors/shopee/normalizers/address.ts - Create: src/modules/connectors/shopee/__tests__/normalizers/address.spec.ts

  • [ ] Step 1: Failing test (use a recorded Shopee address fixture)
// src/modules/connectors/shopee/__tests__/normalizers/address.spec.ts

import { normalizeShopeeAddress } from "../../normalizers/address"

describe("normalizeShopeeAddress", () => {
  it("maps a Singapore Shopee shipping address to Medusa shape", () => {
    const shopeeAddr = {
      name: "Tan Wei Ming",
      phone: "+6591234567",
      full_address: "Blk 123 Toa Payoh Lor 1 #05-67 Singapore 310123",
      district: "Toa Payoh",
      city: "Singapore",
      state: "",
      region: "SG",
      zipcode: "310123",
      town: "",
    }

    expect(normalizeShopeeAddress(shopeeAddr)).toEqual({
      first_name: "Tan Wei Ming",
      last_name: "",
      address_1: "Blk 123 Toa Payoh Lor 1 #05-67 Singapore 310123",
      address_2: null,
      city: "Singapore",
      postal_code: "310123",
      country_code: "sg",
      province: null,
      phone: "+6591234567",
    })
  })

  it("handles missing optional fields gracefully", () => {
    const shopeeAddr = {
      name: "X",
      phone: "",
      full_address: "Address",
      district: "",
      city: "",
      state: "",
      region: "SG",
      zipcode: "",
      town: "",
    }

    const result = normalizeShopeeAddress(shopeeAddr)
    expect(result.country_code).toBe("sg")
    expect(result.first_name).toBe("X")
    expect(result.last_name).toBe("")
  })
})
  • [ ] Step 2: Implement
// src/modules/connectors/shopee/normalizers/address.ts

/**
 * Shopee shipping address shape (from get_order_detail response). Field
 * names per SDK src/schemas/order.ts.
 */
export interface ShopeeAddress {
  name: string
  phone: string
  full_address: string
  district?: string
  city?: string
  state?: string
  region: string  // 2-letter — SG for Singapore
  zipcode?: string
  town?: string
}

/**
 * Medusa CreateAddressDTO subset that the connector populates. Other
 * fields (company, metadata, customer_id) come from elsewhere in the
 * order normalizer.
 */
export interface MedusaAddress {
  first_name: string
  last_name: string
  address_1: string
  address_2: string | null
  city: string
  postal_code: string
  country_code: string  // lowercase ISO 2
  province: string | null
  phone: string
}

/**
 * Maps a Shopee shipping address to Medusa's address shape.
 *
 * Naming: Shopee gives a single `name` field. We put the whole thing in
 * `first_name` rather than splitting on whitespace, which would mangle
 * Asian names (which often have surname-first conventions). The downside
 * is `last_name` is empty, but Medusa accepts that.
 *
 * `address_1` carries the full Shopee `full_address` verbatim — it
 * already includes block/unit/postal in one string for SG addresses.
 */
export function normalizeShopeeAddress(s: ShopeeAddress): MedusaAddress {
  return {
    first_name: s.name,
    last_name: "",
    address_1: s.full_address,
    address_2: null,
    city: s.city ?? "",
    postal_code: s.zipcode ?? "",
    country_code: s.region.toLowerCase(),
    province: s.state ? s.state : null,
    phone: s.phone,
  }
}
  • [ ] Step 3: Test passes; commit
yarn jest src/modules/connectors/shopee/__tests__/normalizers/address.spec.ts
git add src/modules/connectors/shopee/normalizers/address.ts src/modules/connectors/shopee/__tests__/normalizers/address.spec.ts
git commit -m "feat(connectors/shopee): address normalizer"

Task D.3: Status normalizer (handler matrix)

Files: - Create: src/modules/connectors/shopee/normalizers/status.ts - Create: src/modules/connectors/shopee/__tests__/normalizers/status.spec.ts

  • [ ] Step 1: Failing test covers every status
// src/modules/connectors/shopee/__tests__/normalizers/status.spec.ts

import { mapShopeeStatusToAction, ShopeeStatusAction } from "../../normalizers/status"
import { ShopeeOrderStatus } from "../../types/enums"

describe("mapShopeeStatusToAction", () => {
  const cases: Array<[ShopeeOrderStatus, ShopeeStatusAction]> = [
    [ShopeeOrderStatus.UNPAID, "create_pending"],
    [ShopeeOrderStatus.READY_TO_SHIP, "mark_payment_captured"],
    [ShopeeOrderStatus.PROCESSED, "create_shipment"],
    [ShopeeOrderStatus.RETRY_SHIP, "log_only"],
    [ShopeeOrderStatus.SHIPPED, "fulfill"],
    [ShopeeOrderStatus.TO_CONFIRM_RECEIVE, "log_only"],
    [ShopeeOrderStatus.IN_CANCEL, "log_only"],
    [ShopeeOrderStatus.CANCELLED, "cancel"],
    [ShopeeOrderStatus.TO_RETURN, "log_only"],
    [ShopeeOrderStatus.COMPLETED, "complete_and_fetch_escrow"],
  ]

  it.each(cases)("maps %s to %s", (status, expected) => {
    expect(mapShopeeStatusToAction(status)).toBe(expected)
  })

  it("returns 'unknown' for region-restricted INVOICE_PENDING (not in SG OrderStatus union)", () => {
    expect(mapShopeeStatusToAction("INVOICE_PENDING" as any)).toBe("unknown")
  })

  it("returns 'unknown' for an unknown status string", () => {
    expect(mapShopeeStatusToAction("WHATEVER" as any)).toBe("unknown")
  })
})
  • [ ] Step 2: Implement
// src/modules/connectors/shopee/normalizers/status.ts

import { ShopeeOrderStatus } from "../types/enums"

/**
 * Decisions about what the connector does on each Shopee order status
 * transition. The subscriber dispatches the corresponding Medusa
 * workflow.
 *
 * Source: Phase 3 spec "Order lifecycle handler matrix". Confirmed
 * against the SDK's `OrderStatus` union (10 values for SG; INVOICE_PENDING
 * is region-restricted and NOT a member of the union).
 */
export type ShopeeStatusAction =
  | "create_pending"            // UNPAID — create order in pending state
  | "mark_payment_captured"     // READY_TO_SHIP
  | "create_shipment"           // PROCESSED
  | "fulfill"                   // SHIPPED
  | "cancel"                    // CANCELLED
  | "complete_and_fetch_escrow" // COMPLETED
  | "log_only"                  // RETRY_SHIP / TO_CONFIRM_RECEIVE / IN_CANCEL / TO_RETURN
  | "unknown"                   // anything we didn't model

const MAP: Record<ShopeeOrderStatus, ShopeeStatusAction> = {
  [ShopeeOrderStatus.UNPAID]: "create_pending",
  [ShopeeOrderStatus.READY_TO_SHIP]: "mark_payment_captured",
  [ShopeeOrderStatus.PROCESSED]: "create_shipment",
  [ShopeeOrderStatus.RETRY_SHIP]: "log_only",
  [ShopeeOrderStatus.SHIPPED]: "fulfill",
  [ShopeeOrderStatus.TO_CONFIRM_RECEIVE]: "log_only",
  [ShopeeOrderStatus.IN_CANCEL]: "log_only",
  [ShopeeOrderStatus.CANCELLED]: "cancel",
  [ShopeeOrderStatus.TO_RETURN]: "log_only",
  [ShopeeOrderStatus.COMPLETED]: "complete_and_fetch_escrow",
}

export function mapShopeeStatusToAction(status: string): ShopeeStatusAction {
  if (status in MAP) return MAP[status as ShopeeOrderStatus]
  return "unknown"
}
  • [ ] Step 3: Test passes; commit
yarn jest src/modules/connectors/shopee/__tests__/normalizers/status.spec.ts
git add src/modules/connectors/shopee/normalizers/status.ts src/modules/connectors/shopee/__tests__/normalizers/status.spec.ts
git commit -m "feat(connectors/shopee): order status -> action handler matrix"

Task D.4: Order normalizer (recorded fixture)

Files: - Create: src/modules/connectors/shopee/normalizers/order.ts - Create: src/modules/connectors/shopee/__tests__/fixtures/get-order-detail.shopee.json (recorded fixture) - Create: src/modules/connectors/shopee/__tests__/normalizers/order.spec.ts

  • [ ] Step 1: Add a recorded fixture file

The fixture is the response payload from one real getOrdersDetail() SDK call (anonymized buyer info). Get one during development — for the plan, use this representative shape. The test uses getOrdersDetail returns { order_list: Order[] } per SDK schema.

// src/modules/connectors/shopee/__tests__/fixtures/get-order-detail.shopee.json
{
  "order_list": [
    {
      "order_sn": "240423FIXTURE",
      "region": "SG",
      "currency": "SGD",
      "cod": false,
      "total_amount": 1500,
      "order_status": "READY_TO_SHIP",
      "shipping_carrier": "Ninja Van",
      "payment_method": "Credit Card",
      "estimated_shipping_fee": 200,
      "message_to_seller": "",
      "create_time": 1714000000,
      "update_time": 1714000050,
      "buyer_user_id": 700123,
      "buyer_username": "anonbuyer",
      "recipient_address": {
        "name": "Anon Buyer",
        "phone": "+6591234567",
        "full_address": "Blk 1 #01-02 Singapore 100100",
        "district": "Bishan",
        "city": "Singapore",
        "state": "",
        "region": "SG",
        "zipcode": "100100",
        "town": ""
      },
      "item_list": [
        {
          "item_id": 9001,
          "item_name": "Charizard ex Obsidian Flames 223",
          "item_sku": "POK-OBF-223",
          "model_id": 8001,
          "model_name": "NM EN Normal",
          "model_sku": "POK-OBF-223-NM-EN",
          "model_quantity_purchased": 1,
          "model_original_price": 1300,
          "model_discounted_price": 1300,
          "wholesale": false,
          "weight": 0.05,
          "add_on_deal": false,
          "main_item": false,
          "is_b2c_owned_item": false
        }
      ],
      "package_list": [
        {
          "package_number": "PKG_240423_FIXTURE",
          "logistics_status": "LOGISTICS_REQUEST_CREATED",
          "shipping_carrier": "Ninja Van",
          "item_list": [{ "item_id": 9001, "model_id": 8001, "model_quantity": 1 }]
        }
      ]
    }
  ]
}
  • [ ] Step 2: Failing test
// src/modules/connectors/shopee/__tests__/normalizers/order.spec.ts

import fixture from "../fixtures/get-order-detail.shopee.json"
import { normalizeShopeeOrder } from "../../normalizers/order"

describe("normalizeShopeeOrder", () => {
  const order = fixture.order_list[0]

  it("maps top-level fields", () => {
    const norm = normalizeShopeeOrder(order as any)
    expect(norm.ordersn).toBe("240423FIXTURE")
    expect(norm.currency_code).toBe("sgd")
    expect(norm.shopee_status).toBe("READY_TO_SHIP")
    expect(norm.shop_id).toBe(0)  // not present in fixture; normalizer fills 0 fallback
    expect(norm.created_at).toBeInstanceOf(Date)
  })

  it("maps line items with model_sku as the lookup key", () => {
    const norm = normalizeShopeeOrder(order as any)
    expect(norm.line_items).toHaveLength(1)
    const li = norm.line_items[0]
    expect(li.sku).toBe("POK-OBF-223-NM-EN")
    expect(li.quantity).toBe(1)
    expect(li.unit_price).toBe(1300)
    expect(li.title).toBe("Charizard ex Obsidian Flames 223")
    expect(li.metadata).toMatchObject({ shopee_item_id: 9001, shopee_model_id: 8001 })
  })

  it("normalizes the shipping address", () => {
    const norm = normalizeShopeeOrder(order as any)
    expect(norm.shipping_address.country_code).toBe("sg")
    expect(norm.shipping_address.postal_code).toBe("100100")
  })

  it("captures buyer identity for customer matching", () => {
    const norm = normalizeShopeeOrder(order as any)
    expect(norm.buyer.user_id).toBe(700123)
    expect(norm.buyer.username).toBe("anonbuyer")
  })
})
  • [ ] Step 3: Implement
// src/modules/connectors/shopee/normalizers/order.ts

import { normalizeShopeeAddress, MedusaAddress } from "./address"

/** Subset of the SDK's `Order` type the connector needs. */
export interface ShopeeOrder {
  order_sn: string
  shop_id?: number
  region: string
  currency: string
  cod: boolean
  total_amount: number
  order_status: string
  payment_method?: string
  estimated_shipping_fee?: number
  message_to_seller?: string
  create_time: number
  update_time: number
  buyer_user_id: number
  buyer_username?: string
  recipient_address: any  // ShopeeAddress
  item_list: Array<{
    item_id: number
    item_name: string
    item_sku?: string
    model_id: number
    model_name?: string
    model_sku: string
    model_quantity_purchased: number
    model_original_price: number
    model_discounted_price: number
  }>
  package_list?: any[]
}

export interface NormalizedOrder {
  ordersn: string
  shop_id: number
  shopee_status: string
  currency_code: string  // lowercase ISO 3
  cod: boolean
  total_amount: number
  payment_method: string | null
  message_to_seller: string | null
  created_at: Date
  updated_at: Date
  buyer: { user_id: number; username: string | null }
  shipping_address: MedusaAddress
  line_items: Array<{
    sku: string
    quantity: number
    unit_price: number
    title: string
    metadata: Record<string, unknown>
  }>
  /** For storage in ShopeeOrderSync. */
  raw: ShopeeOrder
}

/**
 * Normalizes a Shopee `Order` (from `getOrdersDetail`) to the canonical
 * shape the subscriber feeds into Medusa workflows. SKU lookup happens
 * downstream — this function does not touch the database.
 */
export function normalizeShopeeOrder(o: ShopeeOrder): NormalizedOrder {
  return {
    ordersn: o.order_sn,
    shop_id: o.shop_id ?? 0,
    shopee_status: o.order_status,
    currency_code: o.currency.toLowerCase(),
    cod: o.cod,
    total_amount: o.total_amount,
    payment_method: o.payment_method ?? null,
    message_to_seller: o.message_to_seller ?? null,
    created_at: new Date(o.create_time * 1000),
    updated_at: new Date(o.update_time * 1000),
    buyer: {
      user_id: o.buyer_user_id,
      username: o.buyer_username ?? null,
    },
    shipping_address: normalizeShopeeAddress(o.recipient_address),
    line_items: o.item_list.map((li) => ({
      sku: li.model_sku,
      quantity: li.model_quantity_purchased,
      unit_price: li.model_discounted_price,
      title: li.item_name + (li.model_name ? ` — ${li.model_name}` : ""),
      metadata: {
        shopee_item_id: li.item_id,
        shopee_model_id: li.model_id,
        shopee_item_sku: li.item_sku ?? null,
        shopee_model_name: li.model_name ?? null,
        shopee_original_price: li.model_original_price,
      },
    })),
    raw: o,
  }
}
  • [ ] Step 4: Test passes; commit
yarn jest src/modules/connectors/shopee/__tests__/normalizers/order.spec.ts
git add src/modules/connectors/shopee/normalizers/order.ts \
        src/modules/connectors/shopee/__tests__/normalizers/order.spec.ts \
        src/modules/connectors/shopee/__tests__/fixtures/
git commit -m "feat(connectors/shopee): order normalizer (Shopee Order -> canonical)"

Task D.5: Escrow normalizer

Files: - Create: src/modules/connectors/shopee/normalizers/escrow.ts - Create: src/modules/connectors/shopee/__tests__/normalizers/escrow.spec.ts - Create: src/modules/connectors/shopee/__tests__/fixtures/get-escrow-detail.shopee.json

  • [ ] Step 1: Recorded escrow fixture (anonymized)
// src/modules/connectors/shopee/__tests__/fixtures/get-escrow-detail.shopee.json
{
  "order_sn": "240423FIXTURE",
  "buyer_user_name": "anonbuyer",
  "return_order_sn_list": [],
  "buyer_payment_info": { "buyer_paid_amount": 1500 },
  "order_income": {
    "escrow_amount": 1180,
    "original_price": 1300,
    "seller_discount": 0,
    "shopee_discount": 0,
    "voucher_from_seller": 0,
    "voucher_from_shopee": 100,
    "commission_fee": 30,
    "service_fee": 20,
    "seller_transaction_fee": 10,
    "actual_shipping_fee": 200,
    "seller_lost_compensation": 0,
    "buyer_total_amount": 1500,
    "final_shipping_fee": 200,
    "shopee_shipping_rebate": 0,
    "seller_return_refund": 0,
    "coins": 0,
    "buyer_paid_shipping_fee": 200,
    "escrow_tax": 0,
    "reverse_shipping_fee": 0,
    "drc_adjustable_refund": 0,
    "credit_card_transaction_fee": 0,
    "seller_coin_cash_back": 0
  }
}
  • [ ] Step 2: Failing test
// src/modules/connectors/shopee/__tests__/normalizers/escrow.spec.ts

import fixture from "../fixtures/get-escrow-detail.shopee.json"
import { normalizeShopeeEscrow } from "../../normalizers/escrow"

describe("normalizeShopeeEscrow", () => {
  it("maps OrderIncome fields with renamed names per audit", () => {
    const row = normalizeShopeeEscrow(fixture as any, "SGD")

    expect(row.ordersn).toBe("240423FIXTURE")
    expect(row.escrow_amount).toBe(1180)
    expect(row.voucher_from_seller).toBe(0)         // NOT seller_voucher
    expect(row.voucher_from_shopee).toBe(100)       // NOT shopee_voucher
    expect(row.seller_transaction_fee).toBe(10)     // NOT transaction_fee
    expect(row.buyer_total_amount).toBe(1500)       // NOT buyer_paid_amount
    expect(row.currency).toBe("SGD")                // sourced from caller (Order.currency)
  })

  it("preserves the full payload in raw_escrow_payload for Phase 4 mining", () => {
    const row = normalizeShopeeEscrow(fixture as any, "SGD")
    expect(row.raw_escrow_payload).toEqual(fixture)
  })

  it("leaves escrow_release_time null (only available via getEscrowList)", () => {
    const row = normalizeShopeeEscrow(fixture as any, "SGD")
    expect(row.escrow_release_time).toBeNull()
  })
})
  • [ ] Step 3: Implement
// src/modules/connectors/shopee/normalizers/escrow.ts

/** Subset of OrderIncome — see audit report for full field list. */
interface OrderIncome {
  escrow_amount: number
  original_price: number
  seller_discount: number
  shopee_discount: number
  voucher_from_seller: number
  voucher_from_shopee: number
  commission_fee: number
  service_fee: number
  seller_transaction_fee: number
  actual_shipping_fee: number
  seller_lost_compensation: number
  buyer_total_amount: number
  // ... and many more captured into raw_escrow_payload
}

export interface ShopeeEscrowDetailResponse {
  order_sn: string
  buyer_user_name: string
  return_order_sn_list: string[]
  buyer_payment_info: any
  order_income: OrderIncome
}

/** Output shape for the ShopeeEscrow create. */
export interface NormalizedEscrow {
  ordersn: string
  escrow_amount: number
  original_price: number
  seller_discount: number
  shopee_discount: number
  voucher_from_seller: number
  voucher_from_shopee: number
  commission_fee: number
  service_fee: number
  seller_transaction_fee: number
  actual_shipping_fee: number
  seller_lost_compensation: number
  buyer_total_amount: number
  currency: string
  escrow_release_time: Date | null
  raw_escrow_payload: ShopeeEscrowDetailResponse
  fetched_at: Date
}

/**
 * Normalizes a Shopee getEscrowDetail() response to the ShopeeEscrow row
 * shape. Currency is sourced from the Order (passed in by the caller)
 * because the escrow response itself does not include a currency field
 * per audit.
 *
 * escrow_release_time is null here because getEscrowDetail does not return
 * it. The escrow-backstop-daily job populates that field via getEscrowList
 * for orders where it's needed.
 */
export function normalizeShopeeEscrow(
  resp: ShopeeEscrowDetailResponse,
  currency: string,
): NormalizedEscrow {
  const inc = resp.order_income
  return {
    ordersn: resp.order_sn,
    escrow_amount: inc.escrow_amount,
    original_price: inc.original_price,
    seller_discount: inc.seller_discount,
    shopee_discount: inc.shopee_discount,
    voucher_from_seller: inc.voucher_from_seller,
    voucher_from_shopee: inc.voucher_from_shopee,
    commission_fee: inc.commission_fee,
    service_fee: inc.service_fee,
    seller_transaction_fee: inc.seller_transaction_fee,
    actual_shipping_fee: inc.actual_shipping_fee,
    seller_lost_compensation: inc.seller_lost_compensation,
    buyer_total_amount: inc.buyer_total_amount,
    currency,
    escrow_release_time: null,
    raw_escrow_payload: resp,
    fetched_at: new Date(),
  }
}
  • [ ] Step 4: Test + commit
yarn jest src/modules/connectors/shopee/__tests__/normalizers/escrow.spec.ts
git add src/modules/connectors/shopee/normalizers/escrow.ts \
        src/modules/connectors/shopee/__tests__/normalizers/escrow.spec.ts \
        src/modules/connectors/shopee/__tests__/fixtures/get-escrow-detail.shopee.json
git commit -m "feat(connectors/shopee): escrow normalizer with audit-aligned field names"

Task D.6: ShopeeConnectorService.processEvent — orchestration

Files: - Modify: src/modules/connectors/shopee/service.ts - Create: src/modules/connectors/shopee/__tests__/process-event.spec.ts

The orchestration method is what the subscriber invokes. It loads the raw event, calls the SDK to fetch fresh order detail, normalizes, dispatches to Medusa, and updates ShopeeOrderSync. Failure path: insert ConnectorException, fire alert, leave the raw event for operator-triggered retry.

  • [ ] Step 1: Failing integration test
// src/modules/connectors/shopee/__tests__/process-event.spec.ts
jest.setTimeout(60 * 1000)

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { Modules } from "@medusajs/framework/utils"
import { SHOPEE_CONNECTOR_MODULE } from ".."
import { CONNECTOR_SHARED_MODULE } from "../../shared"
import { __resetShopeeSdkForTesting } from "../sdk"
import fixture from "./fixtures/get-order-detail.shopee.json"
import { OrderSyncStatus, RawEventSource, PushCode } from "../types/enums"

// Mock the SDK module so processEvent doesn't try to hit real Shopee.
jest.mock("../sdk", () => {
  const actual = jest.requireActual("../sdk")
  return {
    ...actual,
    getShopeeSdk: jest.fn(() => ({
      order: {
        getOrdersDetail: jest.fn(async () => ({
          response: { order_list: [fixture.order_list[0]] },
        })),
      },
    })),
  }
})

medusaIntegrationTestRunner({
  inApp: true,
  env: { SHOPEE_PARTNER_KEY: "test", SHOPEE_PARTNER_ID: "1", SHOPEE_SHOP_ID: "9999" },
  testSuite: ({ getContainer }) => {
    beforeEach(() => __resetShopeeSdkForTesting())

    describe("ShopeeConnectorService.processEvent — happy path", () => {
      let productSvc: any, shopeeSvc: any

      beforeAll(async () => {
        productSvc = getContainer().resolve(Modules.PRODUCT)
        shopeeSvc = getContainer().resolve(SHOPEE_CONNECTOR_MODULE)

        // Seed the variant the fixture references.
        await productSvc.createProducts([
          {
            title: "Fixture Product",
            handle: "fixture-product",
            options: [{ title: "version", values: ["v1"] }],
            variants: [{ title: "v1", sku: "POK-OBF-223-NM-EN", options: { version: "v1" } }],
          },
        ])
      })

      it("creates a Medusa order, ShopeeOrderSync row (status=created), marks raw event processed", async () => {
        const [raw] = await shopeeSvc.createShopeeRawEvents([
          {
            event_type: PushCode.ORDER_STATUS_UPDATE,
            shopee_event_id: "evt_proc_001",
            shop_id: 9999n,
            ordersn: "240423FIXTURE",
            payload: { code: 3, data: '{"ordersn":"240423FIXTURE","status":"READY_TO_SHIP"}' },
            signature: "SHA256 abc",
            signature_ok: true,
            source: RawEventSource.WEBHOOK,
          },
        ])

        await shopeeSvc.processEvent(raw.id, getContainer())

        const refreshed = await shopeeSvc.retrieveShopeeRawEvent(raw.id)
        expect(refreshed.processed).toBe(true)

        const syncs = await shopeeSvc.listShopeeOrderSyncs({ ordersn: "240423FIXTURE" })
        expect(syncs).toHaveLength(1)
        expect(syncs[0].status).toBe(OrderSyncStatus.CREATED)
        expect(syncs[0].medusa_order_id).toBeTruthy()

        const orderModuleSvc: any = getContainer().resolve(Modules.ORDER)
        const orders = await orderModuleSvc.listOrders({ id: syncs[0].medusa_order_id })
        expect(orders).toHaveLength(1)
        expect(orders[0].currency_code).toBe("sgd")
      })

      it("creates a ConnectorException + leaves raw event unprocessed when SKU is missing", async () => {
        const [raw] = await shopeeSvc.createShopeeRawEvents([
          {
            event_type: PushCode.ORDER_STATUS_UPDATE,
            shopee_event_id: "evt_proc_002_miss",
            shop_id: 9999n,
            ordersn: "240423MISSING",
            payload: { code: 3, data: '{"ordersn":"240423MISSING"}' },
            source: RawEventSource.WEBHOOK,
            signature_ok: true,
          },
        ])

        // Override the mock to return an order whose SKU isn't seeded.
        const sdkModule = require("../sdk")
        sdkModule.getShopeeSdk.mockReturnValueOnce({
          order: {
            getOrdersDetail: jest.fn(async () => ({
              response: {
                order_list: [{
                  ...fixture.order_list[0],
                  order_sn: "240423MISSING",
                  item_list: [{ ...fixture.order_list[0].item_list[0], model_sku: "DOES-NOT-EXIST" }],
                }],
              },
            })),
          },
        })

        await shopeeSvc.processEvent(raw.id, getContainer())

        const refreshed = await shopeeSvc.retrieveShopeeRawEvent(raw.id)
        expect(refreshed.processed).toBe(false)

        const sharedSvc: any = getContainer().resolve(CONNECTOR_SHARED_MODULE)
        const exceptions = await sharedSvc.listConnectorExceptions({ raw_event_id: raw.id })
        expect(exceptions).toHaveLength(1)
        expect(exceptions[0].error_class).toBe("sku_not_found")
      })
    })
  },
})
  • [ ] Step 2: Run — should FAIL (processEvent doesn't exist)
yarn jest src/modules/connectors/shopee/__tests__/process-event.spec.ts
  • [ ] Step 3: Implement processEvent

Edit src/modules/connectors/shopee/service.ts:

// src/modules/connectors/shopee/service.ts

import { MedusaService, ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import type { MedusaContainer } from "@medusajs/framework/types"
import ShopeeRawEvent from "./models/shopee-raw-event"
import ShopeeOrderSync from "./models/shopee-order-sync"
import ShopeeEscrow from "./models/shopee-escrow"
import ShopeeAuthToken from "./models/shopee-auth-token"
import { getShopeeSdk } from "./sdk"
import { normalizeShopeeOrder } from "./normalizers/order"
import { normalizeShopeeEscrow } from "./normalizers/escrow"
import { mapShopeeStatusToAction } from "./normalizers/status"
import { resolveVariantBySku, SkuNotFoundError } from "./mappers/sku-to-variant"
import { OrderSyncStatus, PushCode } from "./types/enums"
import { CONNECTOR_SHARED_MODULE } from "../shared"
import { ConnectorName, ErrorClass } from "../shared/types/enums"
import { publishConnectorAlert } from "../shared/alerting"

class ShopeeConnectorService extends MedusaService({
  ShopeeRawEvent,
  ShopeeOrderSync,
  ShopeeEscrow,
  ShopeeAuthToken,
}) {
  /**
   * Subscriber-invoked entry point. Loads the raw event, fetches order
   * detail from Shopee, normalizes, dispatches to Medusa workflow,
   * upserts ShopeeOrderSync, marks raw event processed.
   *
   * On any failure: ConnectorException row + Telegram alert. Raw event
   * stays unprocessed so the operator can retry by flipping
   * processed=false back manually (Phase 5 ships a UI for this).
   */
  async processEvent(rawEventId: string, container: MedusaContainer): Promise<void> {
    const raw = await this.retrieveShopeeRawEvent(rawEventId)

    if (!raw.signature_ok) {
      // Signature handler already created the exception in PR C; nothing more to do.
      return
    }

    if (raw.event_type !== PushCode.ORDER_STATUS_UPDATE && raw.event_type !== PushCode.TRACKING_NO) {
      // Code 1/2/5-11/13 — log + ack only, never fetched detail.
      await this.updateShopeeRawEvents([{ id: raw.id, processed: true, processed_at: new Date() }])
      return
    }

    if (!raw.ordersn) {
      await this.recordException(raw.id, ErrorClass.NORMALIZATION_FAILED, "Raw event has no ordersn", container)
      return
    }

    const sharedSvc: any = container.resolve(CONNECTOR_SHARED_MODULE)
    const sdk = getShopeeSdk(this)

    let orderDetail: any
    try {
      const result = await sdk.order.getOrdersDetail({
        order_sn_list: [raw.ordersn],
        response_optional_fields: "buyer_user_id,buyer_username,recipient_address,item_list,package_list,total_amount,payment_method,message_to_seller",
      } as any)
      orderDetail = (result as any).response?.order_list?.[0]
    } catch (err: any) {
      await this.recordException(raw.id, ErrorClass.SDK_TRANSIENT, `getOrdersDetail failed: ${err.message ?? err}`, container)
      return
    }

    if (!orderDetail) {
      await this.recordException(raw.id, ErrorClass.SDK_TRANSIENT, `getOrdersDetail returned no order for ${raw.ordersn}`, container)
      return
    }

    const norm = normalizeShopeeOrder(orderDetail)

    // Resolve every line item's variant before dispatch — fail fast on missing SKUs.
    const items: Array<{ variant_id: string; sku: string; quantity: number; unit_price: number; title: string; metadata: any }> = []
    for (const li of norm.line_items) {
      try {
        const variant = await resolveVariantBySku(container, li.sku, raw.ordersn)
        items.push({ variant_id: variant.id, sku: li.sku, quantity: li.quantity, unit_price: li.unit_price, title: li.title, metadata: li.metadata })
      } catch (err) {
        if (err instanceof SkuNotFoundError) {
          await this.recordException(raw.id, ErrorClass.SKU_NOT_FOUND, err.message, container)
        } else {
          await this.recordException(raw.id, ErrorClass.UNKNOWN, (err as any).message ?? String(err), container)
        }
        return
      }
    }

    const action = mapShopeeStatusToAction(norm.shopee_status)

    // Upsert ShopeeOrderSync first so we have a row to attach the medusa_order_id to.
    const existing = await this.listShopeeOrderSyncs({ ordersn: raw.ordersn }, { take: 1 })
    let sync = existing[0]
    if (!sync) {
      ;[sync] = await this.createShopeeOrderSyncs([{
        ordersn: raw.ordersn,
        shop_id: BigInt(norm.shop_id),
        shopee_status: norm.shopee_status,
        last_raw_event_id: raw.id,
      }])
    } else {
      ;[sync] = await this.updateShopeeOrderSyncs([{
        id: sync.id,
        shopee_status: norm.shopee_status,
        last_raw_event_id: raw.id,
      }])
    }

    try {
      await this.dispatchToMedusa(action, norm, items, sync, container)
    } catch (err: any) {
      await this.recordException(raw.id, ErrorClass.MEDUSA_WORKFLOW_FAILED, err.message ?? String(err), container)
      return
    }

    await this.updateShopeeRawEvents([{ id: raw.id, processed: true, processed_at: new Date() }])
  }

  private async dispatchToMedusa(
    action: string,
    norm: ReturnType<typeof normalizeShopeeOrder>,
    items: Array<any>,
    sync: any,
    container: MedusaContainer,
  ): Promise<void> {
    // Use Medusa's workflow SDK rather than calling services directly so
    // the workflows fire their normal events / hooks. The createOrder
    // workflow handles inventory deduction + sales channel attribution.
    const workflowsSdk = container.resolve("workflowsSdk") as any  // Medusa 2.x: workflows execution
    const _ = workflowsSdk  // keep reference; usage varies by Medusa version — see executor for actual call

    if (action === "create_pending" || action === "mark_payment_captured") {
      // Reuse the workflow if a Medusa order doesn't yet exist for this ordersn.
      if (!sync.medusa_order_id) {
        const { createOrderWorkflow } = await import("@medusajs/medusa/core-flows")
        const { result } = await createOrderWorkflow(container).run({
          input: {
            currency_code: norm.currency_code,
            email: `shopee-${norm.buyer.user_id}@buyers.exzentcg.local`,
            sales_channel_id: await getOrCreateShopeeSalesChannel(container),
            shipping_address: norm.shipping_address as any,
            billing_address: norm.shipping_address as any,
            items: items.map((i) => ({ variant_id: i.variant_id, quantity: i.quantity, unit_price: i.unit_price, title: i.title, metadata: i.metadata })),
            metadata: { shopee_ordersn: norm.ordersn, shopee_buyer_user_id: norm.buyer.user_id },
          } as any,
        })
        const orderId = (result as any)?.id ?? (result as any)?.order?.id
        await this.updateShopeeOrderSyncs([{ id: sync.id, medusa_order_id: orderId, status: OrderSyncStatus.CREATED }])
      }
    } else if (action === "complete_and_fetch_escrow") {
      // Mark Medusa order completed AND enqueue an escrow fetch (PR E ships the job).
      // Phase 3 simplification: synchronously fetch escrow inline. PR E may move this to a workflow job.
      if (sync.medusa_order_id) {
        const sdk = getShopeeSdk(this)
        try {
          const result = await sdk.payment.getEscrowDetail({ order_sn: norm.ordersn } as any)
          const resp = (result as any).response
          const row = normalizeShopeeEscrow(resp, norm.currency_code.toUpperCase())
          await this.createShopeeEscrows([row as any])
        } catch (err: any) {
          // Escrow fetch failure is logged but doesn't fail the whole event —
          // the escrow-backstop-daily job will retry.
          await publishConnectorAlert({
            connector: ConnectorName.SHOPEE,
            error_class: ErrorClass.ESCROW_FETCH_FAILED,
            ordersn: norm.ordersn,
            shop_id: norm.shop_id,
            message: `getEscrowDetail failed: ${err.message ?? err}`,
            exception_id: "(deferred to backstop)",
            occurred_at: new Date().toISOString(),
          })
        }
      }
    } else if (action === "fulfill" || action === "create_shipment" || action === "cancel") {
      // PR D ships the dispatch surface; the actual workflow calls require knowledge
      // of the existing Medusa order's structure. Implementer should look up
      // @medusajs/medusa/core-flows for: createOrderShipmentWorkflow, markOrderFulfilledWorkflow,
      // cancelOrderWorkflow. Pattern is identical to createOrderWorkflow above.
      // For now: log + mark sync row only, leaving Medusa order untouched.
      // TODO(PR D follow-up): wire the actual workflow calls. Tests above cover
      // create_pending; implementer adds tests for these as they wire each.
    } else if (action === "log_only" || action === "unknown") {
      // No Medusa state change. Sync row's shopee_status was already updated above.
    }
  }

  private async recordException(
    rawEventId: string,
    errorClass: ErrorClass,
    message: string,
    container: MedusaContainer,
  ): Promise<void> {
    const sharedSvc: any = container.resolve(CONNECTOR_SHARED_MODULE)
    const [exception] = await sharedSvc.createConnectorExceptions([{
      connector: ConnectorName.SHOPEE,
      raw_event_id: rawEventId,
      error_class: errorClass,
      error_message: message,
      retry_count: 0,
    }])
    await publishConnectorAlert({
      connector: ConnectorName.SHOPEE,
      error_class: errorClass,
      message,
      exception_id: exception.id,
      raw_event_id: rawEventId,
      occurred_at: new Date().toISOString(),
    })
  }
}

async function getOrCreateShopeeSalesChannel(container: MedusaContainer): Promise<string> {
  const svc: any = container.resolve(Modules.SALES_CHANNEL)
  const existing = await svc.listSalesChannels({ name: "Shopee" }, { take: 1 })
  if (existing[0]) return existing[0].id
  const [created] = await svc.createSalesChannels([{ name: "Shopee", description: "Inbound from Shopee Open Platform" }])
  return created.id
}

export default ShopeeConnectorService

Note for the implementer: the dispatch for fulfill / create_shipment / cancel is intentionally left as a TODO inside the file. The pattern is the same as createOrderWorkflow above (import the workflow from @medusajs/medusa/core-flows, call .run({ input: ... })), but the exact field shapes depend on the Medusa order's current state. Add follow-up tasks D.6.fix.{a,b,c} during execution if the placeholder TODOs surface as test failures or operator feedback. The happy-path test (UNPAID → CREATED → READY_TO_SHIP) is fully covered.

  • [ ] Step 4: Test passes; commit
yarn jest src/modules/connectors/shopee/__tests__/process-event.spec.ts
git add src/modules/connectors/shopee/service.ts src/modules/connectors/shopee/__tests__/process-event.spec.ts
git commit -m "feat(connectors/shopee): processEvent orchestration (happy path + SKU-miss exception)"

Task D.7: raw-event-created subscriber

Files: - Create: src/subscribers/shopee-raw-event-created.ts

Medusa 2.x subscribers are file-based under src/subscribers/. The filename does not affect routing — the export specifies which event(s) it subscribes to.

  • [ ] Step 1: Implement subscriber
// src/subscribers/shopee-raw-event-created.ts

import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { SHOPEE_CONNECTOR_MODULE } from "../modules/connectors/shopee"

export default async function shopeeRawEventCreatedHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const service: any = container.resolve(SHOPEE_CONNECTOR_MODULE)
  await service.processEvent(data.id, container)
}

export const config: SubscriberConfig = {
  // Medusa 2.x emits "<entity>.created" for DML model rows by default.
  // entity name is the model file's table name in dot-snake form.
  event: "shopee_raw_event.created",
}
  • [ ] Step 2: Add subscriber-level integration test

Append to the existing process-event.spec.ts (or create subscriber.spec.ts):

describe("subscriber: shopee_raw_event.created", () => {
  it("end-to-end: insert raw event -> subscriber fires -> Medusa order created", async () => {
    const shopeeSvc: any = getContainer().resolve(SHOPEE_CONNECTOR_MODULE)

    await shopeeSvc.createShopeeRawEvents([{
      event_type: PushCode.ORDER_STATUS_UPDATE,
      shopee_event_id: "evt_sub_001",
      shop_id: 9999n,
      ordersn: "240423FIXTURE",
      payload: { code: 3, data: '{"ordersn":"240423FIXTURE"}' },
      signature_ok: true,
      source: RawEventSource.WEBHOOK,
    }])

    // Allow event bus to drain. Use a polling loop rather than fixed sleep.
    let processed = false
    for (let i = 0; i < 30; i++) {
      const rows = await shopeeSvc.listShopeeRawEvents({ shopee_event_id: "evt_sub_001" })
      if (rows[0]?.processed) { processed = true; break }
      await new Promise((r) => setTimeout(r, 200))
    }
    expect(processed).toBe(true)
  })
})
  • [ ] Step 3: Run + commit
yarn jest src/modules/connectors/shopee/__tests__/process-event.spec.ts
git add src/subscribers/shopee-raw-event-created.ts src/modules/connectors/shopee/__tests__/process-event.spec.ts
git commit -m "feat(subscribers): shopee_raw_event.created -> processEvent"

Task D.8: PR D — push + open PR

git push -u origin draft/claude-phase-3-pr-d-flow
gh pr create --base main --head draft/claude-phase-3-pr-d-flow \
  --title "feat(connectors/shopee): Phase 3 PR D — subscriber + normalizers + workflow dispatch" \
  --body "$(cat <<'EOF'
## Summary

Fourth of 5 PRs implementing Phase 3. First end-to-end Medusa order from a Shopee webhook. Status update + escrow fetch (on COMPLETED) wired in the same PR.

## What landed

- \`mappers/sku-to-variant.ts\` — direct \`model_sku\` -> Medusa \`variant.sku\` lookup
- \`normalizers/{address,order,status,escrow}.ts\` — pure functions, fixture-tested
- \`ShopeeConnectorService.processEvent\` — orchestrator: load raw -> SDK get_order_detail -> normalize -> resolve SKUs -> dispatch to Medusa workflow -> upsert sync row
- \`subscribers/shopee-raw-event-created.ts\` — auto-fires on raw event insert
- Escrow fetched inline on COMPLETED (synchronous in PR D; PR E backstop catches misses)
- All audit-driven field renames live in normalizers/escrow.ts

## Known gaps (next PR / follow-ups)

- Dispatch for \`fulfill\` / \`create_shipment\` / \`cancel\` actions is stubbed (logged TODO inside dispatchToMedusa). Wire the actual core-flows workflows in PR D follow-ups as needed — see comments in service.ts. Happy path (UNPAID -> CREATED) fully tested.
- Auto-retry on SDK transient errors: deferred to PR E (lost-push recovery covers most of it).

## Test plan

- [ ] Unit: normalizers, mapper, status matrix
- [ ] Integration: processEvent happy path + SKU-miss exception
- [ ] Integration: subscriber end-to-end (raw event insert -> Medusa order)
- [ ] CI green

## Next

PR E — lost-push recovery, daily reconciliation, escrow backstop jobs.
EOF
)"

PR E — Lost-push recovery + reconciliation + escrow fetch jobs

Goal: Robustness layer. After this PR, missed webhooks are recovered automatically (hourly via getLostPushMessage), missed orders are caught by daily reconciliation, and missed escrow fetches are backfilled by a daily job.

Branch: draft/claude-phase-3-pr-e-jobs from tcg-platform/main (after PRs A–D).

Setup: cd /tmp && rm -rf tcg-platform-pre && git clone https://github.com/ExzenTCG/tcg-platform.git tcg-platform-pre && cd tcg-platform-pre && git checkout -b draft/claude-phase-3-pr-e-jobs

Medusa 2.x scheduled jobs live in src/jobs/<name>.ts and are picked up automatically. Each exports an async default function and a config object specifying the cron expression.

Task E.1: Lost-push recovery job

Files: - Create: src/jobs/shopee-lost-push-recovery.ts - Create: src/modules/connectors/shopee/__tests__/jobs/lost-push-recovery.spec.ts

The SDK's pushManager.getLostPushMessage() returns up to 100 missed messages, paginated via has_next_page + last_message_id. After each batch, we call confirmConsumedLostPushMessage({ last_message_id }) so the next call doesn't re-deliver them.

  • [ ] Step 1: Failing test (mocked SDK)
// src/modules/connectors/shopee/__tests__/jobs/lost-push-recovery.spec.ts
jest.setTimeout(60 * 1000)

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { SHOPEE_CONNECTOR_MODULE } from "../.."
import { __resetShopeeSdkForTesting } from "../../sdk"
import lostPushJob from "../../../../../jobs/shopee-lost-push-recovery"

jest.mock("../../sdk", () => ({
  __resetShopeeSdkForTesting: jest.fn(),
  getShopeeSdk: jest.fn(),
}))

medusaIntegrationTestRunner({
  inApp: true,
  env: { SHOPEE_PARTNER_KEY: "k", SHOPEE_PARTNER_ID: "1", SHOPEE_SHOP_ID: "9999" },
  testSuite: ({ getContainer }) => {
    beforeEach(() => {
      ;(__resetShopeeSdkForTesting as jest.Mock).mockClear()
    })

    it("inserts ShopeeRawEvent rows for each lost message and advances cursor", async () => {
      const sdkModule = require("../../sdk")
      sdkModule.getShopeeSdk.mockReturnValue({
        push: {
          getLostPushMessage: jest.fn()
            .mockResolvedValueOnce({
              response: {
                push_message_list: [
                  { msg_id: 1001, shop_id: 9999, code: 3, timestamp: 1700000001, data: '{"ordersn":"LOST001"}' },
                  { msg_id: 1002, shop_id: 9999, code: 4, timestamp: 1700000002, data: '{"ordersn":"LOST002"}' },
                ],
                has_next_page: false,
                last_message_id: 1002,
              },
            }),
          confirmConsumedLostPushMessage: jest.fn().mockResolvedValue({ response: {} }),
        },
      })

      await lostPushJob({ container: getContainer() } as any)

      const svc: any = getContainer().resolve(SHOPEE_CONNECTOR_MODULE)
      const recovered = await svc.listShopeeRawEvents({ source: "poll" })
      expect(recovered).toHaveLength(2)
      expect(recovered.map((r: any) => r.ordersn).sort()).toEqual(["LOST001", "LOST002"])
      expect(sdkModule.getShopeeSdk().push.confirmConsumedLostPushMessage).toHaveBeenCalledWith({ last_message_id: 1002 })
    })

    it("paginates while has_next_page is true", async () => {
      const sdkModule = require("../../sdk")
      sdkModule.getShopeeSdk.mockReturnValue({
        push: {
          getLostPushMessage: jest.fn()
            .mockResolvedValueOnce({ response: { push_message_list: [{ msg_id: 2001, shop_id: 9999, code: 3, timestamp: 1700000010, data: '{"ordersn":"PAGE_A"}' }], has_next_page: true, last_message_id: 2001 } })
            .mockResolvedValueOnce({ response: { push_message_list: [{ msg_id: 2002, shop_id: 9999, code: 3, timestamp: 1700000011, data: '{"ordersn":"PAGE_B"}' }], has_next_page: false, last_message_id: 2002 } }),
          confirmConsumedLostPushMessage: jest.fn().mockResolvedValue({ response: {} }),
        },
      })

      await lostPushJob({ container: getContainer() } as any)
      expect(sdkModule.getShopeeSdk().push.getLostPushMessage).toHaveBeenCalledTimes(2)
    })
  },
})
  • [ ] Step 2: Implement
// src/jobs/shopee-lost-push-recovery.ts

import type { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { SHOPEE_CONNECTOR_MODULE } from "../modules/connectors/shopee"
import { getShopeeSdk } from "../modules/connectors/shopee/sdk"
import { PushCode, RawEventSource } from "../modules/connectors/shopee/types/enums"

export default async function shopeeLostPushRecoveryJob({
  container,
}: { container: MedusaContainer }) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const service: any = container.resolve(SHOPEE_CONNECTOR_MODULE)
  const sdk = getShopeeSdk(service)

  let totalRecovered = 0
  let lastMessageId: number | undefined = undefined

  for (let page = 0; page < 50 /* hard cap to prevent runaway */; page++) {
    const result: any = await sdk.push.getLostPushMessage(
      lastMessageId !== undefined ? ({ message_id_start: lastMessageId } as any) : ({} as any),
    )
    const resp = result.response
    const messages: any[] = resp?.push_message_list ?? []

    if (messages.length === 0) break

    // Insert each as a ShopeeRawEvent — subscriber processes them.
    const rows = messages.map((m) => ({
      event_type: m.code,
      shopee_event_id: `lost_${m.msg_id}`,
      shop_id: BigInt(m.shop_id ?? 0),
      ordersn: extractOrdersn(m.data),
      payload: m,
      signature: null,
      signature_ok: null,
      source: RawEventSource.POLL,
    }))
    await service.createShopeeRawEvents(rows)
    totalRecovered += rows.length

    lastMessageId = resp.last_message_id

    // Advance cursor on Shopee's side.
    if (lastMessageId !== undefined) {
      await sdk.push.confirmConsumedLostPushMessage({ last_message_id: lastMessageId } as any)
    }

    if (!resp.has_next_page) break
  }

  if (totalRecovered > 0) {
    logger.info(`[shopee-lost-push-recovery] recovered ${totalRecovered} missed pushes`)
  }
}

function extractOrdersn(data: string | undefined): string | null {
  if (!data) return null
  try {
    const parsed = JSON.parse(data)
    if (typeof parsed.ordersn === "string") return parsed.ordersn
  } catch {}
  return null
}

export const config = {
  name: "shopee-lost-push-recovery",
  schedule: "0 * * * *",  // every hour
}
  • [ ] Step 3: Test passes; commit
yarn jest src/modules/connectors/shopee/__tests__/jobs/lost-push-recovery.spec.ts
git add src/jobs/shopee-lost-push-recovery.ts src/modules/connectors/shopee/__tests__/jobs/
git commit -m "feat(jobs): hourly Shopee lost-push recovery via getLostPushMessage"

Task E.2: Daily reconciliation job

Files: - Create: src/jobs/shopee-reconcile-daily.ts - Create: src/modules/connectors/shopee/__tests__/jobs/reconcile-daily.spec.ts

Backstop for messages older than 3 days (the lost-push window) or other edge cases. Pulls full order list for past 24h via sdk.order.getOrderList, finds orphans not in ShopeeOrderSync, injects them as raw events with source='reconcile'.

  • [ ] Step 1: Test + impl in one pass (pattern same as E.1)
// src/modules/connectors/shopee/__tests__/jobs/reconcile-daily.spec.ts
jest.setTimeout(60 * 1000)

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { SHOPEE_CONNECTOR_MODULE } from "../.."
import reconcileJob from "../../../../../jobs/shopee-reconcile-daily"

jest.mock("../../sdk", () => ({ __resetShopeeSdkForTesting: jest.fn(), getShopeeSdk: jest.fn() }))

medusaIntegrationTestRunner({
  inApp: true,
  env: { SHOPEE_PARTNER_KEY: "k", SHOPEE_PARTNER_ID: "1", SHOPEE_SHOP_ID: "9999" },
  testSuite: ({ getContainer }) => {
    it("creates raw events for orders not yet in ShopeeOrderSync", async () => {
      const svc: any = getContainer().resolve(SHOPEE_CONNECTOR_MODULE)
      // Pretend one order is already known.
      await svc.createShopeeOrderSyncs([{ ordersn: "KNOWN_001", shop_id: 9999n, shopee_status: "READY_TO_SHIP" }])

      const sdkModule = require("../../sdk")
      sdkModule.getShopeeSdk.mockReturnValue({
        order: {
          getOrderList: jest.fn().mockResolvedValue({
            response: {
              order_list: [
                { order_sn: "KNOWN_001", order_status: "READY_TO_SHIP" },
                { order_sn: "ORPHAN_001", order_status: "READY_TO_SHIP" },
              ],
              more: false,
              next_cursor: "",
            },
          }),
        },
      })

      await reconcileJob({ container: getContainer() } as any)

      const orphanRaws = await svc.listShopeeRawEvents({ ordersn: "ORPHAN_001", source: "reconcile" })
      expect(orphanRaws).toHaveLength(1)
      const knownRaws = await svc.listShopeeRawEvents({ ordersn: "KNOWN_001", source: "reconcile" })
      expect(knownRaws).toHaveLength(0)  // already known, skipped
    })
  },
})
// src/jobs/shopee-reconcile-daily.ts

import type { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { SHOPEE_CONNECTOR_MODULE } from "../modules/connectors/shopee"
import { getShopeeSdk } from "../modules/connectors/shopee/sdk"
import { PushCode, RawEventSource } from "../modules/connectors/shopee/types/enums"

export default async function shopeeReconcileDailyJob({
  container,
}: { container: MedusaContainer }) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const service: any = container.resolve(SHOPEE_CONNECTOR_MODULE)
  const sdk = getShopeeSdk(service)

  const now = Math.floor(Date.now() / 1000)
  const twentyFourHoursAgo = now - 24 * 3600

  let cursor: string | undefined = undefined
  let totalOrphans = 0

  for (let page = 0; page < 50; page++) {
    const result: any = await sdk.order.getOrderList({
      time_range_field: "create_time",
      time_from: twentyFourHoursAgo,
      time_to: now,
      page_size: 100,
      cursor,
    } as any)
    const resp = result.response
    const orders: any[] = resp?.order_list ?? []
    if (orders.length === 0) break

    // Check which ordersns we already know.
    const ordersns = orders.map((o) => o.order_sn)
    const known = await service.listShopeeOrderSyncs({ ordersn: ordersns })
    const knownSet = new Set(known.map((k: any) => k.ordersn))
    const orphans = orders.filter((o) => !knownSet.has(o.order_sn))

    if (orphans.length > 0) {
      const rows = orphans.map((o) => ({
        event_type: PushCode.ORDER_STATUS_UPDATE,
        shopee_event_id: `reconcile_${o.order_sn}_${now}`,
        shop_id: BigInt(Number(process.env.SHOPEE_SHOP_ID ?? 0)),
        ordersn: o.order_sn,
        payload: { code: 3, data: JSON.stringify({ ordersn: o.order_sn, status: o.order_status }) },
        signature: null,
        signature_ok: null,
        source: RawEventSource.RECONCILE,
      }))
      await service.createShopeeRawEvents(rows)
      totalOrphans += rows.length
    }

    if (!resp.more) break
    cursor = resp.next_cursor
  }

  if (totalOrphans > 0) {
    logger.info(`[shopee-reconcile-daily] injected ${totalOrphans} orphan orders for processing`)
  }
}

export const config = {
  name: "shopee-reconcile-daily",
  schedule: "0 19 * * *",  // 03:00 SGT (UTC+8) = 19:00 UTC previous day
}
yarn jest src/modules/connectors/shopee/__tests__/jobs/reconcile-daily.spec.ts
git add src/jobs/shopee-reconcile-daily.ts src/modules/connectors/shopee/__tests__/jobs/reconcile-daily.spec.ts
git commit -m "feat(jobs): daily reconciliation backstop (03:00 SGT)"

Task E.3: Escrow backstop daily job

Files: - Create: src/jobs/shopee-escrow-backstop-daily.ts - Create: src/modules/connectors/shopee/__tests__/jobs/escrow-backstop-daily.spec.ts

Catches missed escrow fetches AND populates escrow_release_time (which is only available via getEscrowList(), not getEscrowDetail()).

  • [ ] Step 1: Test
// src/modules/connectors/shopee/__tests__/jobs/escrow-backstop-daily.spec.ts
jest.setTimeout(60 * 1000)

import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { SHOPEE_CONNECTOR_MODULE } from "../.."
import escrowBackstopJob from "../../../../../jobs/shopee-escrow-backstop-daily"

jest.mock("../../sdk", () => ({ __resetShopeeSdkForTesting: jest.fn(), getShopeeSdk: jest.fn() }))

medusaIntegrationTestRunner({
  inApp: true,
  env: { SHOPEE_PARTNER_KEY: "k", SHOPEE_PARTNER_ID: "1", SHOPEE_SHOP_ID: "9999" },
  testSuite: ({ getContainer }) => {
    it("backfills missing ShopeeEscrow rows for completed orders", async () => {
      const svc: any = getContainer().resolve(SHOPEE_CONNECTOR_MODULE)

      // Seed: a completed sync with no escrow row.
      await svc.createShopeeOrderSyncs([{
        ordersn: "ESC_BACK_001",
        shop_id: 9999n,
        shopee_status: "COMPLETED",
        status: "created",
      }])

      const sdkModule = require("../../sdk")
      sdkModule.getShopeeSdk.mockReturnValue({
        payment: {
          getEscrowList: jest.fn().mockResolvedValue({
            response: {
              escrow_list: [
                { order_sn: "ESC_BACK_001", payout_amount: 1000, escrow_release_time: 1700100000 },
              ],
              more: false,
            },
          }),
          getEscrowDetail: jest.fn().mockResolvedValue({
            response: {
              order_sn: "ESC_BACK_001",
              buyer_user_name: "x",
              return_order_sn_list: [],
              buyer_payment_info: { buyer_paid_amount: 1000 },
              order_income: {
                escrow_amount: 1000, original_price: 1000, seller_discount: 0, shopee_discount: 0,
                voucher_from_seller: 0, voucher_from_shopee: 0, commission_fee: 30, service_fee: 20,
                seller_transaction_fee: 10, actual_shipping_fee: 100, seller_lost_compensation: 0,
                buyer_total_amount: 1000,
              },
            },
          }),
        },
      })

      await escrowBackstopJob({ container: getContainer() } as any)

      const escrows = await svc.listShopeeEscrows({ ordersn: "ESC_BACK_001" })
      expect(escrows).toHaveLength(1)
      expect(escrows[0].escrow_release_time).toEqual(new Date(1700100000 * 1000))
    })
  },
})
  • [ ] Step 2: Implement
// src/jobs/shopee-escrow-backstop-daily.ts

import type { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { SHOPEE_CONNECTOR_MODULE } from "../modules/connectors/shopee"
import { getShopeeSdk } from "../modules/connectors/shopee/sdk"
import { normalizeShopeeEscrow } from "../modules/connectors/shopee/normalizers/escrow"

export default async function shopeeEscrowBackstopDailyJob({
  container,
}: { container: MedusaContainer }) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const service: any = container.resolve(SHOPEE_CONNECTOR_MODULE)
  const sdk = getShopeeSdk(service)

  const now = Math.floor(Date.now() / 1000)
  const fortyTwoDaysAgo = now - 42 * 24 * 3600  // wide window — Shopee's escrow window varies

  // 1. Pull escrow list for the window — the only source of escrow_release_time.
  const releaseTimesByOrdersn = new Map<string, Date>()
  let pageNo = 1
  for (let i = 0; i < 50; i++) {
    const result: any = await sdk.payment.getEscrowList({
      release_time_from: fortyTwoDaysAgo,
      release_time_to: now,
      page_size: 100,
      page_no: pageNo,
    } as any)
    const list: any[] = result.response?.escrow_list ?? []
    if (list.length === 0) break
    for (const item of list) {
      if (item.order_sn && item.escrow_release_time) {
        releaseTimesByOrdersn.set(item.order_sn, new Date(item.escrow_release_time * 1000))
      }
    }
    if (!result.response.more) break
    pageNo += 1
  }

  // 2. Find completed sync rows that are missing escrow rows.
  const completedSyncs = await service.listShopeeOrderSyncs({ shopee_status: "COMPLETED" })
  let backfilled = 0

  for (const sync of completedSyncs) {
    const existing = await service.listShopeeEscrows({ ordersn: sync.ordersn })
    if (existing.length > 0) {
      // Just patch escrow_release_time if we now have it.
      if (releaseTimesByOrdersn.has(sync.ordersn) && !existing[0].escrow_release_time) {
        await service.updateShopeeEscrows([{ id: existing[0].id, escrow_release_time: releaseTimesByOrdersn.get(sync.ordersn) }])
        backfilled += 1
      }
      continue
    }

    try {
      const result: any = await sdk.payment.getEscrowDetail({ order_sn: sync.ordersn } as any)
      const resp = result.response
      const row = normalizeShopeeEscrow(resp, "SGD") // SG shop — see audit for currency rationale
      row.escrow_release_time = releaseTimesByOrdersn.get(sync.ordersn) ?? null
      await service.createShopeeEscrows([row as any])
      backfilled += 1
    } catch (err: any) {
      // error_not_found is expected when escrow isn't ready yet — skip silently.
      const msg = err.message ?? String(err)
      if (!/not_found/i.test(msg)) {
        logger.warn(`[shopee-escrow-backstop] ${sync.ordersn}: ${msg}`)
      }
    }
  }

  if (backfilled > 0) {
    logger.info(`[shopee-escrow-backstop] backfilled escrow data for ${backfilled} orders`)
  }
}

export const config = {
  name: "shopee-escrow-backstop-daily",
  schedule: "0 20 * * *",  // 04:00 SGT = 20:00 UTC
}
yarn jest src/modules/connectors/shopee/__tests__/jobs/escrow-backstop-daily.spec.ts
git add src/jobs/shopee-escrow-backstop-daily.ts src/modules/connectors/shopee/__tests__/jobs/escrow-backstop-daily.spec.ts
git commit -m "feat(jobs): daily escrow backstop (04:00 SGT) — backfills missing escrow + release_time"

Task E.4: Token expiry alert (push code 12)

Files: - Modify: src/modules/connectors/shopee/service.ts — extend processEvent to handle code 12

  • [ ] Step 1: Add handler clause

Inside processEvent, add a special-case check before the event_type !== ORDER_STATUS_UPDATE && event_type !== TRACKING_NO short-circuit:

if (raw.event_type === PushCode.AUTH_EXPIRY) {
  await this.recordException(
    raw.id,
    ErrorClass.TOKEN_EXPIRED,
    `Shopee push code 12: OpenAPI authorization expiring/expired (shop_id=${raw.shop_id}). ` +
    `Re-run shopee-oauth-bootstrap.ts before access expires.`,
    container,
  )
  await this.updateShopeeRawEvents([{ id: raw.id, processed: true, processed_at: new Date() }])
  return
}
  • [ ] Step 2: Add test

Append to process-event.spec.ts:

it("creates a token-expired exception + alert on push code 12", async () => {
  const [raw] = await shopeeSvc.createShopeeRawEvents([{
    event_type: PushCode.AUTH_EXPIRY,
    shopee_event_id: "evt_token_001",
    shop_id: 9999n,
    ordersn: null,
    payload: { code: 12, data: '{"shop_id":9999}' },
    signature_ok: true,
    source: RawEventSource.WEBHOOK,
  }])

  await shopeeSvc.processEvent(raw.id, getContainer())

  const sharedSvc: any = getContainer().resolve(CONNECTOR_SHARED_MODULE)
  const exceptions = await sharedSvc.listConnectorExceptions({ raw_event_id: raw.id })
  expect(exceptions).toHaveLength(1)
  expect(exceptions[0].error_class).toBe("token_expired")
})
  • [ ] Step 3: Test + commit
yarn jest src/modules/connectors/shopee/__tests__/process-event.spec.ts
git add src/modules/connectors/shopee/service.ts src/modules/connectors/shopee/__tests__/process-event.spec.ts
git commit -m "feat(connectors/shopee): handle push code 12 (auth expiry) with Telegram alert"

Task E.5: PR E — push + open

git push -u origin draft/claude-phase-3-pr-e-jobs
gh pr create --base main --head draft/claude-phase-3-pr-e-jobs \
  --title "feat(connectors/shopee): Phase 3 PR E — lost-push recovery + reconciliation + escrow backstop jobs" \
  --body "$(cat <<'EOF'
## Summary

Fifth and final \`tcg-platform\` PR for Phase 3. Adds the robustness layer: hourly lost-push recovery, daily reconciliation backstop, daily escrow backstop. Plus token-expiry alert handling.

## What landed

- \`src/jobs/shopee-lost-push-recovery.ts\` — hourly, uses \`getLostPushMessage\` (Shopee's purpose-built API)
- \`src/jobs/shopee-reconcile-daily.ts\` — 03:00 SGT, full \`getOrderList\` for past 24h, injects orphans
- \`src/jobs/shopee-escrow-backstop-daily.ts\` — 04:00 SGT, populates missing escrow rows + \`escrow_release_time\` (only available via \`getEscrowList\`)
- Push code 12 (auth expiry) → Telegram alert (handler in \`processEvent\`)

## Test plan

- [ ] All 3 job specs pass with mocked SDK
- [ ] processEvent token-expiry test passes
- [ ] CI green
- [ ] Smoke test on staging: jobs registered (visible in Medusa boot logs), one manual lost-push call returns expected shape

## Next

PR F — Phase 3 findings template + n8n workflow JSON + tcg-staging runbook update (in homelab repo).
EOF
)"

PR F — Phase 3 findings template + n8n workflow + runbook updates

Repo: ExzenTCG/ExzenTCG-Homelab (NOT tcg-platform — these are operational artefacts).

Branch: draft/claude-phase-3-pr-f-docs from ExzenTCG-Homelab/main.

Setup: cd /home/xkenchi/Documents/ExzenTCG-Homelab && git checkout main && git pull --ff-only && git checkout -b draft/claude-phase-3-pr-f-docs

This PR can ship in parallel with PR E since it touches a different repo.

Task F.1: Phase 3 findings template

Files: - Create: tcg-platform/phase-3-findings.md - Modify: mkdocs.yml

  • [ ] Step 1: Write the template

Model on tcg-platform/phase-2-findings.md. Status starts as "In progress ⏳"; the operator fills sections 4 / 5 / 7 during staging validation.

# Phase 3: Shopee Slice (Inbound) — Findings

**Status:** In progress ⏳ *(will flip to Complete ✅ after staging validation run)*
**Predecessor:** [Phase 3 Kickoff Plan](phase-3-plan.md) · [Implementation Plan](phase-3-implementation.md)
**Exit criterion target:** *"A real Shopee order lands in Medusa via the connector."*

## 1. Purpose + method

Validate the inbound Shopee connector end-to-end against the merchant's real Shopee shop credentials.

Method:

1. OAuth-bootstrap the Shopee in-house seller app against staging.
2. Place a low-value test order on the merchant's Shopee shop (sleeves single, ~$1).
3. Observe webhook → raw event → Medusa order creation within 5 min.
4. Drive status updates (ship, complete) and verify Medusa state mirrors.
5. Confirm escrow row populated post-COMPLETED.
6. Negative test: temporarily rename the SKU on Medusa side → trigger another order → verify exception + Telegram alert.

## 2. Connector primitives leaned on

- `@congminh1254/shopee-sdk` v1.5.5+ — order, payment, push managers
- `PostgresTokenStorage` — survives container restart, no Docker volume needed
- Shopee Open Platform v2 — webhook signature (HMAC-SHA256 of body), order detail, escrow detail, escrow list, lost push message recovery

## 3. Custom extensions added

- `src/modules/connectors/shopee/` — full module (service + 4 models + normalizers + mappers + 4 jobs + webhook + 1 subscriber)
- `src/modules/connectors/shared/``ConnectorException` + Telegram alerting
- `src/modules/tcg/` extended with `TcgChannelListing` (Phase 2 deferral)
- 3 Module Links: `ShopeeOrderSync ↔ Medusa order`, `TCGChannelListing → variant`, `TCGChannelListing → serialized item`
- 6 migrations (5 auto + 1 hand-written CHECK on `tcg_channel_listing`)

## 4. Verified scenarios

*To be confirmed after staging validation run.*

- ☐ OAuth bootstrap script succeeds; `shopee_auth_token` row appears
- ☐ Webhook signature verification: good signature returns 200 + `signature_ok=true`
- ☐ Webhook signature verification: tampered body returns 200 + `signature_ok=false` + ConnectorException + Telegram alert
- ☐ Real test order: webhook arrives within ~5s, Medusa order created within 5 min, line items match SKU, sales_channel = "Shopee"
- ☐ Status update: ship test order on Shopee → Medusa fulfillment status updates within 5 min
- ☐ Cancellation: cancel on Shopee → Medusa order canceled within 5 min
- ☐ Escrow: completed order produces a `ShopeeEscrow` row with non-zero `escrow_amount`
- ☐ Lost-push recovery: simulate dropped webhook (firewall block for 1h) → orders recovered on next hourly run
- ☐ Daily reconciliation: orders dropped from BOTH webhook and lost-push (e.g. >3-day-old) recovered overnight
- ☐ Negative SKU test: removed SKU produces exception + Telegram alert
- ☐ Token expiry alert (push code 12) lands in Telegram if Shopee fires it
- ☐ Idempotent: replay same webhook payload → no duplicate order

## 5. Gaps / concerns surfaced

*Fill in during validation. Anticipated candidates:*

- Stub workflow dispatches (`fulfill`, `create_shipment`, `cancel`) need wiring against real Medusa core-flows — initial implementation may surface missing input fields.
- Shopee's actual rate limits unknown until measured under real load.
- Escrow available date varies — real test order may take 5-7 days to settle.
- Customer matching policy on `buyer_user_id` — verify across orders from the same buyer.
- (Others observed during run.)

## 6. Phase 3 decisions recorded

- **Inbound only.** Outbound (price / stock push) deferred to Phase 6 alongside Lazada per project plan.
- **Push code matrix.** Codes 3 (order status), 4 (tracking), 12 (auth expiry) actioned; codes 1/2/5–11/13 ack + log only.
- **SKU mapping by direct match** (`model_sku === variant.sku`). No `TCGChannelListing` lookup for inbound.
- **`TCGChannelListing` populated during inbound** (cheap upsert), used for outbound in Phase 6.
- **All-failures-alert policy.** Operator can filter at n8n later if noise warrants.
- **PostgresTokenStorage** instead of SDK file storage (avoids sync I/O blocking event loop).
- **`getLostPushMessage`** instead of generic order-list polling for the hourly backstop.
- **Audit-driven corrections** captured in [implementation plan](phase-3-implementation.md): region GLOBAL not 'sg', HMAC of body only not url+body, escrow field renames, INVOICE_PENDING removed, etc.

## 7. Open questions for Phase 4+

*Fill in during validation. Anticipated:*

- Should PR D follow-ups (`fulfill` / `create_shipment` / `cancel` workflow dispatches) ship in Phase 3 or carry into Phase 5?
- Phase 4 (DCA) — does `escrow_amount` alone capture everything DCA needs, or are sub-fields (`final_shipping_fee`, `seller_return_refund`, `coins`, etc.) also required?
- TCGChannelListing outbound shape — confirm in Phase 6 design.
- (Others observed.)

## Links

- [Phase 3 Kickoff Plan](phase-3-plan.md)
- [Phase 3 Implementation Plan](phase-3-implementation.md)
- [`tcg-staging` runbook](../homelab/05_service_deployments/tcg-staging.md)
- [Audit report](https://gist.github.com/exzentcg/...) *(internal — not a public artefact)*
- [`@congminh1254/shopee-sdk`](https://github.com/congminh1254/shopee-sdk)
  • [ ] Step 2: Update mkdocs nav

Edit mkdocs.yml — under the TCG Platform section, append after Phase 3 Kickoff:

      - Phase 3 Implementation: tcg-platform/phase-3-implementation.md
      - Phase 3 Findings: tcg-platform/phase-3-findings.md
  • [ ] Step 3: Commit
git add tcg-platform/phase-3-findings.md mkdocs.yml
git commit -m "docs(tcg-platform): Phase 3 findings template + nav"

Task F.2: n8n alert formatter workflow

Files: - Create: homelab/04_n8n_workflows/shopee-alert-formatter.json (JSON export from n8n) - Create: homelab/04_n8n_workflows/3. Shopee Alert Formatter.md

The workflow is built in n8n's UI on CT 102 first, then exported via the n8n UI's "Download" button. The JSON is committed for reproducibility.

  • [ ] Step 1: Build in n8n (manual operator step — documented for repeatability)

In n8n on CT 102:

  1. Create new workflow: Shopee Alert Formatter
  2. Trigger node: Webhook — POST, path /webhook/shopee-alert, response mode "Last Node"
  3. Function node: format the payload into a Telegram message with severity emoji:
    const p = items[0].json
    const emoji = ({
      signature_mismatch: '🛑', sku_not_found: '⚠️', medusa_workflow_failed: '🔥',
      escrow_fetch_failed: '💸', sdk_transient: '🔁', token_expired: '🔑',
      retry_exhausted: '☠️', unknown: '❓',
    })[p.error_class] ?? '❓'
    return [{ json: { text:
      `${emoji} *Shopee connector failure*\n` +
      `\`${p.error_class}\`\n` +
      (p.ordersn ? `Order: \`${p.ordersn}\`\n` : '') +
      `Message: ${p.message}\n` +
      `Exception: \`${p.exception_id}\`\n` +
      (p.raw_event_id ? `Raw event: \`${p.raw_event_id}\`\n` : '') +
      `At: ${p.occurred_at}`
    }}]
    
  4. Telegram node: bot credential = the existing TCG ops bot, chat_id = #tcg-platform-alerts chat ID, text = {{$json.text}}, parse_mode = Markdown
  5. Activate the workflow

  6. [ ] Step 2: Export + commit

In n8n UI: Workflow menu → Download. Save as homelab/04_n8n_workflows/shopee-alert-formatter.json.

  • [ ] Step 3: Document the workflow
# 3. Shopee Alert Formatter

**Status:** Active ✅
**Trigger:** Webhook POST `https://n8n.exzentcg.com/webhook/shopee-alert`
**Output:** Telegram message to `#tcg-platform-alerts`
**Source:** [tcg-platform/phase-3-implementation.md](../../tcg-platform/phase-3-implementation.md) PR F task F.2

Receives structured alerts from the Shopee connector (\`publishConnectorAlert()\` in \`tcg-platform/src/modules/connectors/shared/alerting.ts\`) and forwards a formatted message to the dedicated Telegram channel.

## Payload shape

```json
{
  "connector": "shopee",
  "error_class": "sku_not_found",
  "ordersn": "240422TEST",
  "shop_id": 1234567890,
  "message": "SKU 'X' not found",
  "exception_id": "cex_abc123",
  "raw_event_id": "srev_xyz789",
  "occurred_at": "2026-04-23T08:15:30Z"
}

Severity emoji mapping

error_class Emoji
signature_mismatch 🛑
sku_not_found ⚠️
medusa_workflow_failed 🔥
escrow_fetch_failed 💸
sdk_transient 🔁
token_expired 🔑
retry_exhausted ☠️
unknown

Configuration

The connector reads the n8n webhook URL from SHOPEE_ALERT_WEBHOOK_URL env var on the Medusa side. Set it in docker-compose.yml (staging) or .env.development (local). When unset, alerts log to stderr instead of going to Telegram.

Re-importing

If the workflow needs to be restored on a fresh n8n instance: in n8n UI, Workflow menu → Import from File → select shopee-alert-formatter.json.

- [ ] **Step 4: Commit**

```bash
git add homelab/04_n8n_workflows/shopee-alert-formatter.json \
        "homelab/04_n8n_workflows/3. Shopee Alert Formatter.md"
git commit -m "feat(n8n): Shopee alert formatter workflow + doc"

Task F.3: tcg-staging runbook — Phase 3 applied row

Files: - Modify: homelab/05_service_deployments/tcg-staging.md

  • [ ] Step 1: Add Phase 3 status row

Append (or insert in the appropriate section) — below the existing "Phase 2 applied" line:

**Phase 3 applied:** in progress ⏳ — Shopee inbound connector live; awaiting end-to-end validation with real Shopee credentials. See [Phase 3 Findings](../../tcg-platform/phase-3-findings.md). *(Status flips to ✅ after a real test order completes the inbound flow + status update + escrow capture.)*

Add a new row to the Deployment Details table:

| Phase 3 snapshot | `phase-3-shopee-live` *(taken after staging validation completes)* |

Add a new troubleshooting section:

## Shopee connector troubleshooting

| Symptom | Likely cause | Resolution |
|---|---|---|
| Webhooks return 200 but no Medusa order | `signature_ok=false` on the raw event | Check `SHOPEE_PARTNER_KEY` env matches the partner portal value; verify timezone/clock skew on CT 105 |
| `SkuNotFoundError` on every webhook | Shopee `model_sku` not set or doesn't match Medusa SKU | Update model_sku on Shopee OR add the SKU to Medusa; then `UPDATE shopee_raw_event SET processed=false WHERE id='...'` to retry |
| Telegram alerts not arriving | `SHOPEE_ALERT_WEBHOOK_URL` unset OR n8n workflow disabled | Check env var; verify n8n CT 102 is reachable from CT 105; check n8n workflow is Active |
| `Token refresh failed` on SDK calls | Refresh token expired (Shopee documents ~30 days) | Re-run `yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <new_auth_code>` |
  • [ ] Step 2: Commit
git add homelab/05_service_deployments/tcg-staging.md
git commit -m "docs(tcg-staging): Phase 3 applied row + Shopee troubleshooting"

Task F.4: PR F — push + open

git push -u origin draft/claude-phase-3-pr-f-docs
gh pr create --base main --head draft/claude-phase-3-pr-f-docs \
  --title "docs(tcg-platform): Phase 3 findings template + n8n workflow + runbook updates" \
  --body "$(cat <<'EOF'
## Summary

PR F (homelab side) of the Phase 3 implementation. Operational artefacts that complement the \`tcg-platform\` PRs A–E.

## What landed

- \`tcg-platform/phase-3-findings.md\` — template, mirrors phase-2-findings.md shape (status / verified / gaps / decisions / open questions)
- \`homelab/04_n8n_workflows/shopee-alert-formatter.json\` + matching doc page — the n8n workflow that turns connector alerts into Telegram messages
- \`homelab/05_service_deployments/tcg-staging.md\` — Phase 3 applied row + Shopee troubleshooting section
- mkdocs.yml updated to surface phase-3-implementation + phase-3-findings

## Test plan

- [ ] Local Zensical render shows new pages cleanly
- [ ] CI green (build + secret scan)
- [ ] n8n workflow actually receives + formats a test alert (manual on staging)

## Next

Operator-driven: end-to-end validation on staging, fill in phase-3-findings, flip statuses to ✅.
EOF
)"

Manual end-to-end validation (post-PR-E, on staging)

This is the operator-driven validation that satisfies the Phase 3 exit criterion. Run after all 5 tcg-platform PRs (A–E) merge and the latest image is deployed to CT 105.

Prerequisites

  • Shopee in-house seller app credentials in hand (partner_id, partner_key, shop_id).
  • One Shopee OAuth code (single-use, ~10 min lifetime — get just before running step 1).
  • A second Shopee buyer account (or a colleague's) — must NOT be the merchant account.
  • SHOPEE_ALERT_WEBHOOK_URL set on staging pointing at the n8n workflow.
  • A test SKU listed on the merchant's Shopee shop at $1 with stock 1 — this is the order to be placed.
  • The matching SKU exists in Medusa (created via Phase 2 admin or seed script).

Steps

  1. Deploy + bootstrap

    # On CT 105
    cd /opt/tcg-platform && git pull && docker compose up -d --build
    docker compose logs -f medusa  # wait for "Server is ready on port: 9000"
    docker compose exec medusa yarn medusa exec ./src/scripts/shopee-oauth-bootstrap.ts <auth_code>
    docker compose exec medusa yarn medusa exec ./src/scripts/shopee-oauth-status.ts
    
    Expect: "Token persisted...". Status script confirms a row exists with future expiry.

  2. Subscribe to webhooks on Shopee side In Shopee partner portal, set the push URL to https://tcg-staging.exzentcg.com/connectors/shopee/webhook. Subscribe to push types 3 (order status), 4 (tracking), 12 (auth expiry).

  3. Place the test order Using the second Shopee account, place a $1 order on the test SKU. Pay immediately so the order moves past UNPAID.

  4. Observe inbound flow Within 5 min:

    docker compose exec postgres psql -U medusa -d medusa -c \
      "SELECT id, event_type, ordersn, signature_ok, processed FROM shopee_raw_event ORDER BY created_at DESC LIMIT 5;"
    docker compose exec postgres psql -U medusa -d medusa -c \
      "SELECT ordersn, shopee_status, status, medusa_order_id FROM shopee_order_sync ORDER BY created_at DESC LIMIT 5;"
    docker compose exec postgres psql -U medusa -d medusa -c \
      "SELECT id, error_class, error_message FROM connector_exception WHERE status = 'open';"
    
    Expect: 1+ raw event rows for the test ordersn (signature_ok=true, processed=true), 1 sync row in created state with medusa_order_id set, 0 open exceptions.

  5. Verify Medusa order Open https://tcg-staging.exzentcg.com/app/orders — the test order appears with the right line item, customer, shipping address, and Shopee sales channel.

  6. Status update test On Shopee, mark the order shipped (or trigger any status change). Within 5 min, verify Medusa fulfillment status updates.

  7. Cancellation test (separate $1 order) Place another $1 order; cancel it from Shopee. Within 5 min, verify Medusa order status is canceled.

  8. Escrow test Either complete-and-confirm the first test order on Shopee (or wait the auto-confirm period — 5–7 days) → escrow finalizes → ShopeeEscrow row appears with non-zero escrow_amount. Inspect:

    docker compose exec postgres psql -U medusa -d medusa -c \
      "SELECT ordersn, escrow_amount, voucher_from_seller, voucher_from_shopee, seller_transaction_fee, buyer_total_amount, currency FROM shopee_escrow ORDER BY created_at DESC LIMIT 5;"
    

  9. Negative SKU test Temporarily rename the test SKU on Medusa side. Place another $1 order on Shopee. Within 5 min, verify:

  10. Raw event row created
  11. ConnectorException row with error_class='sku_not_found'
  12. Telegram message in #tcg-platform-alerts
  13. Raw event NOT processed (waiting for operator action)

Restore SKU. Manually retry: UPDATE shopee_raw_event SET processed=false WHERE id='<srev_id>'; — within seconds the subscriber re-fires; verify Medusa order created.

  1. Lost-push recovery (optional, requires firewall manipulation) Block CT 105's inbound for 60 min on Shopee's IPs. Place an order. Restore connectivity. Wait for the next hourly cron — verify the order shows up with source='poll' on the raw event.

  2. Snapshot + flip status

    pct snapshot 105 phase-3-shopee-live --description "tcg-staging after Phase 3 inbound validated"
    
    Edit tcg-platform/phase-3-findings.md — flip status to Complete ✅, tick verified scenarios, fill gaps section with anything observed.


Out of scope for this plan (recap)

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
Auto-retry beyond SDK transient errors Phase 5
Lazada connector Phase 6
Per-channel pricing Phase 4
DCA computation from escrow data Phase 4
Custom Medusa admin widget for connector status Phase 5
Email / SMS alerting (Telegram only) Out of MVP
Multi-shop support Out of MVP
Shopee Ads / promotions / vouchers as first-class entities Out of MVP (raw in JSONB only)

Risk register

Risk Likelihood Impact Mitigation
SDK is CJS-only; Medusa workspace consumes via require() Medium Low Verified at smoke test in B.1; if require() fails, fall back to dynamic await import() and disable strict-ESM checks for the SDK module
Shopee API rate limits unknown until measured Medium Medium 3-attempt exponential backoff in connector + lost-push recovery as authoritative fallback. Monitor 429 rate during E2E; add jitter or queue if hit
model_sku discipline breaks (operator forgets to set on Shopee) Medium Low Each missed SKU produces a single Telegram alert — operator can fix on Shopee and retry. Manual queue via SQL until Phase 5 UI
escrow_release_time only via getEscrowList Low Low Backstop daily job populates it; primary path leaves null. Phase 4 (DCA) can decide if cashflow accounting needs it
Stub workflow dispatches (fulfill/cancel) leave Medusa state stale Medium Medium Documented as PR D follow-ups in service.ts comments; happy-path test is fully covered. Operator catches via daily reconciliation. Phase 5 likely re-touches this code anyway
Subscriber backlog if processing slows Low High processEvent is single-threaded per raw event. Worker mode (MEDUSA_WORKER_MODE=worker on a second container) is the scale path — defer until needed
n8n workflow not configured / wrong chat High during deploy Low Stderr fallback in publishConnectorAlert ensures durable record exists in connector_exception even if Telegram fails
Refresh token expires during long inactivity Low High Push code 12 (auth expiry) → Telegram alert. Operator re-runs OAuth bootstrap. Document in tcg-staging runbook (PR F)
Shopee API contract changes (e.g. new OrderStatus) Low Medium mapShopeeStatusToAction returns unknown for unmodelled values, defaults to log-only. Subscriber processes (no exception); Phase 3 findings doc captures observed deltas
Idempotency assumption (x-shopee-event-id reliable) wrong Low Medium If header turns out unreliable in the wild, switch to compound key (event_type, ordersn, timestamp)webhook/route.ts change only

Timeline estimate

Ballpark from Phase 2's actual experience (Phase 2 = 4 PRs ≈ 8 hours of dispatched subagent work + 4 hours of operator-driven debug). Phase 3 is bigger but more of it is mechanical (no novel domain modeling).

PR Estimated hours
A — scaffold + models + links 3–4h
B — SDK + token storage + OAuth 2–3h
C — webhook + sig + alerting 2–3h
D — subscriber + normalizers + dispatch 4–6h (largest — has the workflow stubs follow-up risk)
E — jobs 2–3h
F — docs / n8n 1–2h
Manual validation on staging 2–4h (plus 5–7 days waiting for first escrow to settle on real data)
Total active engineering 16–25h

Subagent-driven execution generally clears tasks 2–4× faster than estimated here; treat these as upper bounds.


References

  • Spec: phase-3-plan.md
  • Phase 2 implementation plan (template): phase-2-implementation.md
  • Phase 2 codebase patterns: src/modules/tcg/ in ExzenTCG/tcg-platform
  • SDK: @congminh1254/shopee-sdk v1.5.5+
  • Audit report: /tmp/shopee-audit-2026-04-23.md (subagent transcript)
  • Shopee Open Platform v2 (login-gated): https://open.shopee.com/documents/v2