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:
- HMAC-SHA256 with single shared secret (per PartnerConnection), manual rotation
- HMAC-SHA256 + key versioning (kid header, supports overlap during rotation)
- 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:
- Globally unique (one human = one Pasukuru member ever)
- 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
| ID | Title | Track | Jira | CC | Risk | Status | Depends |
|---|---|---|---|---|---|---|---|
| 0.P-1 | Add member.line_user_id column | P | PASS-? | 0.5 | M | Backlog | 0.D-2 |
| 0.P-3 | POST /integration/line/follower-link | P | PASS-? | 0.5 | L | Backlog | 0.P-1 |
| 0.P-4 | Emit order.* events | P | PASS-? | 1 | M | Backlog | 0.P-6 |
| 0.P-5 | OrderPayloadBuilder | P | PASS-? | 0.5 | L | Backlog | 0.P-6 |
| 0.P-6 | Order webhook payload types | P | PASS-? | 0.25 | L | Backlog | — |
| 0.P-7 | HMAC sign outbound webhooks | P | PASS-? | 1 | H | Backlog | 0.D-1 |
| 0.P-12 | BullMQ worker for order events | P | PASS-? | 0.5 | L | Backlog | 0.P-4 |
| 0.C-1 | Inbound receiver POST /api/integration/pasukuru/webhook | C | CRV-? | 1 | M | Backlog | 0.P-7 |
| 0.C-2 | HMAC verify middleware (Curva side) | C | CRV-? | 0.5 | M | Backlog | 0.D-1 |
| 0.C-3 | Webhook event router → handler classes | C | CRV-? | 1 | L | Backlog | 0.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:
- Add
WebhookEntity = 'product' | 'shop' | 'order' - Add
OrderEvent = 'created' | 'paid' | 'shipped' | 'cancelled' - Add
OrderData {id, orderNumber, lineUserId|null, memberId, totalAmount, currency, status, items: [{itemId, name, qty, unitPrice}], shippingAddress, paidAt|null, shippedAt|null} - 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:
- Class
OrderPayloadBuilderwithbuild(order: Order, event: OrderEvent): OrderEventPayload - Resolve
lineUserIdfrommember.line_user_id(joined) - Map order items via existing OrderItem entity
- 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:
- Generate migration:
npm run migration:generate:dev -- AddLineUserIdToMember - Add column
line_user_id VARCHAR(64) NULL - Add composite index
(admin_id, line_user_id)UNIQUE - Update entity with
@Column({nullable: true, length: 64}) lineUserId: string|null - Add to
Memberrepository as searchable - 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 — addlinkFollower)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:
- DTO with class-validator
- Service: lookup member by
(adminId resolved from API key, lineUserId) - If exists → return { memberId, created: false }
- 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 — addemitOrder)src/app/integration/services/webhook.listeners.ts(edit — listen + queue)
Implementation steps:
- Extend
WebhookEventEmitter:emitOrder(event: OrderEvent, data: OrderEvent): void { this.emitter.emit(`order.${event}`, data); } onOrderEvent(event: OrderEvent, cb): void { ... } - In
OrderService:- On create →
emitOrder('created', ...) - On
updateStatus(PAID)→emitOrder('paid', ...) - On
updateStatus(SHIPPED)→emitOrder('shipped', ...) - On
updateStatus(CANCELLED)→emitOrder('cancelled', ...)
- On create →
- Listener catches → builds payload via
OrderPayloadBuilder→ callsWebhookQueueService.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_EVENTSgates 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 inprocessors/)
Implementation steps:
- Switch case
entity→ POST URL templated bypartner_callback_url - Entity-specific URL path:
/api/integration/pasukuru/webhook(single path, type in payload) - 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:
- Create
integration_secrettable:id, partner_connection_id, kid, secret, created_at, expired_at - On PartnerConnection create → generate kid=1 secret
- Signer service:
sign(body, partnerConnection): {signature, timestamp, nonce, kid} - Processor adds headers before POST
- 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.phporHttp/Kernel.php(register middleware alias)
Logic:
- Read
X-Integration-Signature, Timestamp, Nonce, Kid, Version, Source - Reject if version != 1
- Reject if timestamp outside ±5 min
- Lookup secret by (partner_connection_id resolved via header or path, kid)
- Recompute HMAC → compare hash_equals
- Check nonce not seen in last 10 min (Redis SETNX with TTL)
- 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:
- Controller persists to
pasukuru_webhook_logstable FIRST (durability) - Dispatches Laravel Job → routed by entity (handler classes from 0.C-3)
- Returns 200 immediately (queue handles work)
- 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:
- Job constructor takes
webhookLogId handle()reads log, switches on (entity, event), invokes Action- ProductEvent: stub for now (cache table comes Phase 2 — C4)
- ShopEvent: stub
- OrderEvent: stub (Flex push comes Phase 2 — C7)
Acceptance:
- Job dispatches to correct Action by entity
- Unknown entity → log + skip
- All Action stubs throw
NotImplementedinitially (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:
- Pasukuru BE: create test order via existing
POST /api/v1/order(member with line_user_id set) - Mark order paid via existing endpoint
- Verify Pasukuru BullMQ job created in
curva-webhookqueue - Verify Pasukuru worker POSTs to Curva
/api/integration/pasukuru/webhookwith v1 headers - Verify Curva HMAC middleware accepts (200)
- Verify Curva
pasukuru_webhook_logstable has entry - Verify Curva job dispatched + Action invoked (logged)
Pass: all 7 steps green. Fail: any → rollback feature flag, debug, re-run.
Rollback plan
| Item | Rollback |
|---|---|
| 0.P-1 | Migration revert (column nullable, safe) |
| 0.P-3 | Endpoint flag off (no callers in Phase 0) |
| 0.P-4 | Flag integration.order_events=off |
| 0.P-7 | Flag integration.webhook.v1_hmac=off → falls back to v0 bearer |
| 0.C-1 | Flag integration.curva_inbound_receiver=off → 503 returned, Pasukuru retries DLQ |
| 0.C-2 | Coupled to 0.C-1 |
| 0.C-3 | Job 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