Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:11 +07:00
parent 5e28b05770
commit c6062c4bb9
195 changed files with 20058 additions and 2177 deletions
@@ -0,0 +1,196 @@
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())
}