| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "math"
- "strconv"
- "strings"
- "time"
- "entgo.io/ent/dialect/sql"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
- "github.com/Wei-Shaw/sub2api/ent/paymentorder"
- "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
- "github.com/Wei-Shaw/sub2api/internal/payment"
- "github.com/Wei-Shaw/sub2api/internal/payment/provider"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
- )
- // --- Refund Flow ---
- var createPaymentProviderFromInstance = provider.CreateProvider
- // getOrderProviderInstance looks up the provider instance that processed this order.
- // For legacy orders without provider_instance_id, it resolves only when the
- // historical instance is uniquely identifiable from the stored order fields.
- func (s *PaymentService) getOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
- if s == nil || s.entClient == nil || o == nil {
- return nil, nil
- }
- if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
- return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
- }
- instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
- if instIDStr == "" {
- return s.resolveUniqueLegacyOrderProviderInstance(ctx, o)
- }
- instID, err := strconv.ParseInt(instIDStr, 10, 64)
- if err != nil {
- return nil, nil
- }
- return s.entClient.PaymentProviderInstance.Get(ctx, instID)
- }
- // getRefundOrderProviderInstance resolves the provider instance for refund paths.
- // Refunds must be pinned to an explicit historical binding, so legacy
- // "best-effort" provider guessing is intentionally not allowed here.
- func (s *PaymentService) getRefundOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
- if s == nil || s.entClient == nil || o == nil {
- return nil, nil
- }
- if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
- return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
- }
- instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
- if instIDStr == "" {
- return nil, nil
- }
- instID, err := strconv.ParseInt(instIDStr, 10, 64)
- if err != nil {
- return nil, fmt.Errorf("order %d refund provider instance id is invalid: %s", o.ID, instIDStr)
- }
- inst, err := s.entClient.PaymentProviderInstance.Get(ctx, instID)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil, fmt.Errorf("order %d refund provider instance %s is missing", o.ID, instIDStr)
- }
- return nil, err
- }
- return inst, nil
- }
- func (s *PaymentService) resolveUniqueLegacyOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
- paymentType := payment.GetBasePaymentType(strings.TrimSpace(o.PaymentType))
- providerKey := strings.TrimSpace(psStringValue(o.ProviderKey))
- if providerKey != "" {
- instances, err := s.entClient.PaymentProviderInstance.Query().
- Where(paymentproviderinstance.ProviderKeyEQ(providerKey)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
- if len(matched) == 1 {
- return matched[0], nil
- }
- return nil, nil
- }
- if paymentType == "" {
- return nil, nil
- }
- instances, err := s.entClient.PaymentProviderInstance.Query().
- All(ctx)
- if err != nil {
- return nil, err
- }
- matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
- if len(matched) == 1 {
- return matched[0], nil
- }
- return nil, nil
- }
- func psFilterLegacyOrderProviderInstances(orderPaymentType string, instances []*dbent.PaymentProviderInstance) []*dbent.PaymentProviderInstance {
- if len(instances) == 0 {
- return nil
- }
- if strings.TrimSpace(orderPaymentType) == "" {
- return instances
- }
- var matched []*dbent.PaymentProviderInstance
- for _, inst := range instances {
- if psLegacyOrderMatchesInstance(orderPaymentType, inst) {
- matched = append(matched, inst)
- }
- }
- return matched
- }
- func psLegacyOrderMatchesInstance(orderPaymentType string, inst *dbent.PaymentProviderInstance) bool {
- if inst == nil {
- return false
- }
- baseType := payment.GetBasePaymentType(strings.TrimSpace(orderPaymentType))
- instanceProviderKey := strings.TrimSpace(inst.ProviderKey)
- if baseType == "" {
- return false
- }
- if baseType == payment.TypeStripe {
- return instanceProviderKey == payment.TypeStripe
- }
- if instanceProviderKey == payment.TypeStripe {
- return false
- }
- if instanceProviderKey == baseType {
- return true
- }
- return payment.InstanceSupportsType(inst.SupportedTypes, baseType)
- }
- func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reason string) error {
- o, err := s.validateRefundRequest(ctx, oid, uid)
- if err != nil {
- return err
- }
- u, err := s.userRepo.GetByID(ctx, o.UserID)
- if err != nil {
- return fmt.Errorf("get user: %w", err)
- }
- if u.Balance < o.Amount {
- return infraerrors.BadRequest("BALANCE_NOT_ENOUGH", "refund amount exceeds balance")
- }
- nr := strings.TrimSpace(reason)
- now := time.Now()
- by := fmt.Sprintf("%d", uid)
- c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ(payment.OrderTypeBalance)).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx)
- if err != nil {
- return fmt.Errorf("update: %w", err)
- }
- if c == 0 {
- return infraerrors.Conflict("CONFLICT", "order status changed")
- }
- s.writeAuditLog(ctx, oid, "REFUND_REQUESTED", fmt.Sprintf("user:%d", uid), map[string]any{"amount": o.Amount, "reason": nr})
- return nil
- }
- func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int64) (*dbent.PaymentOrder, error) {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- if o.UserID != uid {
- return nil, infraerrors.Forbidden("FORBIDDEN", "no permission")
- }
- if o.OrderType != payment.OrderTypeBalance {
- return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "only balance orders can request refund")
- }
- if o.Status != OrderStatusCompleted {
- return nil, infraerrors.BadRequest("INVALID_STATUS", "only completed orders can request refund")
- }
- // Check provider instance allows user refund
- inst, err := s.getRefundOrderProviderInstance(ctx, o)
- if err != nil || inst == nil {
- return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "refund is not available for this order")
- }
- if !inst.AllowUserRefund {
- return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "user refund is not enabled for this provider")
- }
- return o, nil
- }
- func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float64, reason string, force, deduct bool) (*RefundPlan, *RefundResult, error) {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed}
- if !psSliceContains(ok, o.Status) {
- return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
- }
- // Check provider instance allows admin refund
- inst, instErr := s.getRefundOrderProviderInstance(ctx, o)
- if instErr != nil {
- slog.Warn("refund: provider instance lookup failed", "orderID", oid, "error", instErr)
- return nil, nil, infraerrors.InternalServer("PROVIDER_LOOKUP_FAILED", "failed to look up payment provider for this order")
- }
- if inst == nil {
- // Legacy order without provider_instance_id — block refund
- return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not available for this order")
- }
- if !inst.RefundEnabled {
- return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not enabled for this provider")
- }
- if math.IsNaN(amt) || math.IsInf(amt, 0) {
- return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
- }
- if amt <= 0 {
- amt = o.Amount
- }
- orderCurrency := PaymentOrderCurrency(o)
- if amt-o.Amount > paymentAmountToleranceForCurrency(orderCurrency) {
- return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
- }
- ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt, orderCurrency)
- rr := strings.TrimSpace(reason)
- if rr == "" && o.RefundRequestReason != nil {
- rr = *o.RefundRequestReason
- }
- if rr == "" {
- rr = fmt.Sprintf("refund order:%d", o.ID)
- }
- p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone}
- if deduct {
- if er := s.prepDeduct(ctx, o, p, force); er != nil {
- return nil, er, nil
- }
- }
- return p, nil, nil
- }
- func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
- if o.OrderType == payment.OrderTypeSubscription {
- p.DeductionType = payment.DeductionTypeSubscription
- if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
- p.SubDaysToDeduct = *o.SubscriptionDays
- sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
- if err == nil && sub != nil {
- p.SubscriptionID = sub.ID
- } else if !force {
- return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
- }
- }
- return nil
- }
- u, err := s.userRepo.GetByID(ctx, o.UserID)
- if err != nil {
- if !force {
- return &RefundResult{Success: false, Warning: "cannot fetch user balance, use force", RequireForce: true}
- }
- return nil
- }
- p.DeductionType = payment.DeductionTypeBalance
- if u.Balance < p.RefundAmount && !force {
- return &RefundResult{Success: false, Warning: "user balance is insufficient for deduction, use force", RequireForce: true}
- }
- p.BalanceToDeduct = math.Max(0, math.Min(p.RefundAmount, u.Balance))
- return nil
- }
- type availableBalanceDeductor interface {
- DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error)
- }
- func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int64, amount float64) (float64, error) {
- repo, ok := s.userRepo.(availableBalanceDeductor)
- if !ok {
- return 0, errors.New("user repository does not support available balance deduction")
- }
- return repo.DeductAvailableBalance(ctx, userID, amount)
- }
- func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
- c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("lock: %w", err)
- }
- if c == 0 {
- return nil, infraerrors.Conflict("CONFLICT", "order status changed")
- }
- if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
- // Skip balance deduction on retry if previous attempt already deducted
- // but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
- if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
- deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
- if err != nil {
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("deduction: %w", err)
- }
- p.BalanceToDeduct = deducted
- } else {
- slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID)
- p.BalanceToDeduct = 0
- }
- }
- if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
- if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
- _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
- if err != nil {
- if errors.Is(err, ErrAdjustWouldExpire) {
- // Deduction would expire the subscription — revoke it entirely
- slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
- if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
- }
- } else {
- // Other errors (DB failure, not found) — abort refund
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("deduct subscription days: %w", err)
- }
- }
- } else {
- slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
- p.SubDaysToDeduct = 0
- }
- }
- resp, err := s.gwRefund(ctx, p)
- if err != nil {
- return s.handleGwFail(ctx, p, err)
- }
- return s.finishRefund(ctx, p, resp)
- }
- func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) (*payment.RefundResponse, error) {
- if p.Order.PaymentTradeNo == "" {
- s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"})
- return &payment.RefundResponse{Status: payment.ProviderStatusSuccess}, nil
- }
- // Use the exact provider instance that created this order, not a random one
- // from the registry. Each instance has its own merchant credentials.
- prov, err := s.getRefundProvider(ctx, p.Order)
- if err != nil {
- return nil, fmt.Errorf("get refund provider: %w", err)
- }
- if err := validateProviderSnapshotMetadata(p.Order, prov.ProviderKey(), providerMerchantIdentityMetadata(prov)); err != nil {
- s.writeAuditLog(ctx, p.Order.ID, "REFUND_PROVIDER_METADATA_MISMATCH", "admin", map[string]any{
- "detail": err.Error(),
- })
- return nil, err
- }
- finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
- resp, err := prov.Refund(ctx, payment.RefundRequest{
- TradeNo: p.Order.PaymentTradeNo,
- OrderID: p.Order.OutTradeNo,
- Amount: formatGatewayRefundAmount(p.GatewayAmount, p.Order),
- Reason: p.Reason,
- })
- finishProviderCall()
- if err != nil {
- if resp != nil && strings.TrimSpace(resp.Status) == payment.ProviderStatusPending {
- return resp, nil
- }
- return nil, err
- }
- if err := validateRefundProviderResponse(resp); err != nil {
- return nil, err
- }
- return resp, nil
- }
- func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string {
- return payment.FormatAmountForCurrency(amount, PaymentOrderCurrency(order))
- }
- func validateRefundProviderResponse(resp *payment.RefundResponse) error {
- if resp == nil {
- return fmt.Errorf("payment refund response missing")
- }
- status := strings.TrimSpace(resp.Status)
- switch status {
- case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded, payment.ProviderStatusPending:
- return nil
- case payment.ProviderStatusFailed:
- return fmt.Errorf("payment refund failed: status %s", status)
- default:
- return fmt.Errorf("payment refund returned unknown status: %s", status)
- }
- }
- func (s *PaymentService) finishRefund(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) {
- if err := validateRefundProviderResponse(resp); err != nil {
- return s.handleGwFail(ctx, p, err)
- }
- switch strings.TrimSpace(resp.Status) {
- case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
- return s.markRefundOk(ctx, p)
- case payment.ProviderStatusPending:
- return s.markRefundPending(ctx, p, resp)
- default:
- return s.handleGwFail(ctx, p, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
- }
- }
- func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) (*RefundResult, error) {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- if o.Status != OrderStatusRefundPending {
- return nil, infraerrors.BadRequest("INVALID_STATUS", "only refund pending orders can be finalized")
- }
- prov, err := s.getRefundProvider(ctx, o)
- if err != nil {
- return nil, fmt.Errorf("get refund provider: %w", err)
- }
- queryProvider, ok := prov.(payment.RefundQueryProvider)
- if !ok {
- return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually")
- }
- pendingDetail := s.latestRefundPendingDetail(ctx, oid)
- finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
- resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{
- TradeNo: o.PaymentTradeNo,
- OrderID: o.OutTradeNo,
- RefundID: pendingDetail.RefundID,
- Amount: formatGatewayRefundAmount(o.RefundAmount, o),
- })
- finishProviderCall()
- if err != nil {
- return nil, fmt.Errorf("query refund: %w", err)
- }
- if err := validateRefundProviderResponse(resp); err != nil {
- return s.finalizeRefundFailed(ctx, o, err)
- }
- plan := s.refundFinalizePlan(o)
- if !pendingDetail.DeductionRollbackOK {
- plan.BalanceToDeduct = 0
- plan.SubDaysToDeduct = 0
- } else if o.OrderType == payment.OrderTypeSubscription {
- if early := s.prepDeduct(ctx, o, plan, true); early != nil {
- return early, nil
- }
- }
- switch strings.TrimSpace(resp.Status) {
- case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
- return s.finalizePendingRefundSuccess(ctx, plan)
- case payment.ProviderStatusPending:
- s.writeAuditLog(ctx, oid, "REFUND_QUERY_PENDING", "admin", map[string]any{"refundID": resp.RefundID})
- return &RefundResult{Success: false, Warning: "gateway refund is still pending confirmation"}, nil
- default:
- return s.finalizeRefundFailed(ctx, o, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
- }
- }
- func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *RefundPlan) (_ *RefundResult, err error) {
- tx, err := s.entClient.Tx(ctx)
- if err != nil {
- return nil, fmt.Errorf("begin refund finalization: %w", err)
- }
- defer func() {
- if err != nil {
- _ = tx.Rollback()
- }
- }()
- txCtx := dbent.NewTxContext(ctx, tx)
- claimed, err := tx.PaymentOrder.Update().
- Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
- SetStatus(OrderStatusRefunding).
- Save(txCtx)
- if err != nil {
- return nil, fmt.Errorf("claim pending refund: %w", err)
- }
- if claimed == 0 {
- return nil, infraerrors.Conflict("CONFLICT", "order status changed")
- }
- if err := s.applyRefundFinalDeduction(txCtx, p); err != nil {
- return nil, err
- }
- result, err := s.markRefundOkTx(txCtx, tx.Client(), p)
- if err != nil {
- return nil, err
- }
- if err = tx.Commit(); err != nil {
- return nil, fmt.Errorf("commit refund finalization: %w", err)
- }
- return result, nil
- }
- func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan {
- refundAmount := o.RefundAmount
- reason := strings.TrimSpace(psStringValue(o.RefundReason))
- if reason == "" {
- reason = fmt.Sprintf("refund order:%d", o.ID)
- }
- return &RefundPlan{
- OrderID: o.ID,
- Order: o,
- RefundAmount: refundAmount,
- GatewayAmount: calculateGatewayRefundAmount(o.Amount, o.PayAmount, refundAmount, PaymentOrderCurrency(o)),
- Reason: reason,
- Force: o.ForceRefund,
- DeductBalance: true,
- DeductionType: payment.DeductionTypeBalance,
- BalanceToDeduct: func() float64 {
- if o.OrderType == payment.OrderTypeBalance {
- return refundAmount
- }
- return 0
- }(),
- }
- }
- func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error {
- if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
- deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
- if err != nil {
- return fmt.Errorf("deduction: %w", err)
- }
- p.BalanceToDeduct = deducted
- }
- if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
- if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct); err != nil {
- if errors.Is(err, ErrAdjustWouldExpire) {
- if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
- return fmt.Errorf("revoke subscription: %w", revokeErr)
- }
- } else {
- return fmt.Errorf("deduct subscription days: %w", err)
- }
- }
- }
- return nil
- }
- func (s *PaymentService) finalizeRefundFailed(ctx context.Context, o *dbent.PaymentOrder, gErr error) (*RefundResult, error) {
- now := time.Now()
- _, _ = s.entClient.PaymentOrder.UpdateOneID(o.ID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
- s.writeAuditLog(ctx, o.ID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
- return &RefundResult{Success: false, Warning: "gateway refund failed: " + psErrMsg(gErr)}, nil
- }
- type refundPendingAuditDetail struct {
- RefundID string `json:"refundID"`
- DeductionRollbackOK bool `json:"deductionRollbackOK"`
- }
- func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail {
- logEntry, err := s.entClient.PaymentAuditLog.Query().
- Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
- Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())).
- First(ctx)
- if err != nil || logEntry == nil {
- return refundPendingAuditDetail{DeductionRollbackOK: true}
- }
- detail := refundPendingAuditDetail{DeductionRollbackOK: true}
- _ = json.Unmarshal([]byte(logEntry.Detail), &detail)
- detail.RefundID = strings.TrimSpace(detail.RefundID)
- return detail
- }
- // getRefundProvider creates a provider using the order's original instance config.
- // Delegates to getOrderProvider which handles instance lookup and fallback.
- func (s *PaymentService) getRefundProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) {
- inst, err := s.getRefundOrderProviderInstance(ctx, o)
- if err != nil {
- return nil, err
- }
- if inst == nil {
- return nil, fmt.Errorf("refund provider instance is unavailable for order %d", o.ID)
- }
- return s.createProviderFromInstance(ctx, inst)
- }
- func (s *PaymentService) handleGwFail(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
- if s.RollbackRefund(ctx, p, gErr) {
- s.restoreStatus(ctx, p)
- s.writeAuditLog(ctx, p.OrderID, "REFUND_GATEWAY_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
- return &RefundResult{Success: false, Warning: "gateway failed: " + psErrMsg(gErr) + ", rolled back"}, nil
- }
- now := time.Now()
- _, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
- s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
- return nil, infraerrors.InternalServer("REFUND_FAILED", psErrMsg(gErr))
- }
- func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
- fs := OrderStatusRefunded
- if p.RefundAmount < p.Order.Amount {
- fs = OrderStatusPartiallyRefunded
- }
- now := time.Now()
- _, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("mark refund: %w", err)
- }
- s.writeAuditLog(ctx, p.OrderID, "REFUND_SUCCESS", "admin", map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
- return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
- }
- func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Client, p *RefundPlan) (*RefundResult, error) {
- fs := OrderStatusRefunded
- if p.RefundAmount < p.Order.Amount {
- fs = OrderStatusPartiallyRefunded
- }
- now := time.Now()
- _, err := client.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("mark refund: %w", err)
- }
- detail, err := json.Marshal(map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
- if err != nil {
- return nil, fmt.Errorf("marshal refund audit: %w", err)
- }
- if _, err := client.PaymentAuditLog.Create().
- SetOrderID(strconv.FormatInt(p.OrderID, 10)).
- SetAction("REFUND_SUCCESS").
- SetDetail(string(detail)).
- SetOperator("admin").
- Save(ctx); err != nil {
- return nil, fmt.Errorf("write refund audit: %w", err)
- }
- return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
- }
- func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) {
- balanceDeducted := p.BalanceToDeduct
- subDaysDeducted := p.SubDaysToDeduct
- rollbackOK := s.RollbackRefund(ctx, p, nil)
- if rollbackOK {
- p.BalanceToDeduct = 0
- p.SubDaysToDeduct = 0
- }
- _, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).
- SetStatus(OrderStatusRefundPending).
- SetRefundAmount(p.RefundAmount).
- SetRefundReason(p.Reason).
- ClearRefundAt().
- SetForceRefund(p.Force).
- ClearFailedAt().
- ClearFailedReason().
- Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("mark refund pending: %w", err)
- }
- detail := map[string]any{
- "refundID": refundResponseID(resp),
- "refundAmount": p.RefundAmount,
- "reason": p.Reason,
- "force": p.Force,
- "balanceDeducted": p.BalanceToDeduct,
- "subDaysDeducted": p.SubDaysToDeduct,
- "balanceRolledBack": balanceDeducted,
- "subDaysRolledBack": subDaysDeducted,
- "deductionRollbackOK": rollbackOK,
- }
- s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail)
- warning := "gateway refund is pending confirmation"
- if !rollbackOK {
- warning += "; refund deduction rollback failed"
- }
- return &RefundResult{Success: false, Warning: warning}, nil
- }
- func refundResponseID(resp *payment.RefundResponse) string {
- if resp == nil {
- return ""
- }
- return strings.TrimSpace(resp.RefundID)
- }
- func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool {
- if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
- if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
- slog.Error("[CRITICAL] rollback failed", "orderID", p.OrderID, "amount", p.BalanceToDeduct, "error", err)
- s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct})
- return false
- }
- }
- if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
- if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
- slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
- s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
- return false
- }
- }
- return true
- }
- func (s *PaymentService) restoreStatus(ctx context.Context, p *RefundPlan) {
- rs := OrderStatusCompleted
- if p.Order.Status == OrderStatusRefundRequested {
- rs = OrderStatusRefundRequested
- }
- _, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(rs).Save(ctx)
- }
|