119 lines
4.0 KiB
Go
119 lines
4.0 KiB
Go
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)
|
||
|
|
}
|