package processor import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" ) const ( // voucherProviderTimeout bounds one call to a provider. A call that runs out is an // unknown outcome, settled later by the recovery job. voucherProviderTimeout = 10 * time.Second // voucherRecoveryAfter is how long a PENDING redemption waits before the recovery // job asks its provider again. voucherRecoveryAfter = 2 * time.Minute // voucherMaxAttempts is how many calls a redemption gets before the job gives up, // refunds it and marks it FAILED. voucherMaxAttempts = 10 // voucherRecoveryBatch is how many PENDING redemptions one run of the job claims. voucherRecoveryBatch = 50 ) // issueExternal asks an EXTERNAL voucher's provider for the voucher of a PENDING // redemption, outside any transaction, and settles the redemption with the answer // (docs/rfc-enakgame.md §7.5). With lookup, it first asks whether the provider already // issued it, and only asks to issue when the provider never got the request. // // The call does not stop when the customer's request does: an answer the provider // gave must not be thrown away. func (p *VoucherRedemptionProcessor) issueExternal(ctx context.Context, r *entities.VoucherRedemption, v *entities.Voucher, lookup bool) (*entities.VoucherRedemption, error) { provider, ok := p.providerOf(v) var issue *VoucherIssue var err error if ok { req := VoucherIssueRequest{RedemptionID: r.ID, ProviderRef: v.ProviderRef, VoucherID: v.ID, FaceValue: r.FaceValue, CustomerID: r.CustomerID} callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), voucherProviderTimeout) if lookup { issue, err = provider.Lookup(callCtx, req) } if !lookup || classifyVoucherIssue(issue, err) == voucherNotFound { issue, err = provider.Issue(callCtx, req) } cancel() } else { err = fmt.Errorf("no adapter for provider %q", providerName(v)) } outcome := classifyVoucherIssue(issue, err) if outcome == voucherUnknown || outcome == voucherNotFound { logger.FromContext(ctx).WithError(err).Warn("Voucher provider gave no answer; the redemption stays PENDING") } return p.settleExternal(ctx, r.ID, outcome, issue, err) } // settleExternal applies a provider's answer to a redemption that is still PENDING, // under its customer's wallet lock: COMPLETED with the voucher and its cost split, or // FAILED with its EnakPoint refunded. An unknown answer leaves it PENDING, until it // has had voucherMaxAttempts calls; then it is refunded too. A redemption no longer // PENDING is left as it is, so settling twice refunds nothing twice. func (p *VoucherRedemptionProcessor) settleExternal(ctx context.Context, id uuid.UUID, outcome voucherIssueOutcome, issue *VoucherIssue, callErr error) (*entities.VoucherRedemption, error) { var settled *entities.VoucherRedemption err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { r, err := p.redemptions.GetRedemption(ctx, id) if err != nil { return err } if err := p.wallet.LockWallet(ctx, r.CustomerID); err != nil { return err } if r, err = p.redemptions.GetRedemption(ctx, id); err != nil { return err } settled = r if r.Status != constants.VoucherRedemptionPending { return nil } now := p.now() switch outcome { case voucherIssued: code, ref := issue.Code, issue.Ref var refPtr *string if ref != "" { refPtr = &ref } moved, err := p.redemptions.MarkCompleted(ctx, r.ID, &code, refPtr, now) if err != nil || !moved { return err } r.Status, r.ExternalCode, r.ExternalRef, r.CompletedAt = constants.VoucherRedemptionCompleted, &code, refPtr, &now return p.recordCost(ctx, r) case voucherRejected: return p.refundExternal(ctx, r, "the provider refused: "+callErr.Error()) } attempts, err := p.redemptions.TouchPending(ctx, r.ID) if err != nil { return err } r.Attempts = attempts if attempts >= voucherMaxAttempts { return p.refundExternal(ctx, r, fmt.Sprintf("the provider did not confirm the voucher after %d attempts", attempts)) } return nil }) if err != nil { return nil, err } return settled, nil } // refundExternal gives back the EnakPoint of a redemption that will not complete and // marks it FAILED (§7.5). The refund keeps the expiry of the lots the debit drew from, // with at least seven days left (§6.3). func (p *VoucherRedemptionProcessor) refundExternal(ctx context.Context, r *entities.VoucherRedemption, reason string) error { lots, err := p.wallet.RefundLots(ctx, r.DebitTransactionID) if err != nil { return err } debitID := r.DebitTransactionID refund, err := p.wallet.Credit(ctx, WalletCreditInput{ WalletEntry: WalletEntry{ CustomerID: r.CustomerID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeemRefund, Amount: r.PointCost, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: r.ID, ReversesTransactionID: &debitID, Description: "Pengembalian EnakPoint voucher", Metadata: entities.Metadata{"voucher_id": r.VoucherID.String(), "reason": reason}, IdempotencyKey: "redeem-refund:" + r.ID.String(), }, Lots: lots, }) if err != nil { return err } moved, err := p.redemptions.MarkFailed(ctx, r.ID, refund.Transaction.ID, reason) if err != nil { return err } if !moved { return errors.New("voucher redemption left PENDING during its refund") } r.Status, r.RefundTransactionID, r.FailureReason = constants.VoucherRedemptionFailed, &refund.Transaction.ID, &reason return nil } // RecoverPending is the recovery job (§7.5 step 4): it asks the provider again about // every redemption PENDING for longer than voucherRecoveryAfter, and settles it. It // returns how many it completed and how many it failed. func (p *VoucherRedemptionProcessor) RecoverPending(ctx context.Context) (completed, failed int, err error) { claimed, err := p.redemptions.ClaimStalePending(ctx, p.now().Add(-voucherRecoveryAfter), voucherRecoveryBatch) if err != nil { return 0, 0, err } var failures []error for i := range claimed { r := &claimed[i] v, err := p.vouchers.GetVoucher(ctx, r.OrganizationID, r.VoucherID) if err == nil { r, err = p.issueExternal(ctx, r, v, true) } if err != nil { failures = append(failures, fmt.Errorf("redemption %s: %w", claimed[i].ID, err)) continue } switch r.Status { case constants.VoucherRedemptionCompleted: completed++ case constants.VoucherRedemptionFailed: failed++ } } return completed, failed, errors.Join(failures...) } func (p *VoucherRedemptionProcessor) providerOf(v *entities.Voucher) (VoucherProvider, bool) { if v.Provider == nil { return nil, false } provider, ok := p.providers[*v.Provider] return provider, ok } func providerName(v *entities.Voucher) string { if v.Provider == nil { return "" } return *v.Provider }