Solution A: Implementation Plan
แผนการ implement Solution A — แก้เฉพาะ Flow 2 ไม่มี schema change
ตรวจสอบแล้วว่าครบตามsolution-a-core-function-dependency.mdและsolution-a-sequence-diagrams.md
1. DB Schema
ไม่มี schema change — คำนวณสดจาก transactions ใน memory ทุกครั้ง
2. ขยาย Recent Transaction Window เป็น 48 เดือน
ไฟล์: src/modules/loyalty/services/recent-sales-transaction.service.ts
✅ ทำแล้ว — เปลี่ยน + 12 เป็น + 24 ที่ line 22
// เดิม
DateTimeHelper.subtractTime(
now,
loyaltyConfig.accumulateThresholdMonth + 12,
"month",
);
// ใหม่
DateTimeHelper.subtractTime(
now,
loyaltyConfig.accumulateThresholdMonth + 24,
"month",
);
accumulateThresholdMonth = 24 → window ใหม่ = 48 เดือน
⚠️ Rollout dependency: ต้องรัน one-time backfill สำหรับ recent sales/refund ช่วง 36–48 เดือนก่อน deploy logic ใหม่
ถ้า window ยังเป็น 36 เดือน refund path จะตัดสินบนข้อมูลไม่ครบ
3. เพิ่ม Methods ใหม่ใน member.entity.ts
ไฟล์: src/modules/loyalty/domains/entities/member.entity.ts
3.1 calculateAccumTierLogicSpending()
คำนวณ Accum Tier Logic = SUM 24 เดือนก่อน tierStartedAt (รวม up-tier txn) + SUM ทุก txn หลัง tierStartedAt
isBetweenของ dayjs เป็น exclusive ทั้งสองด้าน → ต้องใช้bufferedUpTierDate = upTierDate + 10minเพื่อให้ up-tier txn ถูกนับใน window 1 และไม่นับซ้ำใน window 2
private calculateAccumTierLogicSpending(): number {
const upTierDate = this.tierStartedAt;
const windowStart = DateTimeHelper.startOf(
DateTimeHelper.subtractTime(upTierDate, loyaltyConfig.accumulateThresholdMonth, 'month'),
'month',
);
const bufferedUpTierDate = DateTimeHelper.addTime(upTierDate, 10, 'minute');
const bufferedNow = DateTimeHelper.addTime(DateTimeHelper.now(), 10, 'minute');
// window 1: [windowStart, bufferedUpTierDate] = 24 เดือนก่อน up-tier รวม up-tier txn เอง
const preUpTierAccum = this.calculateAccumulate(windowStart, bufferedUpTierDate);
// window 2: [bufferedUpTierDate, bufferedNow] = ทุก txn หลัง up-tier ไม่รวม up-tier txn
const postUpTierAccum = this.calculateAccumulate(bufferedUpTierDate, bufferedNow);
return new Decimal(preUpTierAccum).plus(postUpTierAccum).toNumber();
}
หมายเหตุ:
calculateAccumulate()มีอยู่แล้ว รับstartDate, endDate— ใช้ได้ทันที
3.2 calculateTierAdjustmentWithNewLogic(tiers)
ตัดสิน down-tier โดยใช้ Accum Tier Logic แทน rolling 24mo
private calculateTierAdjustmentWithNewLogic(tiers: TierEntity[]): TierEntity | undefined {
const memberCurrentTier = tiers.find((tier) => tier.id === this.tierId);
if (!memberCurrentTier || memberCurrentTier.type !== TierType.NORMAL) {
return this.tier;
}
const accumTierLogic = this.calculateAccumTierLogicSpending();
const sortedTiers = tiers
.filter((tier) => tier.type === TierType.NORMAL)
.sort((a, b) => (b.minimumSpending ?? 0) - (a.minimumSpending ?? 0));
let newTier: TierEntity | undefined;
for (const tier of sortedTiers) {
if (accumTierLogic >= (tier.minimumSpending ?? 0)) {
newTier = tier;
break;
}
}
if (this.minimumTier && newTier) {
return TierEntity.compareGetHigherTier(newTier, this.minimumTier);
}
return newTier ?? this.tier;
}
3.3 findPreviousTierInfo(newTierId)
หา tierStartedAt / tierEndedAt จาก MemberTierHistory ของ tier ปลายทาง + check validity
private findPreviousTierInfo(
newTierId: string,
): { tierStartedAt: Date; tierEndedAt: Date } | null {
const now = DateTimeHelper.now();
const history = [...this.memberTierHistories]
.filter((h) => h.toTierId === newTierId)
.sort(
(a, b) =>
DateTimeHelper.parseDate(b.tierStartedAt).valueOf() -
DateTimeHelper.parseDate(a.tierStartedAt).valueOf(),
)[0];
if (!history) return null;
// tierEndedAt หมดอายุแล้ว → ไม่ valid
if (DateTimeHelper.isAfter(now, history.tierEndedAt)) return null;
return {
tierStartedAt: DateTimeHelper.parseDate(history.tierStartedAt).toDate(),
tierEndedAt: DateTimeHelper.parseDate(history.tierEndedAt).toDate(),
};
}
3.4 findQualifyingTransactionDate(threshold)
scan หา txn ที่ทำให้ accum (หักลบ refund) ข้าม threshold ใน 24 เดือนก่อน tierStartedAt + หลัง up-tier จนถึงปัจจุบัน
private findQualifyingTransactionDate(threshold: number): Date | null {
const upTierDate = this.tierStartedAt;
const windowStart = DateTimeHelper.startOf(
DateTimeHelper.subtractTime(upTierDate, loyaltyConfig.accumulateThresholdMonth, 'month'),
'month',
);
const bufferedNow = DateTimeHelper.addTime(DateTimeHelper.now(), 10, 'minute');
// window = 24 เดือนก่อน up-tier + หลัง up-tier จนถึงปัจจุบัน
const salesTxns = this.getAllSalesTransactions()
.filter((txn) => DateTimeHelper.isBetween(txn.completedAt, windowStart, bufferedNow))
.sort((a, b) =>
DateTimeHelper.parseDate(a.completedAt).valueOf() -
DateTimeHelper.parseDate(b.completedAt).valueOf(),
);
// สร้าง map ของ refund amounts โดย key = salesTransactionId
const refundBySaleId = new Map<string, number>();
for (const refund of this.getAllRefundSalesTransactions()) {
const sale = this.getSalesTransactionById(refund.salesTransactionId);
if (sale && DateTimeHelper.isBetween(sale.completedAt, windowStart, bufferedNow)) {
const current = refundBySaleId.get(refund.salesTransactionId) ?? 0;
refundBySaleId.set(
refund.salesTransactionId,
new Decimal(current).plus(refund.getProps().revokeAccumSpendableAmount).toNumber(),
);
}
}
// วิ่งสะสมยอดทีละ txn จากเก่าสุด โดยหักลบ refund ของแต่ละ txn ด้วย
let running = 0;
for (const txn of salesTxns) {
const saleAmount = txn.getProps().totalAccumSpendableAmount;
const refundAmount = refundBySaleId.get(txn.id) ?? 0;
running = new Decimal(running).plus(saleAmount).plus(refundAmount).toNumber();
if (running >= threshold) {
return txn.completedAt;
}
}
return null;
}
3.5 calculatePostUpTierMaintainSpending()
คำนวณ maintain spending จาก txns หลัง tierStartedAt (ไม่รวม up-tier txn เอง)
private calculatePostUpTierMaintainSpending(): number {
const upTierDate = this.tierStartedAt;
// SUM sales txns ที่ completedAt > upTierDate (isAfter = exclusive → ไม่รวม up-tier txn)
const salesSum = this.getAllSalesTransactions()
.filter((txn) => DateTimeHelper.isAfter(txn.completedAt, upTierDate))
.reduce(
(acc, txn) => new Decimal(acc).plus(txn.getProps().totalAccumSpendableAmount).toNumber(),
0,
);
// SUM refund txns ที่ original sale เกิดหลัง upTierDate
const refundSum = this.getAllRefundSalesTransactions()
.filter((refund) => {
const sale = this.getSalesTransactionById(refund.salesTransactionId);
return sale && DateTimeHelper.isAfter(sale.completedAt, upTierDate);
})
.reduce(
(acc, txn) => new Decimal(acc).plus(txn.getProps().revokeAccumSpendableAmount).toNumber(),
0,
);
return Math.max(0, new Decimal(salesSum).plus(refundSum).toNumber());
}
4. แก้ addNewRefundSalesTransaction() ใน member.entity.ts
code เดิม (line 963–1010):
// เดิม — ใช้ calculateTierAdjustment() และ reset maintain = 0
if (
DateTimeHelper.isAfter(
this.tierStartedAt,
salesTransaction?.completedAt ?? now,
)
) {
const newTier = this.calculateTierAdjustment(tiers);
let newTierStartedAt = this.tierStartedAt;
let newTierEndedAt = this.tierEndedAt;
if (newTier?.id !== this.tierId) {
newTierStartedAt = now;
newTierEndedAt = MemberEntity.calculateNewTierEndedAt(newTierStartedAt);
this.setAccumulateMaintainSpending(0); // ← reset = 0
const updateMemberTierData: UpdateMemberTierProps = {
tier: newTier as TierEntity,
tierStartedAt: newTierStartedAt,
tierEndedAt: newTierEndedAt,
};
this.updateMemberTier(updateMemberTierData);
// ...emit event
}
}
แก้เป็น:
// กฎ 2: refund หลัง up-tier → ไม่ down-tier
if (
DateTimeHelper.isAfter(
this.tierStartedAt,
salesTransaction?.completedAt ?? now,
)
) {
// กฎ 3: refund ก่อน/ตอน up-tier → ใช้ Accum Tier Logic
const newTier = this.calculateTierAdjustmentWithNewLogic(tiers);
if (newTier?.id !== this.tierId) {
let newTierStartedAt: Date;
let newTierEndedAt: Date;
const previousTierInfo = this.findPreviousTierInfo(newTier?.id ?? "");
if (previousTierInfo) {
// กฎ 5.1: มี valid history → ใช้ dates จาก history
newTierStartedAt = previousTierInfo.tierStartedAt;
newTierEndedAt = previousTierInfo.tierEndedAt;
} else {
// กฎ 5.2: ไม่มี / expired → scan qualifying txn
const qualifyingDate = this.findQualifyingTransactionDate(
newTier?.minimumSpending ?? 0,
);
if (qualifyingDate) {
newTierStartedAt = qualifyingDate; // กฎ 3.3.1
} else {
newTierStartedAt = now; // กฎ 3.3.2
}
newTierEndedAt = MemberEntity.calculateNewTierEndedAt(newTierStartedAt);
}
this.updateMemberTier({
tier: newTier as TierEntity,
tierStartedAt: newTierStartedAt,
tierEndedAt: newTierEndedAt,
});
// กฎ 4: maintain ไม่ reset = 0 — ต้องเรียกหลัง updateMemberTier()
// เพราะ updateMemberTier() recalculate maintain ด้วย calculateAccumulateMaintainSpending() ซึ่งจะถูก override ทันที
this.setAccumulateMaintainSpending(
this.calculatePostUpTierMaintainSpending(),
);
const payload = this.domainEvents.find(
(event) => event instanceof MemberTierUpdatedDomainEvent,
) as MemberTierUpdatedDomainEvent;
this.addEvent(
new MemberTierDowngradedDueToRefundDomainEvent(payload as any),
);
}
}
5. Flow 1, 6
- Flow 1 (
addNewSalesTransaction): ไม่แก้ - Flow 6 ADD: ไม่แก้
- Flow 6 DEDUCT: ไม่แก้ — delegate ผ่าน Flow 2 ได้รับ new logic ทางอ้อม
6. Flow 3, 4, 5, 7, 8, 9
ไม่ต้องแก้ — ใช้ path แยก ไม่ผ่าน addNewRefundSalesTransaction()
| Flow | Entry point | ผ่าน addNewRefundSalesTransaction? |
|---|---|---|
| Flow 3B | processSpendingAndTierUpdate() |
❌ |
| Flow 4 | maintainTier() |
❌ |
| Flow 5 | adjustTier() → calculateTierAdjustment() |
❌ |
| Flow 7 | coBrandCardUpdated() → calculateNewTier() |
❌ |
| Flow 8 | processStaffExit() → calculateNewTier() |
❌ |
| Flow 9 | importUpdate() / create() |
❌ |
สรุปไฟล์ที่ต้องแก้
| ไฟล์ | สิ่งที่แก้ | สถานะ |
|---|---|---|
src/modules/loyalty/services/recent-sales-transaction.service.ts |
+ 12 → + 24 (line 22) |
✅ ทำแล้ว |
src/modules/loyalty/domains/entities/member.entity.ts |
เพิ่ม 5 methods + แก้ addNewRefundSalesTransaction() |
✅ ทำแล้ว |
test/modules/loyalty/domain/entities/member.entity.spec.ts |
เพิ่ม unit tests | ⬜ ยังไม่ทำ |
Unit Tests (member.entity.spec.ts)
calculateAccumTierLogicSpending()
- window = 24mo ก่อน tierStartedAt + ทุก txn หลัง tierStartedAt
- refund ก่อน up-tier ลด accum ถูกต้อง
- refund หลัง up-tier ลด accum ถูกต้อง
calculateTierAdjustmentWithNewLogic()
- accum ยังถึง tier ปัจจุบัน → คง tier (กฎ 3.1)
- accum ต่ำกว่า tier ปัจจุบัน แต่ถึง tier ก่อนหน้า → down tier (กฎ 3.2)
- accum ต่ำกว่า tier ก่อนหน้า → down tier ข้าม (กฎ 3.3)
- minimumTier (co-brand floor) ถูก apply
findPreviousTierInfo()
- มี history ของ tier ปลายทาง + tierEndedAt ยังไม่หมด → return dates
- มี history แต่ tierEndedAt หมดแล้ว → return null
- ไม่มี history ของ tier ปลายทาง → return null
findQualifyingTransactionDate()
- มี txn ใน window (24 เดือนก่อน up-tier + หลัง up-tier จนถึงปัจจุบัน) ที่ทำให้ accum (หักลบ refund) ถึง threshold → return completedAt ของ txn นั้น
- ไม่มี txn ใน window ที่ถึง threshold → return null
- refund หักลบจาก sales ถูกต้อง (accum ไม่นับยอดที่ถูก refund)
calculatePostUpTierMaintainSpending()
- SUM เฉพาะ txns ที่ completedAt > tierStartedAt
- up-tier txn เอง (completedAt === tierStartedAt) ไม่นับ
- refund หลัง up-tier หักออกถูกต้อง
- ผลลัพธ์ไม่ติดลบ (Math.max(0, …))
addNewRefundSalesTransaction() — guard + new logic
- refund หลัง up-tier → ไม่ down-tier (กฎ 2)
- refund ก่อน up-tier + accum ยังถึง tier → คง tier (กฎ 3.1)
- refund ก่อน up-tier + accum ต่ำกว่า tier + มี valid history → down + ใช้ dates จาก history (กฎ 5.1)
- refund ก่อน up-tier + accum ต่ำกว่า tier + history หมดอายุ → down + scan qualifying txn (กฎ 5.2)
- refund ก่อน up-tier + accum ต่ำกว่า tier + ไม่มี qualifying txn → down + tierStartedAt = now (กฎ 3.3.2)
- down-tier → maintain ไม่ reset = 0 แต่ = calculatePostUpTierMaintainSpending() (กฎ 4)
- down-tier → emit MemberTierDowngradedDueToRefundDomainEvent
- transaction expired (ไม่มี refund event) → ไม่ down-tier (กฎ 1)
Integration Tests
- ซื้อของ → upgrade → refund ก่อน up-tier → downgrade + maintain ถูกต้อง
- ซื้อของ → upgrade → refund หลัง up-tier → ไม่ downgrade
- admin DEDUCT → behavior เดียวกับ Flow 2
- Flow 3, 4, 5, 7, 8, 9 → behavior เดิม ไม่กระทบ
Deploy
- ✅ แก้
recent-sales-transaction.service.ts→+ 24(ทำแล้ว) - รัน one-time backfill: เติม recent sales/refund ช่วง 36–48 เดือนให้ครบ
- Deploy code ใหม่ (ไม่มี DB migration)
- Monitor: refund downgrade behavior + recent transaction count per member + p95 latency ของ refund path
- Rollback: revert code กลับ legacy ได้ทันที (ไม่มี schema change)