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

  1. ✅ แก้ recent-sales-transaction.service.ts+ 24 (ทำแล้ว)
  2. รัน one-time backfill: เติม recent sales/refund ช่วง 36–48 เดือนให้ครบ
  3. Deploy code ใหม่ (ไม่มี DB migration)
  4. Monitor: refund downgrade behavior + recent transaction count per member + p95 latency ของ refund path
  5. Rollback: revert code กลับ legacy ได้ทันที (ไม่มี schema change)