package processor import ( "context" "fmt" "strconv" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "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" // NotificationTypeWalletExpiring is the data type of the reminder a customer gets // before part of their balance expires. const NotificationTypeWalletExpiring = "WALLET_EXPIRING" // 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 settings organizationSettingsReader wallet *WalletProcessor tx TxRunner notifier customerNotifier now func() time.Time } func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, settings organizationSettingsReader, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor { return &WalletExpiryProcessor{repo: repo, settings: settings, 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) } // SendReminders tells customers, reminder_days before, how much of their balance // expires on a day (F12): one push per customer, currency and expiry day, however many // lots make it up. A reminder is recorded before it is sent, so another instance or a // later run never sends it again; a push that then fails is logged and not retried. // It returns how many reminders it sent. func (p *WalletExpiryProcessor) SendReminders(ctx context.Context) (int, error) { now := p.now() organizations, err := p.repo.OrganizationsWithUpcomingExpiry(ctx, now) if err != nil { return 0, err } sent := 0 for _, organizationID := range organizations { settings, err := p.settings.Organization(ctx, organizationID) if err != nil { logger.NonContext.Error(fmt.Sprintf("Could not read the expiry settings of organization %s; its reminders wait for the next run", organizationID), err) continue } for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} { days := ExpirySettings(settings, currency).ReminderDays if days <= 0 { continue } until := endOfWalletDay(walletDay(now).AddDate(0, 0, int(days))) upcoming, err := p.repo.UpcomingUnreminded(ctx, organizationID, currency, now, *until) if err != nil { return sent, err } for _, u := range upcoming { first, err := p.repo.MarkReminded(ctx, u, currency) if err != nil { return sent, err } if !first { continue } sent++ p.remind(ctx, u, currency) } } } return sent, nil } func (p *WalletExpiryProcessor) remind(ctx context.Context, u repository.UpcomingExpiry, currency string) { if p.notifier == nil { return } name := walletCurrencyName(currency) body := fmt.Sprintf("%d %s akan kedaluwarsa pada %s. Pakai sebelum hangus.", u.Amount, name, formatWalletDate(u.Date)) data := map[string]string{ "type": NotificationTypeWalletExpiring, "currency": currency, "amount": strconv.FormatInt(u.Amount, 10), "expiry_date": u.Date, } if err := p.notifier.Notify(ctx, u.CustomerID, name+" akan kedaluwarsa", body, data); err != nil { logger.NonContext.Error(fmt.Sprintf("Could not remind customer %s of expiring %s", u.CustomerID, name), err) } } var walletMonthNames = [...]string{"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"} // formatWalletDate writes a YYYY-MM-DD date the way the apps do: "31 Okt 2026". func formatWalletDate(date string) string { d, err := time.Parse("2006-01-02", date) if err != nil { return date } return fmt.Sprintf("%d %s %d", d.Day(), walletMonthNames[d.Month()-1], d.Year()) }