Phase 3: Shopee Slice (Inbound) — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-developmentto 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-platformPRs (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-Homelabdirectly.
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'ssrc/modules/tcg/__tests__/service.spec.tsfor the canonical pattern).
TDD discipline (every task):
- Write the failing test
- Run the test, confirm it fails for the expected reason (not a typo or import error)
- Write the minimum implementation that makes the test pass
- Run the test, confirm it passes
- 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.
PR A — scaffold + models + module links + migrations¶
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"
Task A.9: Module Link — ShopeeOrderSync ↔ Medusa Order¶
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)"
Task A.10: Module Link — TCGChannelListing → product variant¶
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)"
Task A.11: Module Link — TCGChannelListing → serialized item¶
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):
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.ts → POST /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/cancelis intentionally left as a TODO inside the file. The pattern is the same ascreateOrderWorkflowabove (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:
- Create new workflow: Shopee Alert Formatter
- Trigger node: Webhook — POST, path
/webhook/shopee-alert, response mode "Last Node" - 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}` }}] - Telegram node: bot credential = the existing TCG ops bot, chat_id =
#tcg-platform-alertschat ID, text ={{$json.text}}, parse_mode =Markdown -
Activate the workflow
-
[ ] 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_URLset 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¶
-
Deploy + bootstrap
Expect: "Token persisted...". Status script confirms a row exists with future expiry.# 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 -
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). -
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.
-
Observe inbound flow Within 5 min:
Expect: 1+ raw event rows for the test ordersn (signature_ok=true, processed=true), 1 sync row indocker 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';"createdstate withmedusa_order_idset, 0 open exceptions. -
Verify Medusa order Open
https://tcg-staging.exzentcg.com/app/orders— the test order appears with the right line item, customer, shipping address, andShopeesales channel. -
Status update test On Shopee, mark the order shipped (or trigger any status change). Within 5 min, verify Medusa fulfillment status updates.
-
Cancellation test (separate $1 order) Place another $1 order; cancel it from Shopee. Within 5 min, verify Medusa order status is
canceled. -
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;" -
Negative SKU test Temporarily rename the test SKU on Medusa side. Place another $1 order on Shopee. Within 5 min, verify:
- Raw event row created
- ConnectorException row with
error_class='sku_not_found' - Telegram message in
#tcg-platform-alerts - 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.
-
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. -
Snapshot + flip status
Editpct snapshot 105 phase-3-shopee-live --description "tcg-staging after Phase 3 inbound validated"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/inExzenTCG/tcg-platform - SDK:
@congminh1254/shopee-sdkv1.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