As-Is: Accum & Tier Flow - Sequence Diagrams

ข้อมูลพื้นฐาน

3 ประเภทของ Accumulation

ประเภท ความหมาย ช่วงเวลา ใช้ทำอะไร
accumulateSpending ยอดสะสมแบบ rolling window 24 เดือนย้อนหลัง (config) ใช้ตัดสินการ upgrade tier
accumulateMaintainSpending ยอดสะสมในรอบ tier ปัจจุบัน tierStartedAt → tierEndedAt ใช้ตัดสินการ maintain/downgrade tier
lifeTimeSpending ยอดสะสมตลอดชีพ ตั้งแต่สมัครสมาชิก Analytics เท่านั้น

Tier Thresholds (Seed Data)

Tier minimumSpending earnRate Type
NAVY 0–49,999 1x NORMAL
SCARLET 50,000–299,999 1x NORMAL
CROWN 300,000–1,999,999 2x NORMAL
VEGA 2,000,000+ 3x NORMAL
VVIP / CRYSTAL INVITATION

Flow 1: Sales Transaction → Accum Update + Tier Upgrade (Real-time)

Trigger: Kafka event จาก External Sales System เมื่อมี transaction ใหม่

Key files:

  • src/modules/loyalty/domains/entities/member.entity.tsaddNewSalesTransaction() (L885)
  • src/modules/loyalty/services/sales-transaction.service.ts
  • src/modules/loyalty/controllers/consumers/message/sales-transaction.controller.ts
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 Member->>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 Member->>Member: calculateAccumulateMaintainSpending() Note right of Member: sum(totalAccumSpendableAmount)<br/>for txns in [tierStartedAt, tierEndedAt]<br/>minus refunds in same window Member->>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 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 EvBus->>Listener: handle MemberTierUpgradedDomainEvent Listener->>KOut: publish to Kafka topic KOut-->>ExtSys: Downstream services consume (Engagement, Notification)

Flow 2: Refund / Void Transaction → Accum Decrease + Tier Downgrade (Real-time)

Trigger: Kafka event จาก External Sales System เมื่อมี refund หรือ void

  • SalesTransactionRefundedDomainEvent → type = FULL_REFUND | PARTIAL_REFUND | ADJUSTMENT
  • SalesTransactionVoidedDomainEvent → type = VOID

Key files:

  • src/modules/loyalty/domains/entities/member.entity.tsaddNewRefundSalesTransaction() (L961)
  • src/modules/loyalty/services/refund-sales-transaction.service.ts
  • src/modules/loyalty/controllers/consumers/message/sales-transaction.controller.tshandleSalesTransactionVoided() (L64), handleSalesTransactionRefunded() (L107)
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 Member->>Member: calculateAccumulateSpending() Note right of Member: Re-sum 24-month window<br/>(now includes negative refund/void) Member->>Member: calculateAccumulateMaintainSpending() Note right of Member: Re-sum tier period<br/>(now includes negative refund/void) Member->>Member: lifeTimeSpending += revokeAccumSpendableAmount (negative) Member->>Member: Check: is original salesTxn in current tier period? Note right of Member: isAfter(tierStartedAt, salesTxn.completedAt)? alt Original transaction IS in current tier period Member->>Member: calculateTierAdjustment(tiers) Note right of Member: Filter NORMAL tiers,<br/>sort by minimumSpending DESC,<br/>find first where accumulateSpending >= minimumSpending,<br/>apply minimumTier floor alt newTier.id !== currentTierId (DOWNGRADE) Member->>Member: updateMemberTier(lowerTier) Member->>Member: Create MemberTierHistory Member->>Member: emit MemberTierUpdatedDomainEvent Member->>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

Flow 3: Batch Accum Update / Recalculate Spending & Tier (Temporal Workflow)

Trigger: Temporal Cron Schedule หรือ Admin API

  • 3A: update accumulateSpending แบบ batch จาก recent transaction changes
  • 3B: recalculate spending + tier ใหม่จาก DB aggregation

Key files:

  • src/modules/loyalty/controllers/workers/workflows/update-accumulate-spending.workflow.tsupdateAccumulateSpendingWorkflow() (L20), recalculateSpendingTierWorkflow() (L52)
  • src/modules/loyalty/controllers/workers/activities/update-accumulate-spending.activity.tsprocessUpdateAccumulateSpending() (L56), processRecalculateSpendingTier() (L61)
  • src/modules/loyalty/services/update-accumulate-spending.service.tsprocessUpdateAccumulateSpending() (L80)
  • src/modules/loyalty/drivers/repositories/member.repository.tscalculateMemberSpendingData() (L2506)
  • src/modules/loyalty/domains/entities/member.entity.tsupdateAccumulateSpending() (L1566), processSpendingAndTierUpdate() (L2457)
sequenceDiagram autonumber participant Sched as Temporal ScheduleClient<br/>(Cron / Admin API) participant WF as updateAccumulateSpending<br/>Workflow participant ChildWF as recalculateSpendingTier<br/>Workflow participant Act as UpdateAccumulateSpending<br/>Activity participant Svc as UpdateAccumulateSpending<br/>Service participant MRepo as MemberRepository participant Member as Member Entity participant TRepo as TierRepository participant DB as Database (Prisma) participant KOut as Kafka (Outbound) alt 3A: Batch update accumulate spending only Sched->>WF: start updateAccumulateSpendingWorkflow(no input) WF->>Act: findMembersAccumulateSpendingChangedTasks() Act->>Svc: findMembersAccumulateSpendingChangedTasks() Svc->>MRepo: query members with txn changes around accumulate window boundary MRepo->>DB: SELECT memberIds affected by rolling-window boundary DB-->>MRepo: member IDs / total count MRepo-->>Svc: task source data Svc->>WF: start child workflow tasks in chunks of 500 loop For each workflow chunk WF->>Act: findMembersAccumulateSpendingChanged(startDate, endDate, limit, offset) Act->>Svc: findMembersAccumulateSpendingChanged(...) Svc-->>Act: memberIds[] loop For each sub-chunk of 100 memberIds WF->>Act: processUpdateAccumulateSpending(memberIds[]) Act->>Svc: processUpdateAccumulateSpending(memberIds[]) loop For each memberId Svc->>MRepo: findMemberWithRecentSalesTransactionById(memberId) MRepo-->>Svc: MemberEntity Svc->>Member: updateAccumulateSpending() Note over Member: Recalculate rolling-window accumulate only<br/>no direct tier recalculation in this path alt accumulateSpending changed Svc->>MRepo: updateAccumulateSpending(member) MRepo->>DB: UPDATE Member SET accumulateSpending = new value end end end end else 3B: Batch recalculate spending + tier from DB aggregation Sched->>ChildWF: start recalculateSpendingTierWorkflow(gwlNos) ChildWF->>ChildWF: Split into sub-chunks of 100 loop For each sub-chunk of 100 ChildWF->>Act: processRecalculateSpendingTier(gwlNos[]) loop For each GWL number Act->>MRepo: findByGwlNo(gwlNo) MRepo->>DB: SELECT Member DB-->>MRepo: member MRepo-->>Act: MemberEntity Act->>MRepo: calculateMemberSpendingData(member) Note over MRepo: Execute 8 parallel Prisma aggregations MRepo->>DB: 1. SUM(totalAccumSpendableAmount) - All time MRepo->>DB: 2. SUM(totalAccumSpendableAmount) - 24-month window MRepo->>DB: 3. SUM(revokeAccumSpendableAmount) - All time MRepo->>DB: 4. SUM(revokeAccumSpendableAmount) - 24-month window MRepo->>DB: 5. SUM(totalAccumSpendableAmount) - Tier period MRepo->>DB: 6. SUM(revokeAccumSpendableAmount) - Tier period MRepo->>DB: 7. MAX(completedAt) - Last sale date MRepo->>DB: 8. MAX(refundedAt) - Last refund date DB-->>MRepo: 8 aggregated values MRepo-->>Act: spending data object Act->>TRepo: findAllActive() TRepo-->>Act: TierEntity[] Act->>Member: processSpendingAndTierUpdate(spendingData, tiers) Note over Member: finalLifeTime = max(0, lifeTimeSales + lifeTimeRefunds) Note over Member: finalAccum = max(0, accumSales + accumRefunds) Note over Member: finalMaintain = max(0, maintainSales + maintainRefunds) Member->>Member: Find highest NORMAL tier where finalAccum >= minimumSpending Member->>Member: Apply minimumTier floor (co-brand) Member->>Member: Prevent downgrade if no refunds (accumRefund === 0) alt Tier changed Member->>Member: Create MemberTierHistory Member->>Member: Set tierStartedAt = max(lastSaleDate, lastRefundDate) Member->>Member: tierEndedAt = tierStartedAt + yearsToMaintain end Member-->>Act: return spending + tier result Act->>MRepo: updateMemberSpendingAndTierByGWLNo(gwlNo, result) MRepo->>DB: UPDATE Member SET accumulateSpending, accumulateMaintainSpending, lifeTimeSpending, tierId, tierStartedAt, tierEndedAt MRepo->>DB: INSERT MemberTierHistory (if tier changed) Act->>Act: Create MemberLogEntity (audit) Act->>KOut: emit MemberUpdatedDomainEvent end Act-->>ChildWF: {success[], failed[]} end ChildWF-->>Sched: aggregated results end

Flow 4: Annual Maintain Tier Cycle (Tier Review)

Trigger: Temporal Schedule — ตรวจสอบสมาชิกที่ tierEndedAt หมดอายุ เพื่อ maintain หรือ downgrade

Key files:

  • src/modules/loyalty/controllers/workers/workflows/maintain-tier.workflow.ts
  • src/modules/loyalty/services/maintain-tier.service.ts
  • src/modules/loyalty/domains/entities/member.entity.tsmaintainTier() (L2242), calculateNewTierFromAccumulateMaintainSpending()
sequenceDiagram autonumber participant Sched as Temporal ScheduleClient<br/>(Annual / Quarterly Cron) participant MaintainWF as maintainTierWorkflow participant RangeWF as maintainTierProcess<br/>InRange (Child WF) participant Act as MaintainTier Activity participant Svc as MaintainTierService participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) participant ReconWF as reconcileMemberTier<br/>Workflow participant KOut as Kafka (Outbound) Sched->>MaintainWF: trigger (members where tierEndedAt < now) MaintainWF->>Act: countCycleEndedMembers(date) Act->>Svc: countCycleEndedMembers(date) Svc->>MRepo: count WHERE tierEndedAt < date MRepo->>DB: SELECT COUNT(*) FROM Member WHERE tierEndedAt < date DB-->>MRepo: total count MRepo-->>Svc: count Svc-->>Act: count Act-->>MaintainWF: total count MaintainWF->>MaintainWF: Split into chunks of 10,000 loop For each chunk of 10,000 MaintainWF->>RangeWF: spawn child workflow(offset, limit) RangeWF->>Act: findCycleEndedMembers(offset, limit) Act->>MRepo: paginated query MRepo->>DB: SELECT id FROM Member WHERE tierEndedAt < date LIMIT/OFFSET DB-->>MRepo: member IDs MRepo-->>Act: string[] Act-->>RangeWF: member IDs RangeWF->>RangeWF: Split into sub-chunks of 100 loop For each sub-chunk of 100 RangeWF->>Svc: processCycleEndedMembers(memberIds) loop For each memberId Svc->>MRepo: findById(memberId) + load co-brand cards MRepo->>DB: SELECT Member with relations DB-->>MRepo: member data MRepo-->>Svc: MemberEntity Svc->>TRepo: findAllActive() TRepo-->>Svc: TierEntity[] Svc->>Member: maintainTier(tiers) Note over Member: Step 1: Reset minimumTierInvitedId = null Member->>Member: calculateNewMinimumTier(tiers) Note right of Member: Check active co-brand cards,<br/>find highest minimum tier from cards,<br/>compare with minimumTierInvited,<br/>set minimumTierId = highest Member->>Member: calculateNewTierFromAccumulateMaintainSpending(tiers) Note right of Member: If current tier is INVITATION -> keep it<br/>If accumulateMaintainSpending >= currentTier.maintainSpending -> keep it<br/>Else: find highest NORMAL tier where<br/>accumulateMaintainSpending >= minimumSpending<br/>or tier.id === minimumTierId Member->>Member: updateMemberTier(newTier, Jan 1, Dec 31 +1yr) Note over Member: New cycle starts: tierStartedAt = Jan 1,<br/>tierEndedAt = Dec 31 of next year alt Tier maintained (same tier) Member->>KOut: MemberTierMaintainedAfterReviewedDomainEvent else Tier downgraded (lower tier) Member->>Member: Create MemberTierHistory (from -> to) Member->>KOut: MemberTierDowngradedAfterReviewedDomainEvent end Svc->>MRepo: save(member) MRepo->>DB: UPDATE Member + INSERT MemberTierHistory end end RangeWF-->>MaintainWF: {success, failed} end MaintainWF->>ReconWF: trigger reconcileMemberTierWorkflow() Note over ReconWF: Starts Flow 5: Reconcile Member Tier

Flow 5: Reconcile Member Tier (Post-Maintain)

Trigger: ทำงานหลัง maintainTierWorkflow เสร็จ — ตรวจสอบ tier ที่อาจผิดพลาดและ upgrade ให้ถูก

Key files:

  • src/modules/loyalty/controllers/workers/workflows/reconcile-member-tier.workflow.ts
  • src/modules/loyalty/services/reconcile-member-tier.service.ts
  • src/modules/loyalty/domains/entities/member.entity.tsadjustTier() (L2279), calculateTierAdjustment()
sequenceDiagram autonumber participant MaintainWF as maintainTierWorkflow<br/>(triggers after completion) participant ReconWF as reconcileMemberTier<br/>Workflow participant RangeWF as reconcileProcessInRange<br/>(Child WF) participant Act as ReconcileMemberTier Activity participant Svc as ReconcileMemberTierService participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) MaintainWF->>ReconWF: trigger after maintain-tier completes ReconWF->>Act: countMembersForReconciliation() Act->>Svc: countMembersForReconciliation() Svc->>MRepo: count members needing tier adjustment MRepo-->>Svc: total count Svc-->>Act: count Act-->>ReconWF: total count ReconWF->>ReconWF: Split into chunks of 10,000 loop For each chunk of 10,000 ReconWF->>RangeWF: spawn child workflow(offset, limit) RangeWF->>Act: findMembersForReconciliation(offset, limit) Act-->>RangeWF: member IDs RangeWF->>RangeWF: Split into sub-chunks of 100 loop For each sub-chunk of 100 RangeWF->>Svc: processReconciliationForMembers(ids) loop For each memberId Svc->>MRepo: findById(id) + load txns + co-brand cards MRepo->>DB: SELECT Member with relations DB-->>MRepo: member data MRepo-->>Svc: MemberEntity Svc->>TRepo: findAllActive() TRepo-->>Svc: TierEntity[] Svc->>Member: adjustTier(tiers) Note over Member: Step 1: Skip if tierId === minimumTierId<br/>(co-brand floor, cannot adjust) Member->>Member: calculateTierAdjustment(tiers) Note right of Member: Filter NORMAL tiers,<br/>sort by minimumSpending DESC,<br/>find first where accumulateSpending >= minimumSpending,<br/>apply minimumTier floor alt newTier is INVITATION type Member-->>Svc: return false (skip) else newTier.minimumSpending > currentTier.minimumSpending Member->>Member: calculateNewTierStartDateFromSalesTransactionPeriod() Note right of Member: Walk through txns in cycle period,<br/>find the txn that crossed the threshold,<br/>use its completedAt as new tierStartedAt alt Valid new start date found AND different from current Member->>Member: updateMemberTier(newTier, newStart, newEnd) Member->>Member: Create MemberTierHistory alt newTier.id !== currentTier.id Member->>Member: emit MemberTierUpgradedDomainEvent end Member-->>Svc: return true else No valid date or same date Member-->>Svc: return false (no change) end else newTier <= currentTier Member-->>Svc: return false (no downgrade in reconcile) end opt adjustTier returned true Svc->>MRepo: save(member) MRepo->>DB: UPDATE Member + INSERT MemberTierHistory end end end RangeWF-->>ReconWF: {success, failed} end

Flow 6: Manual Adjustment (Admin Backoffice)

Trigger: Admin ปรับ transaction ด้วยมือผ่าน REST API — trigger tier change ได้

Key files:

  • src/modules/loyalty/services/adjust-sales-transaction.service.tscreateWithTransaction() (L154)
  • src/modules/loyalty/services/sales-transaction.service.ts
  • src/modules/loyalty/services/refund-sales-transaction.service.ts
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 RefSvc-->>AdjSvc: refundSalesTransactionId end AdjSvc->>KOut: emit MemberUpdatedLogDomainEvent<br/>(adjustSalesTransaction payload) KOut-->>Admin: Downstream audit trail updated

Flow 7: Co-Brand Card → Tier Upgrade / Downgrade

Trigger: Import file จากธนาคาร (NEW/REPLACE/CANCEL), Admin cancel/inactivate co-brand card, หรือ Kafka CoBrandInactivatedDomainEvent

Key files:

  • src/modules/loyalty/domains/entities/member.entity.tsupdateMemberLinkCoBrand() (L1710), coBrandCardCanceled() (L2069), coBrandCardInactivated() (L2095), coBrandCardUpdated() (L2122), calculateNewMinimumTier() (L2429), calculateNewTier() (L1868)
  • src/modules/loyalty/services/member-co-brand-card-import.service.ts
  • src/modules/loyalty/services/member-co-brand-card.service.ts
  • src/modules/loyalty/controllers/consumers/message/co-brand-consumer.controller.tshandleCoBrandInactivatedEvent() (L27)
sequenceDiagram autonumber participant Admin as Admin / Bank File Import participant Kafka as Kafka Broker participant CBCtrl as CoBrand Consumer<br/>(Kafka Consumer) participant ImportSvc as MemberCoBrandCard<br/>ImportService participant CoBrandSvc as MemberCoBrandCard<br/>Service participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) participant KOut as Kafka (Outbound) Note over Admin,KOut: 7A: Co-Brand Card NEW → Tier Upgrade (via Import File) Admin->>ImportSvc: upload co-brand card file (CSV) ImportSvc->>ImportSvc: parse & validate rows ImportSvc->>TRepo: findAllActiveTier() TRepo-->>ImportSvc: TierEntity[] ImportSvc->>ImportSvc: Determine minimumTier from coBrand.minimumTierCode ImportSvc->>ImportSvc: Compare: minimumTier.minimumSpending > currentTier.minimumSpending? alt Member exists (link co-brand) ImportSvc->>Member: updateMemberLinkCoBrand(data, defaultTier, coBrand, tiers, minimumTier) Member->>Member: Set minimumTierId = minimumTier.id (if higher) alt IssueType = NEW & minimumTier > currentTier Member->>Member: updateMemberTier(minimumTier, now, now + yearsToMaintain) Member->>KOut: MemberTierUpgradedCoBrandDomainEvent end else Member not found (create new co-brand member) ImportSvc->>Member: createMemberLinkCoBrand(data, defaultTier, coBrand, minimumTier) Note over Member: accumulateSpending = 0<br/>lifeTimeSpending = 0<br/>accumulateMaintainSpending = 0<br/>tierId = minimumTier or defaultTier Member->>KOut: MemberTierAssignedDomainEvent end ImportSvc->>MRepo: save(member) MRepo->>DB: INSERT/UPDATE Member + INSERT MemberCoBrandCard Note over Admin,KOut: 7B: Admin cancel / inactivate single co-brand card Admin->>CoBrandSvc: updateCoBrandCardCancel(cardId, data) or updateCoBrandCardInactivate(cardId) CoBrandSvc->>MRepo: findMemberWithProfileAndTierById(memberId) MRepo-->>CoBrandSvc: MemberEntity (with co-brand cards) CoBrandSvc->>TRepo: findAllActiveTier() TRepo-->>CoBrandSvc: TierEntity[] CoBrandSvc->>Member: coBrandCardCanceled(card, data, tiers) or coBrandCardInactivated(card, tiers) Member->>Member: coBrandCardUpdated(cardId, {CANCELLED/INACTIVE}, tiers) Member->>Member: card.inactivate() Member->>Member: Filter remaining ACTIVE cards alt No more active cards from this coBrand OR card's minimumTierCode matches current minimumTier Member->>Member: calculateNewMinimumTier(tiers, activeCards) Note right of Member: Re-evaluate minimumTierId<br/>from remaining active co-brand cards alt Current tier is NOT INVITATION type Member->>Member: calculateNewTier(tiers) Note right of Member: calculateAccumulateSpending()<br/>→ find highest NORMAL tier<br/>where accum >= minimumSpending<br/>or tier.id === minimumTierId alt newTier.id !== currentTierId (DOWNGRADE) Member->>Member: updateMemberTier(newTier, now, newEndDate) alt Card was CANCELLED Member->>KOut: MemberTierDowngradedCoBrandCanceledDomainEvent else Card was INACTIVE Member->>KOut: MemberTierDowngradedCoBrandInactiveDomainEvent end end end end CoBrandSvc->>MRepo: transaction: update cards + update member MRepo->>DB: UPDATE MemberCoBrandCard + UPDATE Member Note over Kafka,KOut: 7C: Co-Brand Inactivated via Kafka Event Kafka->>CBCtrl: Publish CoBrandInactivatedDomainEvent CBCtrl->>CoBrandSvc: inactivateAllCardsByCoBrandId(coBrandId) CoBrandSvc->>TRepo: findAllActiveTier() TRepo-->>CoBrandSvc: TierEntity[] loop For each page of members by coBrandId CoBrandSvc->>MRepo: findAllByCoBrandId(id, page, limit) MRepo-->>CoBrandSvc: MemberEntity[] loop For each member linked to that co-brand CoBrandSvc->>Member: coBrandCardInactivated(card, tiers) Note over Member: Inactivate matching card(s) for this co-brand<br/>then reuse coBrandCardUpdated() logic alt Tier changed after minimum tier recalculation Member->>Member: calculateNewTier(tiers) Member->>Member: updateMemberTier(newTier, now, newEndDate) Member->>KOut: MemberTierDowngradedCoBrandInactiveDomainEvent end CoBrandSvc->>MRepo: update member + changed co-brand cards MRepo->>DB: UPDATE MemberCoBrandCard + UPDATE Member end end

Flow 8: Staff Exit Import → Tier Reassignment

Trigger: Admin upload CSV ของพนักงานที่ลาออก — downgrade จาก CRYSTAL (staff tier) เป็น NORMAL tier ตามยอด spending

Key files:

  • src/modules/loyalty/domains/entities/member.entity.tsprocessStaffExit() (L2011), calculateNewTier() (L1868)
  • src/modules/loyalty/services/import-staff-exit.service.tsupdateStaffExit() (L668)
  • src/modules/loyalty/controllers/workers/workflows/import-staff-exit.workflow.ts
--- id: 38f255bc-177e-4f06-a52b-2be1b8fe4618 --- sequenceDiagram autonumber participant Admin as Admin User participant API as Admin REST API participant WF as importStaffExit<br/>Workflow participant Act as ImportStaffExit<br/>Activity participant ImportSvc as ImportStaffExitService participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) participant KOut as Kafka (Outbound) Admin->>API: Upload staff exit CSV file API->>ImportSvc: importStaffExit(file) ImportSvc->>ImportSvc: Create ImportHistory record ImportSvc->>WF: start importStaffExitWorkflow(importHistoryId) WF->>Act: processImportFile(importHistoryId) Act->>ImportSvc: processImportFile(id) ImportSvc->>ImportSvc: readDataFromUploadFile() → parse CSV ImportSvc->>ImportSvc: mapStaffExitRawData() → validate fields ImportSvc->>ImportSvc: transformStaffExitData() → StaffExitTableData[] ImportSvc->>MRepo: getMemberListByIdentifier(identifications) MRepo->>DB: SELECT Members by CID/Passport/StaffId DB-->>MRepo: MemberEntity[] MRepo-->>ImportSvc: found members ImportSvc->>TRepo: findAllActiveTier() TRepo-->>ImportSvc: TierEntity[] loop For each staff exit row ImportSvc->>ImportSvc: findTargetMember(staffData, foundMembers) ImportSvc->>Member: processStaffExit(staffData, activeTiers) Note over Member: Validate: current tier must be CRYSTAL (staff tier) Member->>Member: Cross-check firstName, lastName, staffId Member->>Member: Update phone/email if not verified Member->>Member: Remove staffProfile (staffProfile = null) Member->>Member: calculateNewTier(activeTiers) Note right of Member: calculateAccumulateSpending()<br/>→ find highest NORMAL tier<br/>where accum >= minimumSpending<br/>or tier.id === minimumTierId<br/>(Staff CRYSTAL → NORMAL tier based on spending) Member->>Member: updateMemberTier(newTier, now, now + yearsToMaintain) Member->>KOut: MemberTierAssignedDueToLeftTheCompanyDomainEvent end ImportSvc->>MRepo: updateImportStaffExit(memberList) MRepo->>DB: BEGIN TRANSACTION MRepo->>DB: UPDATE Members (tierId, tierStartedAt, tierEndedAt, staffProfile, phone, email) MRepo->>DB: INSERT MemberTierHistory for each changed member MRepo->>DB: COMMIT ImportSvc->>ImportSvc: Update ImportHistory → COMPLETED

Flow 9: Member Import → Tier Assignment

Trigger: Admin upload CSV → POST /admin/members/import

Key files:

  • src/modules/loyalty/controllers/apis/http/admin-member.controller.tsuploadFileMember() (L165)
  • src/modules/loyalty/controllers/workers/workflows/import-member.workflow.tsimportMemberWorkflow()
  • src/modules/loyalty/controllers/workers/activities/import-member.activity.tsprocessMemberImportFile()
  • src/modules/loyalty/services/import-member.service.tsprocessImportFile() (L583), upsertMembers() (L669)
  • src/modules/loyalty/domains/entities/member.entity.tsimportUpdate() (L652), create() (L335)
sequenceDiagram autonumber participant Admin as Admin User participant API as Admin REST API participant ImportSvc as ImportMemberService participant WFClient as ImportMemberClient participant WF as importMemberWorkflow participant Act as ImportMemberActivity participant MRepo as MemberRepository participant TRepo as TierRepository participant Member as Member Entity participant DB as Database (Prisma) participant KOut as Kafka (Outbound) Admin->>API: POST /admin/members/import (CSV) API->>ImportSvc: uploadImportFile(file, requester) ImportSvc->>ImportSvc: Validate header + upload file ImportSvc->>DB: INSERT ImportHistory (VALIDATING) ImportSvc->>WFClient: start importMemberWorkflow(importHistoryId) WFClient->>WF: importMemberWorkflow(importHistoryId) WF->>Act: validateMemberImport(importHistoryId) WF->>Act: processMemberImportFile(importHistoryId) Act->>ImportSvc: processImportFile(id) ImportSvc->>ImportSvc: readDataFromUploadFile() → parse CSV ImportSvc->>ImportSvc: mapMemberRawData() → validate fields ImportSvc->>ImportSvc: transformMemberData() → MemberTableData[] ImportSvc->>MRepo: getMemberListByIdentifier(memberIdentification) MRepo->>DB: SELECT Members by CID/Passport/Email/Phone DB-->>MRepo: MemberEntity[] MRepo-->>ImportSvc: found members ImportSvc->>TRepo: findAllActiveTier() TRepo-->>ImportSvc: TierEntity[] loop For each CSV row ImportSvc->>ImportSvc: Resolve invitedTier from tierCode alt Existing member (update) ImportSvc->>ImportSvc: Validate no downgrade<br/>INVITATION→NORMAL, invitation tier swap,<br/>or higher NORMAL→lower NORMAL are blocked ImportSvc->>Member: calculateNewMinimumTier(tiers) ImportSvc->>Member: importUpdate(row, {tier: invitedTier, yearsToMaintain: 2}, requester) Member->>Member: If tier.type !== INVITATION<br/>set minimumTierId + minimumTierInvitedId Member->>Member: updateMemberTier(invitedTier, now, calculateNewTierEndedAt(now, 2)) Member->>Member: Create MemberTierHistory Member->>Member: emit MemberTierUpdatedDomainEvent Member->>Member: emit MemberTierAssignedDomainEvent else New member (create) ImportSvc->>Member: MemberEntity.create(row, {tier: invitedTier, yearsToMaintain: 2}, requester, true) Note over Member: initial accumulateSpending = 0<br/>lifeTimeSpending = 0<br/>accumulateMaintainSpending = 0 Member->>Member: set initial tierId / tierStartedAt / tierEndedAt Member->>Member: emit MemberTierUpdatedDomainEvent Member->>Member: emit MemberTierAssignedDomainEvent end end ImportSvc->>MRepo: bulk create/update members + tier histories MRepo->>DB: INSERT/UPDATE Member + INSERT MemberTierHistory ImportSvc->>DB: UPDATE ImportHistory → COMPLETED / ERROR Note over ImportSvc,Member: Bug note: yearsToMaintain is hardcoded to 2<br/>in import-member.service.ts and MemberEntity.calculateNewTierEndedAt()

สรุป: ทั้ง 9 Flow ใช้ logic หลักจาก Member Entity

Flow Trigger Accum Methods ที่ใช้ Tier Methods ที่ใช้
1. Sales Txn Kafka event calculateAccumulateSpending(), calculateAccumulateMaintainSpending() calculateTierToUpgrade()
2. Refund / Void Txn Kafka event calculateAccumulateSpending(), calculateAccumulateMaintainSpending() calculateTierAdjustment()
3. Batch Accum Update / Recalc Temporal cron / Admin updateAccumulateSpending() หรือ processSpendingAndTierUpdate() + DB aggregation processSpendingAndTierUpdate() (3B only)
4. Maintain Tier Temporal cron (annual) accumulateMaintainSpending (pre-calculated) calculateNewTierFromAccumulateMaintainSpending(), maintainTier()
5. Reconcile After maintain-tier accumulateSpending (pre-calculated) calculateTierAdjustment(), adjustTier()
6. Manual Adjust Admin API ผ่าน createSalesTransaction() / createRefundSalesTransaction() trigger tier change ผ่าน Flow 1 (ADD) / Flow 2 (DEDUCT)
7. Co-Brand Card Bank file import / Admin / Kafka event calculateAccumulateSpending() (via calculateNewTier()) calculateNewTier(), calculateNewMinimumTier(), updateMemberLinkCoBrand(), coBrandCardUpdated()
8. Staff Exit Admin CSV import calculateAccumulateSpending() (via calculateNewTier()) calculateNewTier(), processStaffExit()
9. Member Import Admin CSV import ไม่ recalculated accum (new member = 0) calculateNewMinimumTier(), importUpdate(), updateMemberTier(), create()

Core Shared Functions

จริง ๆ แล้วทั้ง 9 Flow funnel ผ่าน core functions ไม่กี่ชุด โดยมี 2 ข้อยกเว้นสำคัญ คือ Flow 3 batch recalc ใช้ processSpendingAndTierUpdate() โดยตรง และ Flow 9 new member import ใช้ create() สำหรับ initial tier assignment

Level 0 — Core Calculation (pure/shared helpers)
├── calculateAccumulate()
├── calculateAccumulateSpending()
├── calculateAccumulateMaintainSpending()
├── static calculateNewTierEndedAt()
└── static getMinimumTierFromCoBrandCards()

Level 1 — Tier Decision (select target tier)
├── calculateTierToUpgrade()                         ← Flow 1, Flow 6(ADD via Flow 1)
├── calculateTierAdjustment()                        ← Flow 2, Flow 5, Flow 6(DEDUCT via Flow 2)
├── calculateNewTier()                               ← Flow 7, Flow 8
├── calculateNewTierFromAccumulateMaintainSpending() ← Flow 4
├── calculateNewMinimumTier()                        ← Flow 4, Flow 7, Flow 9(existing member)
└── create()                                         ← Flow 9(new member initial tier assignment)

Level 2 — Core Mutation (write tier/spending state)
├── updateMemberTier()                               ← Flow 1, 2, 4, 5, 6, 7, 8, 9(existing member)
│   ├── recalc accumulateMaintainSpending
│   ├── create MemberTierHistory
│   └── emit MemberTierUpdatedDomainEvent
├── create()                                         ← Flow 9(new member)
│   ├── set initial tierId / tierStartedAt / tierEndedAt
│   ├── set initial accumulate fields = 0
│   └── emit MemberTierUpdatedDomainEvent + MemberTierAssignedDomainEvent
├── updateAccumulateSpending()                       ← Flow 3A service-level accumulate refresh
└── processSpendingAndTierUpdate()                   ← Flow 3B batch-specific mutation path
    ├── recompute spending from aggregated DB values
    ├── create MemberTierHistory directly
    └── does not call updateMemberTier()

Level 3Orchestration (entry point per flow)
├── addNewSalesTransaction()                         ← Flow 1, Flow 6(ADD)
├── addNewRefundSalesTransaction()                   ← Flow 2, Flow 6(DEDUCT)
├── maintainTier()                                   ← Flow 4
├── adjustTier()                                     ← Flow 5
├── updateAccumulateSpending()                       ← Flow 3A
├── processSpendingAndTierUpdate()                   ← Flow 3B
├── coBrandCardUpdated()                             ← Flow 7
├── processStaffExit()                               ← Flow 8
├── importUpdate()                                   ← Flow 9(existing member)
└── create()                                         ← Flow 9(new member)

Key insight:

  • ถ้าแก้ calculateAccumulate() หรือ calculateAccumulateSpending() จะกระทบ flow ที่ตัดสิน tier จาก transaction history โดยตรง
  • ถ้าแก้ updateMemberTier() จะกระทบเกือบทุก flow ที่มี tier change ยกเว้น Flow 3B ซึ่งใช้ processSpendingAndTierUpdate() คนละ path และ Flow 9 new-member path ที่ใช้ create()

Impact Assessment: ถ้าปรับ Logic Accum/Tier จะกระทบอะไรบ้าง

Critical Files ที่ต้องแก้

File สิ่งที่กระทบ
src/modules/loyalty/domains/entities/member.entity.ts create(), calculateAccumulate(), calculateAccumulateSpending(), calculateAccumulateMaintainSpending(), calculateTierToUpgrade(), calculateTierAdjustment(), maintainTier(), adjustTier(), processSpendingAndTierUpdate(), updateAccumulateSpending(), addNewSalesTransaction(), addNewRefundSalesTransaction(), updateMemberLinkCoBrand(), coBrandCardCanceled(), coBrandCardInactivated(), coBrandCardUpdated(), calculateNewMinimumTier(), calculateNewTier(), processStaffExit(), importUpdate()
src/modules/loyalty/drivers/repositories/member.repository.ts calculateMemberSpendingData() — DB aggregation queries (8 parallel queries)
src/modules/loyalty/services/sales-transaction.service.ts createSalesTransaction() — real-time accum + tier upgrade
src/modules/loyalty/services/refund-sales-transaction.service.ts createRefundSalesTransaction() — refund accum + tier downgrade
src/modules/loyalty/services/adjust-sales-transaction.service.ts Manual adjustment — accum ADD/DEDUCT
src/modules/loyalty/services/maintain-tier.service.ts Annual tier maintenance logic
src/modules/loyalty/services/reconcile-member-tier.service.ts Post-maintenance reconciliation
src/modules/loyalty/services/update-accumulate-spending.service.ts Batch recalculation
src/modules/loyalty/controllers/workers/activities/update-accumulate-spending.activity.ts processRecalculateSpendingTier()
src/modules/loyalty/services/member-co-brand-card.service.ts updateCoBrandCardCancel(), inactivateAllCardsByCoBrandId() — co-brand card cancel/inactivate → tier downgrade
src/modules/loyalty/services/member-co-brand-card-import.service.ts Co-brand card import → tier upgrade (NEW issue)
src/modules/loyalty/services/import-staff-exit.service.ts updateStaffExit() — staff exit → CRYSTAL downgrade
src/modules/loyalty/services/import-member.service.ts processImportFile() / upsertMembers() — member import สามารถ assign/change tier ได้
src/modules/loyalty/services/member.service.ts MemberEntity.create() — member creation path ตั้ง initial tier
src/configs/loyalty.config.ts accumulateThresholdMonth, yearsToMaintain

Core Entity Methods ที่ต้อง Review เพิ่ม

Method Line ทำไมสำคัญ
importUpdate() L652 ใช้ใน Flow 9 existing member import และเป็นจุดที่ assign tier/event ตอน import
create() L335 ใช้ใน Flow 9 new member import และ emit initial MemberTierUpdatedDomainEvent + MemberTierAssignedDomainEvent
static calculateNewTierEndedAt() L1095 Utility คำนวณ tierEndedAt; หลาย flow ใช้ และ default offsetYear = 2
updateMemberTier() L1100 Core mutation สำหรับ tier change ส่วนใหญ่: เปลี่ยน tierId/tierStartedAt/tierEndedAt, recalc accumulateMaintainSpending, สร้าง MemberTierHistory, emit MemberTierUpdatedDomainEvent
updateAccumulateSpending() L1566 ใช้ใน batch recalculate service path เพื่อ refresh accum field
static getMinimumTierFromCoBrandCards() L2190 Helper ของ calculateNewMinimumTier() สำหรับ co-brand floor logic
calculateNewTierFromAccumulateMaintainSpending() L2216 ใช้ใน Flow 4 maintain tier
calculateNewTierStartDateFromSalesTransactionPeriod() L2340 ใช้ใน Flow 5 reconcile หา exact threshold crossing date

Additional Services / Entry Points ที่ควร Include

Service / Entry Point Impact
src/modules/loyalty/controllers/consumers/message/sales-transaction.controller.ts รับทั้ง refunded และ void event แล้ว delegate ไป refund service
src/modules/loyalty/controllers/consumers/message/co-brand-consumer.controller.ts Kafka path สำหรับ CoBrandInactivatedDomainEvent
src/modules/loyalty/controllers/apis/http/admin-member.controller.ts Admin import route POST /admin/members/import
src/modules/loyalty/controllers/workers/workflows/import-member.workflow.ts Member import workflow orchestration

Domain Events ที่อาจ payload เปลี่ยน (11 events)

  • MemberTierUpgradedDomainEvent
  • MemberTierDowngradedDueToRefundDomainEvent
  • MemberTierMaintainedAfterReviewedDomainEvent
  • MemberTierDowngradedAfterReviewedDomainEvent
  • MemberTierUpdatedDomainEvent
  • MemberTierAssignedDomainEvent
  • MemberTierDowngradedCoBrandInactiveDomainEvent
  • MemberTierDowngradedCoBrandCanceledDomainEvent
  • MemberTierAssignedDueToLeftTheCompanyDomainEvent
  • MemberTierUpgradedCoBrandDomainEvent
  • MemberTierPrivilegeDomainEvent

Downstream ที่รับ Event ต่อ

  • Engagement Service
  • Notification Service

Test Files ที่ต้อง Update

  • test/modules/loyalty/domain/entities/member.entity.spec.ts
  • test/modules/loyalty/services/adjust-sales-transaction.service.spec.ts (ควรเพิ่ม coverage)
  • test/modules/loyalty/services/import-member.service.spec.ts
  • test/modules/loyalty/services/maintain-tier.service.spec.ts
  • test/modules/loyalty/services/reconcile-member-tier.service.spec.ts
  • test/modules/loyalty/services/update-accumulate-spending.service.spec.ts
  • test/modules/loyalty/services/tier.service.spec.ts

DB Schema ที่เกี่ยวข้อง (Prisma)

  • Member: accumulateSpending, accumulateMaintainSpending, lifeTimeSpending, tierId, tierStartedAt, tierEndedAt
  • Tier: minimumSpending, maintainSpending, earnRate
  • MemberTierHistory: accumulateSpending, fromTierId, toTierId
  • SalesTransaction: totalAccumSpendableAmount
  • RefundSalesTransaction: revokeAccumSpendableAmount

QA Retest Impact Matrix + Checklist

QA Retest Impact Matrix

ถ้าแก้ Core Function นี้ Flows ที่กระทบ Priority
calculateAccumulate() 1, 2, 5, 6, 7, 8 CRITICAL
calculateAccumulateSpending() 1, 2, 5, 6, 7, 8 CRITICAL
calculateAccumulateMaintainSpending() 1, 2 โดยตรง; 4, 5, 6, 7, 8, 9 ผ่าน updateMemberTier() HIGH
calculateTierToUpgrade() 1, 6(ADD) HIGH
calculateTierAdjustment() 2, 5, 6(DEDUCT) HIGH
calculateNewTier() 7, 8 HIGH
calculateNewTierFromAccumulateMaintainSpending() 4 HIGH
calculateNewMinimumTier() 4, 7, 9(existing member) HIGH
create() 9(new member) HIGH
updateMemberTier() 1, 2, 4, 5, 6, 7, 8, 9(existing member) CRITICAL
updateAccumulateSpending() 3A HIGH
processSpendingAndTierUpdate() 3B CRITICAL
calculateNewTierStartDateFromSalesTransactionPeriod() 5 HIGH
static calculateNewTierEndedAt() 1, 2, 3B, 5, 6, 7, 8, 9 HIGH
static getMinimumTierFromCoBrandCards() 4, 7, 9(existing member) HIGH

QA Retest Checklist

Scenario A: ถ้าแก้ calculateAccumulate() / calculateAccumulateSpending()

  • Flow 1: ซื้อของ → accum เพิ่มถูกต้อง + tier upgrade ถ้าถึง threshold
  • Flow 2: Refund → accum ลดถูกต้อง + tier downgrade ถ้าต่ำกว่า threshold
  • Flow 2: Void → accum ลดถูกต้องด้วย refund type = VOID
  • Flow 5: Reconcile → tier adjust ถูกต้องหลัง maintain
  • Flow 6: Manual ADD → accum เพิ่ม + tier upgrade
  • Flow 6: Manual DEDUCT → accum ลด + tier downgrade
  • Flow 7: Co-brand cancel/inactive → tier downgrade ถูกต้อง
  • Flow 8: Staff exit → tier reassign ตาม accum ถูกต้อง

Scenario B: ถ้าแก้ calculateTierToUpgrade() / threshold upgrade logic

  • Flow 1: ซื้อของข้าม threshold → upgrade tier ถูกต้อง
  • Flow 6: Manual ADD ข้าม threshold → upgrade tier ผ่าน Flow 1 logic

Scenario C: ถ้าแก้ calculateTierAdjustment() / downgrade logic

  • Flow 2: Refund ทำให้ต่ำกว่า threshold → downgrade
  • Flow 2: Void ทำให้ต่ำกว่า threshold → downgrade
  • Flow 5: Reconcile ตรวจพบ tier ผิด → adjust ถูกต้อง
  • Flow 6: Manual DEDUCT → downgrade ผ่าน Flow 2 logic

Scenario D: ถ้าแก้ calculateNewTierFromAccumulateMaintainSpending() / maintain tier logic

  • Flow 4: accumulateMaintainSpending >= maintainSpending → maintain tier
  • Flow 4: accumulateMaintainSpending < maintainSpending → downgrade tier
  • Flow 4 เสร็จแล้ว Flow 5 reconcile ยังทำงานต่อถูกต้อง

Scenario E: ถ้าแก้ updateMemberTier()

  • Retest ทุก flow ที่มี tier change: 1, 2, 4, 5, 6, 7, 8, 9
  • MemberTierHistory ถูกสร้างด้วย fromTierId / toTierId / accumulateSpending ที่ถูกต้อง
  • accumulateMaintainSpending ถูก recalc ถูกต้องหลังเปลี่ยน tier
  • MemberTierUpdatedDomainEvent ถูก emit ทุกครั้งที่มี tier change

Scenario F: ถ้าแก้ calculateNewMinimumTier() / co-brand floor logic

  • Flow 4: Maintain tier ใช้ minimum tier floor ถูกต้อง
  • Flow 7B: Cancel / inactive single card → minimum tier recalc ถูกต้อง
  • Flow 7C: Kafka co-brand inactive ทั้งแบรนด์ → loop ทุก member และ recalc ถูกต้อง
  • Flow 9: Existing member import → minimum tier assign ถูกต้องก่อน importUpdate()

Scenario G: ถ้าแก้ Flow 3 batch paths

  • 3A: update-accumulate-spending.service.ts เรียก updateAccumulateSpending() ถูกต้อง
  • 3A: accum-only path ไม่เปลี่ยน tier โดยไม่ตั้งใจ
  • 3B: processSpendingAndTierUpdate() ใช้ aggregated values ถูกต้อง
  • 3B: Batch path ยังสร้าง MemberTierHistory ได้แม้ไม่ได้ผ่าน updateMemberTier()

Scenario H: ถ้าแก้ member import logic

  • Existing member import: ป้องกัน downgrade ยังทำงานอยู่
  • Existing member import: emit MemberTierAssignedDomainEvent และ MemberTierUpdatedDomainEvent ถูกต้อง
  • New member import: create() สร้าง member ด้วย accum = 0 และ tier ตามไฟล์ถูกต้อง
  • New member import: emit MemberTierAssignedDomainEvent และ MemberTierUpdatedDomainEvent ถูกต้อง
  • ตรวจ hardcoded yearsToMaintain = 2 ใน import path ว่ายังตรง business expectation หรือไม่

Config / Code Caveat

  • import-member.service.ts ใช้ yearsToMaintain: 2 แบบ hardcoded ใน existing/new member import path
  • member.entity.ts ใช้ calculateNewTierEndedAt(tierStartedAt, offsetYear = 2) เป็น default
  • ถ้ามีการเปลี่ยน YEARS_TO_MAINTAIN ใน config/environment ต้อง review 2 จุดนี้เพิ่ม เพราะอาจไม่ตาม loyaltyConfig.yearsToMaintain