2026-09-30 14:31:38 +07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 14:35:12 +07:00
|
|
|
// UpcomingExpiry is how much of a customer's balance expires on one day.
|
|
|
|
|
type UpcomingExpiry struct {
|
|
|
|
|
CustomerID uuid.UUID
|
|
|
|
|
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
|
|
|
|
|
Date string
|
|
|
|
|
Amount int64
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 14:31:38 +07:00
|
|
|
// 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)
|
2026-09-30 14:35:12 +07:00
|
|
|
|
|
|
|
|
// OrganizationsWithUpcomingExpiry lists the organizations that have balance
|
|
|
|
|
// expiring after asOf.
|
|
|
|
|
OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error)
|
|
|
|
|
// UpcomingUnreminded sums, per customer and expiry day, the balance of one currency
|
|
|
|
|
// of an organization expiring after asOf and up to until, leaving out the days the
|
|
|
|
|
// customer has already been reminded of.
|
|
|
|
|
UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error)
|
|
|
|
|
// MarkReminded records a reminder, and reports false when it was already recorded,
|
|
|
|
|
// by this run or another.
|
|
|
|
|
MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error)
|
2026-09-30 14:31:38 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-09-30 14:35:12 +07:00
|
|
|
|
|
|
|
|
func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) {
|
|
|
|
|
var ids []uuid.UUID
|
|
|
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
|
|
|
SELECT DISTINCT organization_id FROM wallet_lots
|
|
|
|
|
WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return ids, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) {
|
|
|
|
|
var rows []UpcomingExpiry
|
|
|
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
|
|
|
WITH by_day AS (
|
|
|
|
|
SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount
|
|
|
|
|
FROM wallet_lots
|
|
|
|
|
WHERE organization_id = ? AND currency = ? AND remaining_amount > 0
|
|
|
|
|
AND expires_at > ? AND expires_at <= ?
|
|
|
|
|
GROUP BY customer_id, day
|
|
|
|
|
)
|
|
|
|
|
SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount
|
|
|
|
|
FROM by_day d
|
|
|
|
|
LEFT JOIN wallet_expiry_reminders w
|
|
|
|
|
ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day
|
|
|
|
|
WHERE w.customer_id IS NULL
|
|
|
|
|
ORDER BY d.day, d.customer_id`,
|
|
|
|
|
walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list upcoming expiry: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return rows, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) {
|
|
|
|
|
res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
|
|
|
|
INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount)
|
|
|
|
|
VALUES (?, ?, ?::date, ?)
|
|
|
|
|
ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount)
|
|
|
|
|
if res.Error != nil {
|
|
|
|
|
return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error)
|
|
|
|
|
}
|
|
|
|
|
return res.RowsAffected == 1, nil
|
|
|
|
|
}
|