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
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
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
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
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
---
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