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 }