197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
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
|
||
|
|
}
|