197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
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())
|
||
|
|
}
|