feat(loyalty): give new balances their expiry

Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:

- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
  settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
  received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
  least seven days from the refund (N4, decided). A lot that never expired
  stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.

Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.

The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 13:29:25 +07:00
co-authored by Claude Opus 5.5
parent 9ce55e6002
commit 4d63673a25
16 changed files with 269 additions and 63 deletions
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -47,6 +48,12 @@ type LoyaltySettingsRepository interface {
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
// ListChanges returns a page of history, newest first, and the total count.
ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error)
// UndatedBalance counts an organization's lots of one currency that still hold
// something and have no expiry, and what they hold.
UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error)
// DateUndatedLots gives those lots an expiry, when expiry is turned on (F12).
DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error)
}
type loyaltySettingsRepository struct {
@@ -174,3 +181,39 @@ func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter Loya
}
return changes, total, nil
}
func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) {
var row struct{ Lots, Amount int64 }
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount
FROM wallet_lots
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`,
organizationID, currency).Scan(&row).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err)
}
return row.Lots, row.Amount, nil
}
func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
db, err := r.requireTx(ctx)
if err != nil {
return 0, 0, err
}
var rows []struct{ RemainingAmount int64 }
// Only lots that still hold something: an empty lot never expires anything, and
// leaving it undated keeps the history as it was.
err = db.Raw(`
UPDATE wallet_lots SET expires_at = ?
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0
RETURNING remaining_amount`,
expiresAt, organizationID, currency).Scan(&rows).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err)
}
var amount int64
for _, row := range rows {
amount += row.RemainingAmount
}
return int64(len(rows)), amount, nil
}
@@ -124,7 +124,7 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
orgSettings.PointValue = 200
saved := make(chan error, 1)
go func() {
_, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
_, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
saved <- err
}()
select {