feat(loyalty): expire balances whose time is up
Adds the expiry job (docs/prd-point-coin.md F12, PC-503).
Every 15 minutes it lists the lots whose expiry has passed and that still
hold something, the longest overdue first, 500 at a time, and expires each
in its own transaction through WalletProcessor.ExpireLot: lock the wallet,
read the lot again, and take what is left with an EXPIRE row pointing at
the lot, keyed expire:{lot_id}. The description is frozen as
"Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read
under the lock. Lots expire at the end of their day, so none stays past it
for more than about a quarter of an hour.
It is safe on several instances and across restarts, keeping no state in
memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP
LOCKED, as PC-503 suggested, would lock a lot before its wallet and
deadlock against payments, which lock the wallet first; instead the
listing takes no lock, and the wallet lock plus the idempotency key make a
second instance find the lot empty or the key used and take nothing.
A lot that fails is logged and retried on the next run without stopping
the others. Each customer gets one FCM push per currency with the total
that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type
WALLET_EXPIRED).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4d63673a25
commit
d293786cde
@@ -149,6 +149,55 @@ func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey st
|
||||
return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey)
|
||||
}
|
||||
|
||||
// ExpireLot takes what is left in a lot whose expiry has passed at asOf, as an EXPIRE
|
||||
// row pointing at the lot (F12). It locks the wallet before reading the lot again, so
|
||||
// it never races a payment for the same balance, and the key expire:{lot_id} makes a
|
||||
// second run, on this instance or another, take nothing more. describe gives the
|
||||
// row's description for the amount taken. It returns nil when
|
||||
// there is nothing to take: the lot is empty, not due, or already expired.
|
||||
func (p *WalletProcessor) ExpireLot(ctx context.Context, lotID uuid.UUID, describe func(amount int64) string, asOf time.Time) (*WalletResult, error) {
|
||||
lot, err := p.getLot(ctx, lotID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.repo.LockWallet(ctx, lot.CustomerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Read again under the lock: a payment may have used it up meanwhile.
|
||||
if lot, err = p.getLot(ctx, lotID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lot.RemainingAmount == 0 || lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf) {
|
||||
return nil, nil
|
||||
}
|
||||
return p.Debit(ctx, WalletDebitInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: lot.CustomerID,
|
||||
Currency: lot.Currency,
|
||||
Type: constants.WalletTxTypeExpire,
|
||||
Amount: lot.RemainingAmount,
|
||||
ReferenceType: constants.WalletRefTypeLot,
|
||||
ReferenceID: lot.ID,
|
||||
Description: describe(lot.RemainingAmount),
|
||||
Metadata: entities.Metadata{"expires_at": lot.ExpiresAt.UTC().Format(time.RFC3339)},
|
||||
IdempotencyKey: "expire:" + lot.ID.String(),
|
||||
},
|
||||
// Exactly what the lot holds, from the lot itself, even though it has expired.
|
||||
PreferredLotIDs: []uuid.UUID{lot.ID},
|
||||
})
|
||||
}
|
||||
|
||||
func (p *WalletProcessor) getLot(ctx context.Context, lotID uuid.UUID) (*entities.WalletLot, error) {
|
||||
lots, err := p.repo.GetLotsByIDs(ctx, []uuid.UUID{lotID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lots) == 0 {
|
||||
return nil, fmt.Errorf("%w: lot %s does not exist", ErrWalletInvalidEntry, lotID)
|
||||
}
|
||||
return &lots[0], nil
|
||||
}
|
||||
|
||||
// Credit adds Amount to the wallet and creates its lots.
|
||||
func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) {
|
||||
if err := validateWalletEntry(&in.WalletEntry, true); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user