feat(loyalty): remind customers before balances expire
Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12, PC-504). GET /customer/wallet/expiring lists everything that will expire, per currency and day, soonest first. GET /customer/wallet already had the nearest expiry per currency. The expiry job now also sends reminders, with the settings of note N4 as decided: once, reminder_days before (7 by default, 0 for none), per currency. A customer gets one FCM push per currency and expiry day, however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency, amount and expiry_date in its data. Reminders cover whatever falls within the window, so a run that was missed catches up rather than skipping a day. Migration 000097 adds wallet_expiry_reminders, one row per customer, currency and expiry day. The row is written before the push is sent, so several instances of the job or a restart never remind twice; a push that then fails is logged and not retried. Lots that expire later on the same day as an earlier reminder are not reminded of again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d293786cde
commit
550122f29c
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
@@ -23,19 +24,24 @@ const (
|
||||
// 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, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor {
|
||||
return &WalletExpiryProcessor{repo: repo, wallet: wallet, tx: tx, notifier: notifier, now: time.Now}
|
||||
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 {
|
||||
@@ -116,3 +122,75 @@ func expiryDescription(amount int64, currency, sourceDescription string) string
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user