package processor import ( "context" "fmt" "strconv" "time" "github.com/google/uuid" "apskel-pos-be/internal/logger" "apskel-pos-be/internal/repository" ) const ( // Lots expired per query; a run keeps going until nothing is due. walletExpiryBatchSize = 500 // Batches per run at most, so one run cannot run away. walletExpiryMaxBatches = 40 ) // NotificationTypeWalletExpired is the data type of the push a customer gets when // part of their balance expires. const NotificationTypeWalletExpired = "WALLET_EXPIRED" // WalletExpiryProcessor takes what is left in lots whose expiry has passed // (docs/prd-point-coin.md F12, PC-503). It is safe to run on several instances at // once: every lot is expired under its wallet's lock with the key expire:{lot_id}. type WalletExpiryProcessor struct { repo repository.WalletExpiryRepository wallet *WalletProcessor tx TxRunner notifier customerNotifier now func() time.Time } func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor { return &WalletExpiryProcessor{repo: repo, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} } type walletExpiredKey struct { customerID uuid.UUID currency string } // ExpireDue expires every lot due now and tells each customer how much of each // currency they lost, in one push per currency. It returns how many lots it expired. // A lot that fails is logged and left for the next run; it does not stop the others. func (p *WalletExpiryProcessor) ExpireDue(ctx context.Context) (int, error) { asOf := p.now() expired := map[walletExpiredKey]int64{} count := 0 for batch := 0; batch < walletExpiryMaxBatches; batch++ { due, err := p.repo.ListDueLots(ctx, asOf, walletExpiryBatchSize) if err != nil { p.notify(ctx, expired) return count, err } progressed := false for _, lot := range due { var res *WalletResult err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { var err error res, err = p.wallet.ExpireLot(ctx, lot.ID, func(amount int64) string { return expiryDescription(amount, lot.Currency, lot.SourceDescription) }, asOf) return err }) if err != nil { logger.NonContext.Error(fmt.Sprintf("Could not expire wallet lot %s; it will be retried", lot.ID), err) continue } if res == nil || res.Transaction == nil || res.Replayed { // Another run got there first, or a payment used it up. continue } progressed = true count++ expired[walletExpiredKey{lot.CustomerID, lot.Currency}] += -res.Transaction.Amount } // A short batch was the last; a batch that moved nothing would only come back // the same, whether failing or taken by another instance. if len(due) < walletExpiryBatchSize || !progressed { break } } p.notify(ctx, expired) return count, nil } // notify is best effort: the balance has already expired. func (p *WalletExpiryProcessor) notify(ctx context.Context, expired map[walletExpiredKey]int64) { if p.notifier == nil { return } for key, amount := range expired { name := walletCurrencyName(key.currency) data := map[string]string{ "type": NotificationTypeWalletExpired, "currency": key.currency, "amount": strconv.FormatInt(amount, 10), } body := fmt.Sprintf("%d %s kamu sudah kedaluwarsa.", amount, name) if err := p.notifier.Notify(ctx, key.customerID, name+" kedaluwarsa", body, data); err != nil { logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about expired %s", key.customerID, name), err) } } } // expiryDescription is the EXPIRE row's frozen description (ยง8.1): // "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098". func expiryDescription(amount int64, currency, sourceDescription string) string { description := fmt.Sprintf("Kedaluwarsa: %d %s", amount, walletCurrencyName(currency)) if sourceDescription != "" { description += " dari " + sourceDescription } return truncateRunes(description, walletDescriptionLimit) }