Solution A: Sequence Diagrams — Flow 1–6

แสดงลำดับการทำงานของ Flow ที่เกี่ยวข้องกับ Solution A
อ่านคู่กับ solution-a-mapping.md และ 3.legacy-fullsystem-tier-flows.md

Shared prerequisite: sequence นี้สมมติว่า recent sales/refund 48 เดือนถูกเตรียมพร้อมก่อน deploy logic ใหม่แล้ว
ต้องแก้ recent-sales-transaction.service.ts ให้ retention = accumulateThresholdMonth + 24 (จากเดิม + 12) และรัน one-time backfill ก่อน deploy


Legend

Symbol ความหมาย
🟢 ไม่เปลี่ยนจาก Legacy
🟡 เพิ่ม logic เล็กน้อย
🔴 เปลี่ยน logic สำคัญ
🔵 เพิ่ม method ใหม่

Flow 1: Sales Transaction

ไม่เปลี่ยนอะไรเลย — เหมือน Legacy 100%

sequenceDiagram autonumber participant ExtSys as External Sales System participant Kafka as Kafka Broker participant STCtrl as SalesTransaction Controller<br/>(Kafka Consumer) participant STSvc as SalesTransactionService participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) participant EvBus as Domain Event Bus participant Listener as MemberTierUpdated Listener participant KOut as Kafka (Outbound) ExtSys->>Kafka: Publish SalesTransactionCompletedDomainEvent Kafka->>STCtrl: @EventPattern consume message STCtrl->>STSvc: createSalesTransaction(memberId, brandCode, amounts, ...) STSvc->>MRepo: findById(memberId) + load recent sales txns MRepo->>DB: SELECT Member + SalesTransactions + RefundSalesTransactions DB-->>MRepo: member data with transactions MRepo-->>STSvc: MemberEntity (hydrated) STSvc->>TRepo: findAllActive() TRepo->>DB: SELECT Tier WHERE active = true DB-->>TRepo: tier rows TRepo-->>STSvc: TierEntity[] STSvc->>STSvc: Create SalesTransactionEntity STSvc->>Member: addNewSalesTransaction(salesTxnEntity, tiers) Note over Member: 🟢 Push txn to _newSalesTransaction list Note over Member: 🟢 calculateAccumulateSpending() Note right of Member: sum(totalAccumSpendableAmount)<br/>for txns in [now - 24 months, now]<br/>minus sum(revokeAccumSpendableAmount)<br/>for refunds in same window Note over Member: 🟢 calculateAccumulateMaintainSpending() Note right of Member: sum(totalAccumSpendableAmount)<br/>for txns in [tierStartedAt, tierEndedAt]<br/>minus refunds in same window Note over Member: 🟢 lifeTimeSpending += txn.totalAccumSpendableAmount Member->>Member: calculateTierToUpgrade(tiers) Note right of Member: 🟢 Filter NORMAL tiers,<br/>sort by minimumSpending DESC,<br/>find first where accumulateSpending >= minimumSpending,<br/>apply minimumTier floor (co-brand) alt newTier.id !== currentTierId (UPGRADE) Member->>Member: updateMemberTier(newTier, newStartDate, newEndDate) Member->>Member: Create MemberTierHistory record Member->>EvBus: emit MemberTierUpdatedDomainEvent Member->>EvBus: emit MemberTierUpgradedDomainEvent else newTier.id === currentTierId (NO UPGRADE) Note over Member: 🟢 ไม่เปลี่ยน tier end Member-->>STSvc: return (member updated in memory) STSvc->>MRepo: save(member) MRepo->>DB: BEGIN TRANSACTION MRepo->>DB: INSERT SalesTransaction MRepo->>DB: UPDATE Member (accumulateSpending, accumulateMaintainSpending, lifeTimeSpending, tierId, tierStartedAt, tierEndedAt) MRepo->>DB: INSERT MemberTierHistory (if tier changed) MRepo->>DB: COMMIT alt Tier changed EvBus->>Listener: handle MemberTierUpgradedDomainEvent Listener->>KOut: publish to Kafka topic KOut-->>ExtSys: Downstream services consume (Engagement, Notification) end

Solution A: ไม่เปลี่ยนอะไรเลย — Flow นี้เหมือน Legacy 100%


Flow 2: Refund / Void

🔴 เปลี่ยน guard condition และ down-tier decision

sequenceDiagram autonumber participant ExtSys as External Sales System participant Kafka as Kafka Broker participant STCtrl as SalesTransaction Controller<br/>(Kafka Consumer) participant RefSvc as RefundSalesTransactionService participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) participant KOut as Kafka (Outbound) alt Refund trigger ExtSys->>Kafka: Publish SalesTransactionRefundedDomainEvent Kafka->>STCtrl: handleSalesTransactionRefunded() STCtrl->>STCtrl: Validate status == EARNED and latest refund type<br/>in FULL_REFUND | PARTIAL_REFUND | ADJUSTMENT STCtrl->>RefSvc: createRefundSalesTransaction(..., type = refundTransaction.type) else Void trigger ExtSys->>Kafka: Publish SalesTransactionVoidedDomainEvent Kafka->>STCtrl: handleSalesTransactionVoided() STCtrl->>STCtrl: Validate status == VOIDED or latest refund type == VOID STCtrl->>RefSvc: createRefundSalesTransaction(..., type = VOID) end RefSvc->>MRepo: find sales transaction by branchCode + salesTransactionExternalId MRepo->>DB: SELECT SalesTransaction from recent/all sales tables DB-->>MRepo: original SalesTransaction MRepo-->>RefSvc: SalesTransactionEntity RefSvc->>RefSvc: Check duplicate refund by refundTransactionExternalId RefSvc->>MRepo: findById(memberId) + load recent txns MRepo->>DB: SELECT Member + transactions DB-->>MRepo: member data MRepo-->>RefSvc: MemberEntity RefSvc->>TRepo: findAllActive() TRepo-->>RefSvc: TierEntity[] RefSvc->>RefSvc: Create RefundSalesTransactionEntity<br/>(revokeAccumSpendableAmount = negative amount) RefSvc->>Member: addNewRefundSalesTransaction(refundEntity, tiers) Note over Member: 🟢 Push refund to _newRefundSalesTransaction Note over Member: 🟢 calculateAccumulateSpending() Note right of Member: Re-sum 24-month window<br/>(now includes negative refund/void) Note over Member: 🟢 calculateAccumulateMaintainSpending() Note right of Member: Re-sum tier period<br/>(now includes negative refund/void) Note over Member: 🟢 lifeTimeSpending += revokeAccumSpendableAmount (negative) alt originalSale.completedAt > tierStartedAt Note over Member: 🔴 จบเลย ไม่ check downgrade (กฎ 2) else originalSale.completedAt <= tierStartedAt Note over Member: 🔵 NEW: result = calculateTierAdjustmentWithNewLogic(tiers) Note right of Member: 🔵 ใช้ calculateAccumTierLogicSpending()<br/>แทน calculateTierAdjustment() (rolling 24mo)<br/>+ capToCurrentTier() ป้องกัน upgrade alt newTier.id === currentTierId (NO DOWNGRADE) Note over Member: 🟢 คง tier เดิม Note over Member: 🔵 NEW: recalculate maintain จาก qualifying txn ใหม่ Note right of Member: findQualifyingTransactionDate(currentTier.minimumSpending)<br/>→ maintain = SUM txns หลัง qualifying txn else newTier.id !== currentTierId (DOWNGRADE) Note over Member: 🔴 NEW: หา tier dates (กฎ 5) Note over Member: findPreviousTierInfo() → มี history valid? Note over Member: ถ้าไม่มี → findQualifyingTransactionDate()<br/>(scan 24mo ก่อน up-tier + หลัง up-tier จนถึงปัจจุบัน, หักลบ refund) Note over Member: ถ้าไม่มี → tierStartedAt = now Note over Member: 🔴 NEW: calculateAccumulateMaintainSpending(newTierStartedAt, newTierEndedAt) Note over Member: maintain คำนวณจาก txns ในช่วง newTierStartedAt ถึง newTierEndedAt Note over Member: 🟢 updateMemberTier(newTier, newStart, newEnd) Note over Member: 🟢 Create MemberTierHistory Note over Member: 🟢 emit MemberTierUpdatedDomainEvent Note over Member: 🟢 emit MemberTierDowngradedDueToRefundDomainEvent end end Member-->>RefSvc: return RefSvc->>MRepo: save(member) MRepo->>DB: BEGIN TRANSACTION MRepo->>DB: INSERT RefundSalesTransaction MRepo->>DB: UPDATE Member (spending fields, tierId if changed) MRepo->>DB: INSERT MemberTierHistory (if downgraded) MRepo->>DB: COMMIT MRepo-->>KOut: publish domain events to Kafka

Solution A: Flow เดียวครอบทั้ง no-down-tier และ down-tier

  • Guard ใหม่: ถ้า original sale เกิดหลัง up-tier date → ไม่ down-tier (กฎ 2)
  • Tier decision ใหม่: ใช้ calculateTierAdjustmentWithNewLogic() (Accum Tier Logic) แทน calculateTierAdjustment() (rolling 24mo)
  • Tier dates ใหม่: หาจาก history หรือ qualifying transaction scan (กฎ 5)
  • Maintain ใหม่: ไม่ reset เป็น 0 แต่คำนวณจาก transactions หลัง up-tier (กฎ 4)
  • ไม่มี schema change: คำนวณสดจาก transactions ใน memory ทุกครั้ง

ตัวอย่าง: Refund → Down-tier (ตัวเลขจริง)

Timeline ของคุณ A:
  ต.ค. 2023  ซื้อ 10,000  ─┐
  พ.ย. 2023  ซื้อ 10,00024 เดือนก่อน up-tier
  ธ.ค. 2023  ซื้อ 40,000   │
  ม.ค. 2024  ซื้อ 10,000  ─┼── up-tier date (01 Jan 2024) → ขึ้น SCARLET
  ก.พ. 2024  ซื้อ  2,000  ─┐
  มี.ค. 2024  ซื้อ  2,000   │  transactions หลัง up-tier
  เม.ย. 2024  ซื้อ  2,000   │
  พ.ค. 2024  ซื้อ  1,000   │
  มิ.ย. 2024  ซื้อ  1,000   │
  ก.ค. 2024  ซื้อ  2,000  ─┘  ← ปัจจุบัน

Accum Tier Logic = (10K+10K+40K+10K) + (2K+2K+2K+1K+1K+2K)
                 = 70,000 + 10,000 = 80,000

เกิด Refund: คืนสินค้าที่ซื้อ ธ.ค. 2023 (ก่อน up-tier) มูลค่า 40,000 บาท

ขั้นตอนที่ 1: calculateAccumTierLogicSpending()
  = (70,000 - 40,000) + 10,000 = 40,000

ขั้นตอนที่ 2: หา tier ใหม่จาก 40,000
  CROWN   = 300,000 → ❌
  SCARLET =  50,000 → ❌
  NAVY    =       0 → ✅ → Down เป็น NAVY

ขั้นตอนที่ 3: หา tier dates
  NAVY ไม่มี history → findQualifyingTransactionDate() (scan หักลบ refund, window ถึงปัจจุบัน) → ไม่มี
  → tierStartedAt = now

ขั้นตอนที่ 4: calculatePostUpTierMaintainSpending()
  = 2K+2K+2K+1K+1K+2K = 10,000

ผลลัพธ์: Down เป็น NAVY, tierStartedAt = now, maintainSpending = 10,000

Flow 6: Manual Adjustment (Admin Backoffice)

Delegate ไป Flow 1 (ADD) / Flow 2 (DEDUCT) — ใช้ new logic อัตโนมัติ

sequenceDiagram autonumber participant Admin as Admin User (Backoffice) participant API as Admin REST API participant AdjSvc as AdjustSalesTransactionService participant MRepo as MemberRepository participant STSvc as SalesTransactionService participant RefSvc as RefundSalesTransactionService participant KOut as Kafka (Outbound) Admin->>API: POST /admin/members/:id/adjust-transaction<br/>{type: ADD|DEDUCT, amount, memoId, reason} API->>AdjSvc: createWithTransaction(memberId, request, requester) AdjSvc->>MRepo: findMemberWithProfileAndTierById(memberId) MRepo-->>AdjSvc: MemberEntity AdjSvc->>AdjSvc: Create AdjustmentSalesTransactionEntity<br/>{amount, type: ADD|DEDUCT, memoId, createdBy} alt type === ADD AdjSvc->>AdjSvc: Validate duplicate externalId + valid sales status AdjSvc->>STSvc: createSalesTransaction(..., totalAccumSpendableAmount = amount) Note over STSvc: 🟢 Delegate to Flow 1<br/>addNewSalesTransaction() runs full accum update<br/>and may upgrade tier → ไม่เปลี่ยน STSvc-->>AdjSvc: salesTransactionId else type === DEDUCT AdjSvc->>AdjSvc: Validate remaining deduct balance AdjSvc->>RefSvc: createRefundSalesTransaction(..., type = FULL_REFUND or PARTIAL_REFUND) Note over RefSvc: 🔴 Delegate to Flow 2<br/>addNewRefundSalesTransaction() runs full accum update<br/>and may downgrade tier → ใช้ new logic อัตโนมัติ RefSvc-->>AdjSvc: refundSalesTransactionId end AdjSvc->>KOut: emit MemberUpdatedLogDomainEvent<br/>(adjustSalesTransaction payload) KOut-->>Admin: Downstream audit trail updated

Solution A: ไม่แก้ Flow 6 โดยตรง — ใช้ new logic อัตโนมัติเพราะ delegate ไป Flow 1/2