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>
55 lines
1.7 KiB
Go
55 lines
1.7 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// DueLot is a lot whose expiry has passed and that still holds something.
|
|
type DueLot struct {
|
|
ID uuid.UUID
|
|
CustomerID uuid.UUID
|
|
Currency string
|
|
Remaining int64
|
|
ExpiresAt time.Time
|
|
// The description of the row that created the lot, for the EXPIRE row's.
|
|
SourceDescription string
|
|
}
|
|
|
|
// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md
|
|
// F12). Balances only change through WalletProcessor.
|
|
type WalletExpiryRepository interface {
|
|
// ListDueLots returns up to limit lots due at asOf, the longest overdue first. It
|
|
// takes no lock: locking a lot before its wallet would deadlock against payments,
|
|
// which lock the wallet first. WalletProcessor.ExpireLot locks and reads again.
|
|
ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error)
|
|
}
|
|
|
|
type walletExpiryRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewWalletExpiryRepository(db *gorm.DB) WalletExpiryRepository {
|
|
return &walletExpiryRepository{db: db}
|
|
}
|
|
|
|
func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) {
|
|
var lots []DueLot
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
SELECT l.id, l.customer_id, l.currency, l.remaining_amount AS remaining, l.expires_at,
|
|
t.description AS source_description
|
|
FROM wallet_lots l
|
|
JOIN wallet_transactions t ON t.id = l.source_transaction_id
|
|
WHERE l.remaining_amount > 0 AND l.expires_at <= ?
|
|
ORDER BY l.expires_at, l.id
|
|
LIMIT ?`, asOf, limit).Scan(&lots).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list due wallet lots: %w", err)
|
|
}
|
|
return lots, nil
|
|
}
|