Phase 0 — Foundation (1 day parallel, ~6 CC sessions)

Goal: Complete the half-built integration. Both sides talk in v1 contract. Identity column added. Order events flow.

Exit gate: Curva receives an order webhook from Pasukuru staging, persists it, no LIFF needed yet.


Decisions in this phase

0.D-1 — HMAC algo + secret rotation policy

  • Status: open
  • Options:
    1. HMAC-SHA256 with single shared secret (per PartnerConnection), manual rotation
    2. HMAC-SHA256 + key versioning (kid header, supports overlap during rotation)
    3. Mutual TLS (overkill for now)
  • Recommendation: Option 2 — overlap window safer. Implementation only marginal cost.
  • Blocks: 0.P-7, 0.C-2

0.D-2 — member.line_user_id uniqueness scope

  • Status: open
  • Options:
    1. Globally unique (one human = one Pasukuru member ever)
    2. Unique per admin_id (same human can be member at multiple tenant shops)
  • Recommendation: Option 2 — matches existing tenant model + multi-shop future. Composite index (admin_id, line_user_id).
  • Blocks: 0.P-1

Tracking matrix

IDTitleTrackJiraCCRiskStatusDepends
0.P-1Add member.line_user_id columnPPASS-?0.5MBacklog0.D-2
0.P-3POST /integration/line/follower-linkPPASS-?0.5LBacklog0.P-1
0.P-4Emit order.* eventsPPASS-?1MBacklog0.P-6
0.P-5OrderPayloadBuilderPPASS-?0.5LBacklog0.P-6
0.P-6Order webhook payload typesPPASS-?0.25LBacklog
0.P-7HMAC sign outbound webhooksPPASS-?1HBacklog0.D-1
0.P-12BullMQ worker for order eventsPPASS-?0.5LBacklog0.P-4
0.C-1Inbound receiver POST /api/integration/pasukuru/webhookCCRV-?1MBacklog0.P-7
0.C-2HMAC verify middleware (Curva side)CCRV-?0.5MBacklog0.D-1
0.C-3Webhook event router → handler classesCCRV-?1LBacklog0.C-1

Total: 6.75 CC sessions = ~1 day parallel (3 worktrees) or ~2 days serial.


Items detail

0.P-6 — Webhook payload types extension

Track: Pasukuru BE CC: XS (0.25) Risk: L Goal: Add order event types alongside existing product/shop in webhook-payload.types.ts.

Files touched:

  • src/app/integration/types/webhook-payload.types.ts (edit)

Implementation steps:

  1. Add WebhookEntity = 'product' | 'shop' | 'order'
  2. Add OrderEvent = 'created' | 'paid' | 'shipped' | 'cancelled'
  3. Add OrderData {id, orderNumber, lineUserId|null, memberId, totalAmount, currency, status, items: [{itemId, name, qty, unitPrice}], shippingAddress, paidAt|null, shippedAt|null}
  4. Add OrderEventPayload = WebhookPayload<OrderData>

Acceptance:

  • Types compile, no breaking change to existing builders
  • Exported from module index

Test: type-check only (tsc --noEmit).

Done when: PR merged.


0.P-5 — OrderPayloadBuilder

Track: Pasukuru BE CC: S (0.5) Depends: 0.P-6 Risk: L Goal: Mirror existing product-payload.builder.ts for orders.

Files touched:

  • src/app/integration/builders/order-payload.builder.ts (new)

Implementation steps:

  1. Class OrderPayloadBuilder with build(order: Order, event: OrderEvent): OrderEventPayload
  2. Resolve lineUserId from member.line_user_id (joined)
  3. Map order items via existing OrderItem entity
  4. Include shipping address from order.shipping_*

Acceptance:

  • Builds correct payload for each event type
  • Returns null lineUserId if member not LINE-linked
  • Unit test with mock Order fixture

Test: order-payload.builder.spec.ts with 4 fixtures (one per event).


0.P-1 — member.line_user_id column

Track: Pasukuru BE CC: S (0.5) Depends: 0.D-2 Risk: M Goal: Add LINE identity column to member entity.

Files touched:

  • src/migrations/{timestamp}-add-line-user-id-to-member.ts (new)
  • src/app/master/member/entities/member.entity.ts (edit)

Implementation steps:

  1. Generate migration: npm run migration:generate:dev -- AddLineUserIdToMember
  2. Add column line_user_id VARCHAR(64) NULL
  3. Add composite index (admin_id, line_user_id) UNIQUE
  4. Update entity with @Column({nullable: true, length: 64}) lineUserId: string|null
  5. Add to Member repository as searchable
  6. Write migration:revert path

Acceptance:

  • Migration runs forward + reverse on local
  • Existing members untouched
  • Insert two members with same line_user_id under DIFFERENT admin_id → succeeds
  • Insert two with same line_user_id under SAME admin_id → fails with constraint error

Rollout:

  • Migration: yes (additive, safe)
  • Backfill: no
  • Feature flag: not needed (column nullable)

0.P-3 — POST /integration/line/follower-link

Track: Pasukuru BE CC: S (0.5) Depends: 0.P-1 Risk: L Goal: Endpoint Curva calls to bind a LineFollower to a Pasukuru member.

Files touched:

  • src/app/integration/integration.controller.ts (edit — add method)
  • src/app/integration/integration.service.ts (edit — add linkFollower)
  • src/app/integration/dto/link-follower.dto.ts (new)

Endpoint: POST /api/v1/integration/line/follower-link Auth: KuruApiKeyGuard Body: { lineUserId: string, email?: string, displayName?: string, phone?: string } Returns: { memberId: number, created: boolean }

Implementation steps:

  1. DTO with class-validator
  2. Service: lookup member by (adminId resolved from API key, lineUserId)
  3. If exists → return { memberId, created: false }
  4. Else create new member with line_user_id + optional fields → return { memberId, created: true }

Acceptance:

  • Endpoint guarded
  • Idempotent (calling twice with same lineUserId returns same memberId)
  • Validation rejects bad lineUserId format
  • e2e test in integration.e2e-spec.ts

0.P-4 — Emit order.* events

Track: Pasukuru BE CC: M (1) Depends: 0.P-5, 0.P-6 Risk: M Goal: Hook OrderService lifecycle to emit webhook events.

Files touched:

  • src/app/order/order.service.ts (edit — inject emitter, call on status change)
  • src/app/integration/services/webhook-event.emitter.ts (edit — add emitOrder)
  • src/app/integration/services/webhook.listeners.ts (edit — listen + queue)

Implementation steps:

  1. Extend WebhookEventEmitter:
    emitOrder(event: OrderEvent, data: OrderEvent): void {
      this.emitter.emit(`order.${event}`, data);
    }
    onOrderEvent(event: OrderEvent, cb): void { ... }
  2. In OrderService:
    • On create → emitOrder('created', ...)
    • On updateStatus(PAID)emitOrder('paid', ...)
    • On updateStatus(SHIPPED)emitOrder('shipped', ...)
    • On updateStatus(CANCELLED)emitOrder('cancelled', ...)
  3. Listener catches → builds payload via OrderPayloadBuilder → calls WebhookQueueService.addJob

Acceptance:

  • All 4 events fire on respective transitions
  • No duplicate emits (idempotency check)
  • Listener queues job
  • Existing product/shop emits unaffected
  • Feature flag FEATURE_INTEGRATION_ORDER_EVENTS gates emit (off by default)

Rollout:

  • Feature flag: yes (integration.order_events)
  • Rollback: flip flag off

0.P-12 — BullMQ worker accepts order entity

Track: Pasukuru BE CC: S (0.5) Depends: 0.P-4 Risk: L Goal: Worker dispatching webhooks understands entity: 'order'.

Files touched:

  • src/app/integration/processors/webhook.processor.ts (edit, file likely exists in processors/)

Implementation steps:

  1. Switch case entity → POST URL templated by partner_callback_url
  2. Entity-specific URL path: /api/integration/pasukuru/webhook (single path, type in payload)
  3. Reuse retry/backoff from queue config

Acceptance:

  • Order job processed identically to product/shop
  • Failed delivery → retried per BullMQ config
  • After max retries → DLQ stored

0.P-7 — HMAC sign outbound webhooks

Track: Pasukuru BE CC: M (1) Depends: 0.D-1 Risk: H (security boundary) Goal: Sign every outbound webhook with HMAC-SHA256 + timestamp + nonce + kid.

Files touched:

  • src/app/integration/services/webhook-signer.service.ts (new)
  • src/app/integration/processors/webhook.processor.ts (edit — add headers on POST)
  • src/app/integration/entities/integration-secret.entity.ts (new — kid versioning)
  • src/migrations/...-add-integration-secret-table.ts (new)

Headers added:

X-Integration-Version: 1
X-Integration-Signature: hmac-sha256=<hex>
X-Integration-Timestamp: <unix-seconds>
X-Integration-Nonce: <uuid>
X-Integration-Kid: <secret-version>
X-Integration-Source: pasukuru

Sign string: ${timestamp}.${nonce}.${body}

Implementation steps:

  1. Create integration_secret table: id, partner_connection_id, kid, secret, created_at, expired_at
  2. On PartnerConnection create → generate kid=1 secret
  3. Signer service: sign(body, partnerConnection): {signature, timestamp, nonce, kid}
  4. Processor adds headers before POST
  5. Document rotation: POST /integration/rotate-secret (admin-only, generates kid=N+1, marks N expired in 24h)

Acceptance:

  • All outbound POSTs carry 6 X-Integration-* headers
  • Sign string canonical (body bytes exact)
  • Multiple kids supported (overlap window)
  • Unit test for signer
  • Backward compat: if FEATURE_INTEGRATION_WEBHOOK_V1_HMAC=off → fall back to bearer-only (v0)

Rollout:

  • Feature flag: integration.webhook.v1_hmac (off → on after Curva 0.C-2 ready)
  • Coordinate flip with Curva inbound receiver (deploy Curva first, flip Pasukuru second)

0.C-2 — HMAC verify middleware (Curva)

Track: Curva CC: S (0.5) Depends: 0.D-1 Risk: M Goal: Middleware verifying inbound HMAC + replay window.

Files touched:

  • app/Http/Middleware/ValidatePasukuruSignature.php (new)
  • app/Models/IntegrationSecret.php (new — Curva also stores Pasukuru’s secrets per PartnerConnection)
  • database/migrations/...add_integration_secrets_table.php (new)
  • bootstrap/app.php or Http/Kernel.php (register middleware alias)

Logic:

  1. Read X-Integration-Signature, Timestamp, Nonce, Kid, Version, Source
  2. Reject if version != 1
  3. Reject if timestamp outside ±5 min
  4. Lookup secret by (partner_connection_id resolved via header or path, kid)
  5. Recompute HMAC → compare hash_equals
  6. Check nonce not seen in last 10 min (Redis SETNX with TTL)
  7. Pass → set request->partnerConnection

Acceptance:

  • Bad signature → 401
  • Stale timestamp → 401
  • Replay (same nonce) → 401
  • Valid → request continues
  • Pest feature test for each rejection path
  • Test for valid path

0.C-1 — Inbound receiver

Track: Curva CC: M (1) Depends: 0.C-2 Risk: M Goal: Single endpoint receiving all Pasukuru webhooks.

Files touched:

  • app/Http/Controllers/Api/Integration/PasukuruWebhookController.php (new)
  • routes/api.php (edit — add route under HMAC middleware)
  • app/Models/PasukuruWebhookLog.php (new — audit table)
  • database/migrations/...add_pasukuru_webhook_logs_table.php (new)

Endpoint: POST /api/integration/pasukuru/webhook Middleware: validate.pasukuru.signature (from 0.C-2)

Body shape:

{
  "event": "created|updated|deleted|paid|shipped|cancelled",
  "entity": "product|shop|order",
  "shop_id": "...",
  "data": { ... }
}

Implementation steps:

  1. Controller persists to pasukuru_webhook_logs table FIRST (durability)
  2. Dispatches Laravel Job → routed by entity (handler classes from 0.C-3)
  3. Returns 200 immediately (queue handles work)
  4. Idempotency: log table has unique index on (source, nonce)

Acceptance:

  • 200 returned within 200ms
  • Log persisted before dispatch
  • Duplicate nonce → 200 + skip dispatch
  • Pest feature test for each entity type

0.C-3 — Webhook event router

Track: Curva CC: M (1) Depends: 0.C-1 Risk: L Goal: Job dispatches to correct handler per (entity, event).

Files touched:

  • app/Jobs/Integration/HandlePasukuruWebhook.php (new)
  • app/Actions/Integration/Pasukuru/HandleProductEvent.php (new)
  • app/Actions/Integration/Pasukuru/HandleShopEvent.php (new)
  • app/Actions/Integration/Pasukuru/HandleOrderEvent.php (new)

Implementation steps:

  1. Job constructor takes webhookLogId
  2. handle() reads log, switches on (entity, event), invokes Action
  3. ProductEvent: stub for now (cache table comes Phase 2 — C4)
  4. ShopEvent: stub
  5. OrderEvent: stub (Flex push comes Phase 2 — C7)

Acceptance:

  • Job dispatches to correct Action by entity
  • Unknown entity → log + skip
  • All Action stubs throw NotImplemented initially (intentional, filled in later phases)
  • Test: dispatch each entity, assert correct Action invoked (mocked)

Phase 0 smoke test

Setup:

  • Curva LineAccount (staging) connected to Pasukuru staging tenant via existing handshake
  • Feature flags ON: integration.order_events, integration.webhook.v1_hmac, integration.curva_inbound_receiver

Procedure:

  1. Pasukuru BE: create test order via existing POST /api/v1/order (member with line_user_id set)
  2. Mark order paid via existing endpoint
  3. Verify Pasukuru BullMQ job created in curva-webhook queue
  4. Verify Pasukuru worker POSTs to Curva /api/integration/pasukuru/webhook with v1 headers
  5. Verify Curva HMAC middleware accepts (200)
  6. Verify Curva pasukuru_webhook_logs table has entry
  7. Verify Curva job dispatched + Action invoked (logged)

Pass: all 7 steps green. Fail: any → rollback feature flag, debug, re-run.


Rollback plan

ItemRollback
0.P-1Migration revert (column nullable, safe)
0.P-3Endpoint flag off (no callers in Phase 0)
0.P-4Flag integration.order_events=off
0.P-7Flag integration.webhook.v1_hmac=off → falls back to v0 bearer
0.C-1Flag integration.curva_inbound_receiver=off → 503 returned, Pasukuru retries DLQ
0.C-2Coupled to 0.C-1
0.C-3Job no-op if flag off

Total rollback time: < 5 min (flag flips only).


Phase 0 exit checklist

  • All 10 items DoD satisfied
  • Smoke test passed in staging
  • All feature flags ON in staging
  • Vault log entry filed
  • Memory saved
  • User sign-off → Phase 1 start