package processor import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // ErrVoucherRedemptionRejected wraps every reason a customer cannot redeem a voucher: // the key, the voucher's status or dates, the stock, the per-customer limit, the // EnakPoint balance. The message says which. var ErrVoucherRedemptionRejected = errors.New("voucher redemption refused") // catalogStockModes are the vouchers the catalog lists. An EXTERNAL one is listed only // when its provider has an adapter. var catalogStockModes = []string{constants.VoucherStockStatic, constants.VoucherStockCodePool, constants.VoucherStockExternal} func redemptionRejected(format string, args ...any) error { return fmt.Errorf("%w: %s", ErrVoucherRedemptionRejected, fmt.Sprintf(format, args...)) } // VoucherRedemptionProcessor redeems EnakPoint for vouchers and records what each // redemption cost each budget (docs/rfc-enakgame.md §7.4, §7.6, D5). type VoucherRedemptionProcessor struct { customers gameCustomerReader vouchers repository.VoucherRepository redemptions repository.VoucherRedemptionRepository pins pinVerifier spendable spendableReader wallet *WalletProcessor providers VoucherProviders tx TxRunner now func() time.Time } func NewVoucherRedemptionProcessor(customers gameCustomerReader, vouchers repository.VoucherRepository, redemptions repository.VoucherRedemptionRepository, pins pinVerifier, spendable spendableReader, wallet *WalletProcessor, providers VoucherProviders, tx TxRunner) *VoucherRedemptionProcessor { return &VoucherRedemptionProcessor{ customers: customers, vouchers: vouchers, redemptions: redemptions, pins: pins, spendable: spendable, wallet: wallet, providers: providers, tx: tx, now: time.Now, } } // Redeem takes a voucher's point cost in EnakPoint and hands out the voucher, in one // transaction (§7.4): the stock, the code, the debit, the redemption and its cost // split all commit together or not at all. The PIN approves it (K8). // // An EXTERNAL voucher comes from its provider, which is never called inside a // transaction (§7.5): the redemption is written PENDING with its debit first, the // provider is asked, and its answer completes it, fails and refunds it, or, when // there is no answer, leaves it PENDING for the recovery job. // // idempotencyKey is the client's Idempotency-Key: a retry with the same key returns // the first redemption, with its code, and takes nothing more. func (p *VoucherRedemptionProcessor) Redeem(ctx context.Context, customerID, voucherID uuid.UUID, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.VoucherRedeemResult, error) { key := strings.TrimSpace(idempotencyKey) if key == "" { return nil, redemptionRejected("the Idempotency-Key header is required") } if len(key) > gameSessionKeyLimit { return nil, redemptionRejected("the Idempotency-Key header must be at most %d characters", gameSessionKeyLimit) } customer, err := p.customers.GetCustomer(ctx, customerID) if err != nil { return nil, err } // A retry is answered before anything else is checked: the voucher may have run // out or ended since, but this redemption went through. if previous, err := p.redemptions.GetByKey(ctx, customerID, key); err != nil || previous != nil { if err != nil { return nil, err } return p.replay(ctx, previous, voucherID) } if !customer.IsActive { return nil, redemptionRejected("the customer is not active") } voucher, err := p.vouchers.GetVoucher(ctx, customer.OrganizationID, voucherID) if err != nil { return nil, err } // Refuse what cannot work before the PIN is checked, so it costs no attempt. if err := p.redeemable(voucher, p.now()); err != nil { return nil, err } if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionRedeem, info); err != nil { return nil, err } var redemption *entities.VoucherRedemption var code *entities.VoucherCode replayed := false err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.wallet.LockWallet(ctx, customerID); err != nil { return err } // Under the lock, a request sent twice at once finds the first one here. previous, err := p.redemptions.GetByKey(ctx, customerID, key) if err != nil { return err } if previous != nil { redemption, replayed = previous, true return nil } if voucher, err = p.vouchers.GetVoucher(ctx, customer.OrganizationID, voucherID); err != nil { return err } now := p.now() if err := p.redeemable(voucher, now); err != nil { return err } if voucher.MaxPerCustomer != nil { // Counted under the wallet lock, so the customer's own redemptions at the // same time see each other. count, err := p.redemptions.CountForCustomer(ctx, customerID, voucher.ID) if err != nil { return err } if count >= int64(*voucher.MaxPerCustomer) { return redemptionRejected("this voucher can be redeemed at most %d times per customer", *voucher.MaxPerCustomer) } } redemptionID := uuid.New() switch voucher.StockMode { case constants.VoucherStockStatic: taken, err := p.vouchers.TakeStock(ctx, voucher.ID) if err != nil { return err } if !taken { return redemptionRejected("the voucher is out of stock") } case constants.VoucherStockCodePool: if code, err = p.vouchers.ClaimCode(ctx, voucher.ID, redemptionID, now); err != nil { return err } if code == nil { return redemptionRejected("the voucher is out of stock") } } external := voucher.StockMode == constants.VoucherStockExternal debit, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ CustomerID: customerID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, Amount: voucher.PointCost, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: redemptionID, Description: truncateDescription("Tukar voucher " + voucher.Name), Metadata: entities.Metadata{"voucher_id": voucher.ID.String(), "face_value": voucher.FaceValue}, IdempotencyKey: "redeem:" + redemptionID.String(), }}) if errors.Is(err, repository.ErrWalletInsufficientBalance) { return redemptionRejected("not enough EnakPoint") } if err != nil { return err } redemption = &entities.VoucherRedemption{ ID: redemptionID, OrganizationID: customer.OrganizationID, CustomerID: customerID, VoucherID: voucher.ID, IdempotencyKey: key, Status: constants.VoucherRedemptionCompleted, FaceValue: voucher.FaceValue, PointCost: voucher.PointCost, DebitTransactionID: debit.Transaction.ID, CompletedAt: &now, } if external { // Its cost is split once the provider has issued it. redemption.Status, redemption.CompletedAt = constants.VoucherRedemptionPending, nil } if code != nil { redemption.VoucherCodeID = &code.ID } if err := p.redemptions.CreateRedemption(ctx, redemption); err != nil { return err } if external { return nil } return p.recordCost(ctx, redemption) }) if err != nil { return nil, err } if replayed { return p.replay(ctx, redemption, voucherID) } if redemption.Status == constants.VoucherRedemptionPending { if redemption, err = p.issueExternal(ctx, redemption, voucher, false); err != nil { return nil, err } } return p.result(ctx, redemption, voucher, code, false) } // recordCost splits a completed redemption's face value over where its EnakPoint // came from and freezes the split (§7.6). Only the part from EnakGame rewards names // a budget; the rest is kept for reporting. func (p *VoucherRedemptionProcessor) recordCost(ctx context.Context, redemption *entities.VoucherRedemption) error { sources, err := p.redemptions.PointSources(ctx, redemption.DebitTransactionID) if err != nil { return err } var traced int64 for _, s := range sources { traced += s.Points } if traced != redemption.PointCost { return fmt.Errorf("redemption %s: traced %d EnakPoint back to their source, not %d", redemption.ID, traced, redemption.PointCost) } parts := SplitVoucherCost(redemption.FaceValue, sources) costs := make([]entities.VoucherRedemptionCost, 0, len(parts)) for _, part := range parts { costs = append(costs, entities.VoucherRedemptionCost{ RedemptionID: redemption.ID, BudgetID: part.BudgetID, SourceType: part.SourceType, Points: part.Points, Cost: part.Cost, RecognizedAt: *redemption.CompletedAt, }) } return p.redemptions.CreateCosts(ctx, costs) } // replay answers a retry with the redemption it repeats. func (p *VoucherRedemptionProcessor) replay(ctx context.Context, redemption *entities.VoucherRedemption, voucherID uuid.UUID) (*models.VoucherRedeemResult, error) { if redemption.VoucherID != voucherID { return nil, redemptionRejected("this Idempotency-Key was already used to redeem another voucher") } voucher, err := p.vouchers.GetVoucher(ctx, redemption.OrganizationID, redemption.VoucherID) if err != nil { return nil, err } var code *entities.VoucherCode if redemption.VoucherCodeID != nil { if code, err = p.vouchers.GetCode(ctx, *redemption.VoucherCodeID); err != nil { return nil, err } } return p.result(ctx, redemption, voucher, code, true) } func (p *VoucherRedemptionProcessor) result(ctx context.Context, r *entities.VoucherRedemption, v *entities.Voucher, code *entities.VoucherCode, replayed bool) (*models.VoucherRedeemResult, error) { balances, err := p.spendable.SpendableBalances(ctx, r.CustomerID, p.now()) if err != nil { return nil, err } item := repository.CustomerRedemption{VoucherRedemption: *r, VoucherName: v.Name, VoucherImageURL: v.ImageURL, VoucherType: v.VoucherType, Code: r.ExternalCode} if code != nil { c := code.Code item.Code, item.CodeExpiresAt = &c, code.ExpiresAt } return &models.VoucherRedeemResult{ CustomerVoucherRedemption: customerRedemptionModel(item), PointBalance: balances[constants.WalletCurrencyPoint], Replayed: replayed, }, nil } // Catalog returns the vouchers the customer can redeem now, with how many are left. func (p *VoucherRedemptionProcessor) Catalog(ctx context.Context, customerID uuid.UUID) ([]models.CustomerVoucher, error) { customer, err := p.customers.GetCustomer(ctx, customerID) if err != nil { return nil, err } vouchers, err := p.vouchers.ListCatalog(ctx, customer.OrganizationID, p.now(), catalogStockModes) if err != nil { return nil, err } out := make([]models.CustomerVoucher, 0, len(vouchers)) for _, v := range vouchers { if v.StockMode == constants.VoucherStockExternal { if _, ok := p.providerOf(&v.Voucher); !ok { continue } } out = append(out, models.CustomerVoucher{ ID: v.ID, Name: v.Name, Description: v.Description, ImageURL: v.ImageURL, VoucherType: v.VoucherType, FaceValue: v.FaceValue, PointCost: v.PointCost, MaxPerCustomer: v.MaxPerCustomer, ValidUntil: v.ValidUntil, Terms: []byte(v.Terms), Available: v.Available, }) } return out, nil } // ListRedemptions returns a page of the customer's redemptions, newest first, with // their codes. func (p *VoucherRedemptionProcessor) ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerVoucherRedemption], error) { page, limit = enakGamePage(page, limit) rows, total, err := p.redemptions.ListCustomerRedemptions(ctx, customerID, (page-1)*limit, limit) if err != nil { return nil, err } items := make([]models.CustomerVoucherRedemption, 0, len(rows)) for _, row := range rows { items = append(items, customerRedemptionModel(row)) } return &models.PaginatedResponse[models.CustomerVoucherRedemption]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil } // redeemable says why a voucher cannot be redeemed now, or nil. func (p *VoucherRedemptionProcessor) redeemable(v *entities.Voucher, now time.Time) error { if v.StockMode == constants.VoucherStockExternal { if _, ok := p.providerOf(v); !ok { return redemptionRejected("the voucher is not available yet") } } return redeemableNow(v, now) } func redeemableNow(v *entities.Voucher, now time.Time) error { switch { case v.Status != constants.VoucherStatusActive: return redemptionRejected("the voucher is not available") case v.ValidFrom != nil && now.Before(*v.ValidFrom): return redemptionRejected("the voucher cannot be redeemed yet") case v.ValidUntil != nil && !now.Before(*v.ValidUntil): return redemptionRejected("the voucher has ended") } return nil } func customerRedemptionModel(r repository.CustomerRedemption) models.CustomerVoucherRedemption { return models.CustomerVoucherRedemption{ ID: r.ID, VoucherID: r.VoucherID, VoucherName: r.VoucherName, VoucherImageURL: r.VoucherImageURL, VoucherType: r.VoucherType, Status: r.Status, FaceValue: r.FaceValue, PointCost: r.PointCost, Code: r.Code, CodeExpiresAt: r.CodeExpiresAt, CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, } }