292 lines
12 KiB
Go
292 lines
12 KiB
Go
package repository
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"gorm.io/gorm"
|
||
|
|
|
||
|
|
"apskel-pos-be/internal/constants"
|
||
|
|
"apskel-pos-be/internal/entities"
|
||
|
|
)
|
||
|
|
|
||
|
|
// RedemptionPointSource is EnakPoint a redemption spent, traced back to the ledger row
|
||
|
|
// that first created it (D5): GAME_REWARD with the budget that paid for it, or EARN,
|
||
|
|
// ADJUSTMENT, MIGRATION with none.
|
||
|
|
type RedemptionPointSource struct {
|
||
|
|
Points int64
|
||
|
|
SourceType string
|
||
|
|
BudgetID *uuid.UUID
|
||
|
|
}
|
||
|
|
|
||
|
|
// CustomerRedemption is a redemption as its customer lists it, with the voucher it was
|
||
|
|
// for and its code.
|
||
|
|
type CustomerRedemption struct {
|
||
|
|
entities.VoucherRedemption
|
||
|
|
VoucherName string
|
||
|
|
VoucherImageURL *string
|
||
|
|
VoucherType string
|
||
|
|
Code *string
|
||
|
|
CodeExpiresAt *time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// VoucherRedemptionRepository stores redemptions and what they cost each budget
|
||
|
|
// (docs/rfc-enakgame.md §5.7, §7.4, §7.6).
|
||
|
|
type VoucherRedemptionRepository interface {
|
||
|
|
// GetByKey returns a customer's redemption made with an Idempotency-Key, or nil.
|
||
|
|
GetByKey(ctx context.Context, customerID uuid.UUID, idempotencyKey string) (*entities.VoucherRedemption, error)
|
||
|
|
// CountForCustomer counts a customer's redemptions of a voucher that went through
|
||
|
|
// or may still: COMPLETED and PENDING.
|
||
|
|
CountForCustomer(ctx context.Context, customerID, voucherID uuid.UUID) (int64, error)
|
||
|
|
CreateRedemption(ctx context.Context, redemption *entities.VoucherRedemption) error
|
||
|
|
// ListCustomerRedemptions returns a page of a customer's redemptions, newest first,
|
||
|
|
// and the total.
|
||
|
|
ListCustomerRedemptions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerRedemption, int64, error)
|
||
|
|
|
||
|
|
// PointSources follows the lots a debit drew from back to their root lots and
|
||
|
|
// sums the EnakPoint per source type and budget (§7.6). Exchanges, transfers and
|
||
|
|
// refunds are walked through, since their lots point at the lots they came from.
|
||
|
|
PointSources(ctx context.Context, debitTransactionID uuid.UUID) ([]RedemptionPointSource, error)
|
||
|
|
|
||
|
|
GetRedemption(ctx context.Context, id uuid.UUID) (*entities.VoucherRedemption, error)
|
||
|
|
// MarkCompleted settles a PENDING redemption with the provider's voucher, and
|
||
|
|
// reports false when it was no longer PENDING.
|
||
|
|
MarkCompleted(ctx context.Context, id uuid.UUID, code, ref *string, at time.Time) (bool, error)
|
||
|
|
// MarkFailed settles a PENDING redemption as failed, with the refund of its
|
||
|
|
// EnakPoint, and reports false when it was no longer PENDING.
|
||
|
|
MarkFailed(ctx context.Context, id, refundTransactionID uuid.UUID, reason string) (bool, error)
|
||
|
|
// TouchPending counts one more unanswered call to the provider and returns the
|
||
|
|
// count; 0 when the redemption is no longer PENDING.
|
||
|
|
TouchPending(ctx context.Context, id uuid.UUID) (int, error)
|
||
|
|
// ClaimStalePending takes at most limit PENDING redemptions untouched since before,
|
||
|
|
// oldest first, and touches them, so another instance leaves them alone until they
|
||
|
|
// go stale again.
|
||
|
|
ClaimStalePending(ctx context.Context, before time.Time, limit int) ([]entities.VoucherRedemption, error)
|
||
|
|
CreateCosts(ctx context.Context, costs []entities.VoucherRedemptionCost) error
|
||
|
|
ListCosts(ctx context.Context, redemptionID uuid.UUID) ([]entities.VoucherRedemptionCost, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type voucherRedemptionRepository struct {
|
||
|
|
db *gorm.DB
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewVoucherRedemptionRepository(db *gorm.DB) VoucherRedemptionRepository {
|
||
|
|
return &voucherRedemptionRepository{db: db}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) GetByKey(ctx context.Context, customerID uuid.UUID, idempotencyKey string) (*entities.VoucherRedemption, error) {
|
||
|
|
var redemption entities.VoucherRedemption
|
||
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||
|
|
Where("customer_id = ? AND idempotency_key = ?", customerID, idempotencyKey).First(&redemption).Error
|
||
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read voucher redemption: %w", err)
|
||
|
|
}
|
||
|
|
return &redemption, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) CountForCustomer(ctx context.Context, customerID, voucherID uuid.UUID) (int64, error) {
|
||
|
|
var count int64
|
||
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.VoucherRedemption{}).
|
||
|
|
Where("customer_id = ? AND voucher_id = ? AND status IN ?", customerID, voucherID,
|
||
|
|
[]string{constants.VoucherRedemptionCompleted, constants.VoucherRedemptionPending}).
|
||
|
|
Count(&count).Error
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to count voucher redemptions: %w", err)
|
||
|
|
}
|
||
|
|
return count, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) CreateRedemption(ctx context.Context, redemption *entities.VoucherRedemption) error {
|
||
|
|
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(redemption).Error; err != nil {
|
||
|
|
return fmt.Errorf("failed to create voucher redemption: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) ListCustomerRedemptions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerRedemption, int64, error) {
|
||
|
|
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||
|
|
var total int64
|
||
|
|
if err := db.Model(&entities.VoucherRedemption{}).Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to count voucher redemptions: %w", err)
|
||
|
|
}
|
||
|
|
var redemptions []entities.VoucherRedemption
|
||
|
|
err := db.Where("customer_id = ?", customerID).Order("created_at DESC, id").Offset(offset).Limit(limit).Find(&redemptions).Error
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to list voucher redemptions: %w", err)
|
||
|
|
}
|
||
|
|
voucherIDs, codeIDs := []uuid.UUID{}, []uuid.UUID{}
|
||
|
|
for _, red := range redemptions {
|
||
|
|
voucherIDs = append(voucherIDs, red.VoucherID)
|
||
|
|
if red.VoucherCodeID != nil {
|
||
|
|
codeIDs = append(codeIDs, *red.VoucherCodeID)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
vouchers := map[uuid.UUID]entities.Voucher{}
|
||
|
|
if len(voucherIDs) > 0 {
|
||
|
|
var rows []entities.Voucher
|
||
|
|
if err := db.Where("id IN ?", voucherIDs).Find(&rows).Error; err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to read redeemed vouchers: %w", err)
|
||
|
|
}
|
||
|
|
for _, v := range rows {
|
||
|
|
vouchers[v.ID] = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
codes := map[uuid.UUID]entities.VoucherCode{}
|
||
|
|
if len(codeIDs) > 0 {
|
||
|
|
var rows []entities.VoucherCode
|
||
|
|
if err := db.Where("id IN ?", codeIDs).Find(&rows).Error; err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to read redeemed codes: %w", err)
|
||
|
|
}
|
||
|
|
for _, c := range rows {
|
||
|
|
codes[c.ID] = c
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out := make([]CustomerRedemption, 0, len(redemptions))
|
||
|
|
for _, red := range redemptions {
|
||
|
|
v := vouchers[red.VoucherID]
|
||
|
|
item := CustomerRedemption{VoucherRedemption: red, VoucherName: v.Name, VoucherImageURL: v.ImageURL, VoucherType: v.VoucherType, Code: red.ExternalCode}
|
||
|
|
if red.VoucherCodeID != nil {
|
||
|
|
if c, ok := codes[*red.VoucherCodeID]; ok {
|
||
|
|
code := c.Code
|
||
|
|
item.Code, item.CodeExpiresAt = &code, c.ExpiresAt
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out = append(out, item)
|
||
|
|
}
|
||
|
|
return out, total, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) PointSources(ctx context.Context, debitTransactionID uuid.UUID) ([]RedemptionPointSource, error) {
|
||
|
|
var rows []struct {
|
||
|
|
Points int64
|
||
|
|
SourceType string
|
||
|
|
BudgetID *string
|
||
|
|
}
|
||
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||
|
|
WITH RECURSIVE chain AS (
|
||
|
|
SELECT a.lot_id AS spent_lot, a.amount AS points, l.origin_lot_id, l.source_transaction_id
|
||
|
|
FROM wallet_lot_allocations a
|
||
|
|
JOIN wallet_lots l ON l.id = a.lot_id
|
||
|
|
WHERE a.transaction_id = ?
|
||
|
|
UNION ALL
|
||
|
|
SELECT c.spent_lot, c.points, p.origin_lot_id, p.source_transaction_id
|
||
|
|
FROM chain c
|
||
|
|
JOIN wallet_lots p ON p.id = c.origin_lot_id
|
||
|
|
)
|
||
|
|
SELECT SUM(c.points) AS points, t.type AS source_type, gsr.budget_id::text AS budget_id
|
||
|
|
FROM chain c
|
||
|
|
JOIN wallet_transactions t ON t.id = c.source_transaction_id
|
||
|
|
LEFT JOIN game_session_rewards gsr ON gsr.wallet_transaction_id = t.id
|
||
|
|
WHERE c.origin_lot_id IS NULL
|
||
|
|
GROUP BY t.type, gsr.budget_id
|
||
|
|
ORDER BY t.type, gsr.budget_id`, debitTransactionID).Scan(&rows).Error
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to trace redeemed EnakPoint: %w", err)
|
||
|
|
}
|
||
|
|
out := make([]RedemptionPointSource, 0, len(rows))
|
||
|
|
for _, row := range rows {
|
||
|
|
s := RedemptionPointSource{Points: row.Points, SourceType: row.SourceType}
|
||
|
|
if row.BudgetID != nil {
|
||
|
|
id, err := uuid.Parse(*row.BudgetID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("bad budget id %q: %w", *row.BudgetID, err)
|
||
|
|
}
|
||
|
|
s.BudgetID = &id
|
||
|
|
}
|
||
|
|
out = append(out, s)
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) CreateCosts(ctx context.Context, costs []entities.VoucherRedemptionCost) error {
|
||
|
|
if len(costs) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(&costs).Error; err != nil {
|
||
|
|
return fmt.Errorf("failed to record redemption costs: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) ListCosts(ctx context.Context, redemptionID uuid.UUID) ([]entities.VoucherRedemptionCost, error) {
|
||
|
|
var costs []entities.VoucherRedemptionCost
|
||
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Where("redemption_id = ?", redemptionID).
|
||
|
|
Order("cost DESC, source_type").Find(&costs).Error
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to list redemption costs: %w", err)
|
||
|
|
}
|
||
|
|
return costs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) GetRedemption(ctx context.Context, id uuid.UUID) (*entities.VoucherRedemption, error) {
|
||
|
|
var redemption entities.VoucherRedemption
|
||
|
|
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id = ?", id).First(&redemption).Error; err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read voucher redemption: %w", err)
|
||
|
|
}
|
||
|
|
return &redemption, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) MarkCompleted(ctx context.Context, id uuid.UUID, code, ref *string, at time.Time) (bool, error) {
|
||
|
|
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||
|
|
UPDATE voucher_redemptions SET status = ?, external_code = ?, external_ref = ?, completed_at = ?,
|
||
|
|
attempts = attempts + 1, updated_at = NOW()
|
||
|
|
WHERE id = ? AND status = ?`,
|
||
|
|
constants.VoucherRedemptionCompleted, code, ref, at, id, constants.VoucherRedemptionPending)
|
||
|
|
if result.Error != nil {
|
||
|
|
return false, fmt.Errorf("failed to complete voucher redemption: %w", result.Error)
|
||
|
|
}
|
||
|
|
return result.RowsAffected == 1, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) MarkFailed(ctx context.Context, id, refundTransactionID uuid.UUID, reason string) (bool, error) {
|
||
|
|
if len(reason) > 255 {
|
||
|
|
reason = reason[:255]
|
||
|
|
}
|
||
|
|
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||
|
|
UPDATE voucher_redemptions SET status = ?, refund_transaction_id = ?, failure_reason = ?,
|
||
|
|
attempts = attempts + 1, updated_at = NOW()
|
||
|
|
WHERE id = ? AND status = ?`,
|
||
|
|
constants.VoucherRedemptionFailed, refundTransactionID, reason, id, constants.VoucherRedemptionPending)
|
||
|
|
if result.Error != nil {
|
||
|
|
return false, fmt.Errorf("failed to fail voucher redemption: %w", result.Error)
|
||
|
|
}
|
||
|
|
return result.RowsAffected == 1, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) TouchPending(ctx context.Context, id uuid.UUID) (int, error) {
|
||
|
|
var attempts []int
|
||
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||
|
|
UPDATE voucher_redemptions SET attempts = attempts + 1, updated_at = NOW()
|
||
|
|
WHERE id = ? AND status = ? RETURNING attempts`, id, constants.VoucherRedemptionPending).Scan(&attempts).Error
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to touch voucher redemption: %w", err)
|
||
|
|
}
|
||
|
|
if len(attempts) == 0 {
|
||
|
|
return 0, nil
|
||
|
|
}
|
||
|
|
return attempts[0], nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *voucherRedemptionRepository) ClaimStalePending(ctx context.Context, before time.Time, limit int) ([]entities.VoucherRedemption, error) {
|
||
|
|
var claimed []entities.VoucherRedemption
|
||
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||
|
|
UPDATE voucher_redemptions SET updated_at = NOW()
|
||
|
|
WHERE id IN (
|
||
|
|
SELECT id FROM voucher_redemptions
|
||
|
|
WHERE status = ? AND updated_at <= ?
|
||
|
|
ORDER BY updated_at, id
|
||
|
|
LIMIT ?
|
||
|
|
FOR UPDATE SKIP LOCKED)
|
||
|
|
RETURNING *`, constants.VoucherRedemptionPending, before, limit).Scan(&claimed).Error
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to claim pending voucher redemptions: %w", err)
|
||
|
|
}
|
||
|
|
return claimed, nil
|
||
|
|
}
|