| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "math"
- "strconv"
- "strings"
- "time"
- "entgo.io/ent/dialect"
- 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/internal/payment"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- // ErrOrderNotFound is returned by HandlePaymentNotification when the webhook
- // references an out_trade_no that does not exist in our DB. Callers (webhook
- // handlers) should treat this as a terminal, non-retryable condition and still
- // respond with a 2xx success to the provider — otherwise the provider will keep
- // retrying forever (e.g. when a foreign environment's webhook endpoint is
- // misconfigured to point at us, or when our orders table has been wiped).
- var ErrOrderNotFound = errors.New("payment order not found")
- const paymentFulfillmentLeaseDuration = 5 * time.Minute
- type paymentFulfillmentLease struct {
- version time.Time
- }
- // --- Payment Notification & Fulfillment ---
- func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error {
- if n.Status != payment.NotificationStatusSuccess {
- return nil
- }
- // Look up order by out_trade_no (the external order ID we sent to the provider)
- order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx)
- if err != nil {
- // Fallback only for true legacy "sub2_N" DB-ID payloads when the
- // current out_trade_no lookup genuinely did not find an order.
- if oid, ok := parseLegacyPaymentOrderID(n.OrderID, err); ok {
- return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk, n.Metadata)
- }
- if dbent.IsNotFound(err) {
- return fmt.Errorf("%w: out_trade_no=%s", ErrOrderNotFound, n.OrderID)
- }
- return fmt.Errorf("lookup order failed for out_trade_no %s: %w", n.OrderID, err)
- }
- return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk, n.Metadata)
- }
- func parseLegacyPaymentOrderID(orderID string, lookupErr error) (int64, bool) {
- if !dbent.IsNotFound(lookupErr) {
- return 0, false
- }
- orderID = strings.TrimSpace(orderID)
- if !strings.HasPrefix(orderID, orderIDPrefix) {
- return 0, false
- }
- trimmed := strings.TrimPrefix(orderID, orderIDPrefix)
- if trimmed == "" || trimmed == orderID {
- return 0, false
- }
- oid, err := strconv.ParseInt(trimmed, 10, 64)
- if err != nil || oid <= 0 {
- return 0, false
- }
- return oid, true
- }
- func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string, metadata map[string]string) error {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- slog.Error("order not found", "orderID", oid)
- return nil
- }
- instanceProviderKey := ""
- if inst, instErr := s.getOrderProviderInstance(ctx, o); instErr == nil && inst != nil {
- instanceProviderKey = inst.ProviderKey
- }
- expectedProviderKey := expectedNotificationProviderKeyForOrder(s.registry, o, instanceProviderKey)
- if expectedProviderKey != "" && strings.TrimSpace(pk) != "" && !strings.EqualFold(expectedProviderKey, strings.TrimSpace(pk)) {
- s.writeAuditLog(ctx, o.ID, "PAYMENT_PROVIDER_MISMATCH", pk, map[string]any{
- "expectedProvider": expectedProviderKey,
- "actualProvider": pk,
- "tradeNo": tradeNo,
- })
- return fmt.Errorf("provider mismatch: expected %s, got %s", expectedProviderKey, pk)
- }
- if err := validateProviderNotificationMetadata(o, pk, metadata); err != nil {
- s.writeAuditLog(ctx, o.ID, "PAYMENT_PROVIDER_METADATA_MISMATCH", pk, map[string]any{
- "detail": err.Error(),
- "tradeNo": tradeNo,
- })
- return err
- }
- if !isValidProviderAmount(paid) {
- s.writeAuditLog(ctx, o.ID, "PAYMENT_INVALID_AMOUNT", pk, map[string]any{
- "expected": o.PayAmount,
- "paid": paid,
- "tradeNo": tradeNo,
- })
- return fmt.Errorf("invalid paid amount from provider: %v", paid)
- }
- if math.Abs(paid-o.PayAmount) > paymentAmountToleranceForCurrency(PaymentOrderCurrency(o)) {
- s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
- return fmt.Errorf("amount mismatch: expected %s, got %s", strconv.FormatFloat(o.PayAmount, 'f', -1, 64), strconv.FormatFloat(paid, 'f', -1, 64))
- }
- return s.toPaid(ctx, o, tradeNo, paid, pk)
- }
- func paymentAmountToleranceForCurrency(currency string) float64 {
- minorUnit := payment.CurrencyMinorUnit(currency)
- if minorUnit <= 2 {
- return amountToleranceCNY
- }
- return math.Pow10(-minorUnit) / 2
- }
- func isValidProviderAmount(amount float64) bool {
- return amount > 0 && !math.IsNaN(amount) && !math.IsInf(amount, 0)
- }
- func validateProviderNotificationMetadata(order *dbent.PaymentOrder, providerKey string, metadata map[string]string) error {
- return validateProviderSnapshotMetadata(order, providerKey, metadata)
- }
- func expectedNotificationProviderKey(registry *payment.Registry, orderPaymentType string, orderProviderKey string, instanceProviderKey string) string {
- if key := strings.TrimSpace(instanceProviderKey); key != "" {
- return key
- }
- if key := strings.TrimSpace(orderProviderKey); key != "" {
- return key
- }
- if registry != nil {
- if key := strings.TrimSpace(registry.GetProviderKey(payment.PaymentType(orderPaymentType))); key != "" {
- return key
- }
- }
- return strings.TrimSpace(orderPaymentType)
- }
- func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, tradeNo string, paid float64, pk string) error {
- previousStatus := o.Status
- now := time.Now()
- grace := now.Add(-paymentGraceMinutes * time.Minute)
- c, err := s.entClient.PaymentOrder.Update().Where(
- paymentorder.IDEQ(o.ID),
- paymentorder.Or(
- paymentorder.StatusEQ(OrderStatusPending),
- paymentorder.StatusEQ(OrderStatusCancelled),
- paymentorder.And(
- paymentorder.StatusEQ(OrderStatusExpired),
- paymentorder.UpdatedAtGTE(grace),
- ),
- ),
- ).SetStatus(OrderStatusPaid).SetPayAmount(paid).SetPaymentTradeNo(tradeNo).SetPaidAt(now).ClearFailedAt().ClearFailedReason().Save(ctx)
- if err != nil {
- return fmt.Errorf("update to PAID: %w", err)
- }
- if c == 0 {
- return s.alreadyProcessed(ctx, o)
- }
- if previousStatus == OrderStatusCancelled || previousStatus == OrderStatusExpired {
- slog.Info("order recovered from webhook payment success",
- "orderID", o.ID,
- "previousStatus", previousStatus,
- "tradeNo", tradeNo,
- "provider", pk,
- )
- s.writeAuditLog(ctx, o.ID, "ORDER_RECOVERED", pk, map[string]any{
- "previous_status": previousStatus,
- "tradeNo": tradeNo,
- "paidAmount": paid,
- "reason": "webhook payment success received after order " + previousStatus,
- })
- }
- s.writeAuditLog(ctx, o.ID, "ORDER_PAID", pk, map[string]any{"tradeNo": tradeNo, "paidAmount": paid})
- return s.executeFulfillment(ctx, o.ID)
- }
- func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentOrder) error {
- cur, err := s.entClient.PaymentOrder.Get(ctx, o.ID)
- if err != nil {
- return nil
- }
- switch cur.Status {
- case OrderStatusCompleted, OrderStatusRefunded:
- return nil
- case OrderStatusFailed, OrderStatusPaid, OrderStatusRecharging:
- return s.executeFulfillment(ctx, o.ID)
- case OrderStatusExpired:
- slog.Warn("webhook payment success for expired order beyond grace period",
- "orderID", o.ID,
- "status", cur.Status,
- "updatedAt", cur.UpdatedAt,
- )
- s.writeAuditLog(ctx, o.ID, "PAYMENT_AFTER_EXPIRY", "system", map[string]any{
- "status": cur.Status,
- "updatedAt": cur.UpdatedAt,
- "reason": "payment arrived after expiry grace period",
- })
- return nil
- default:
- return nil
- }
- }
- func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) error {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return fmt.Errorf("get order: %w", err)
- }
- if o.OrderType == payment.OrderTypeSubscription {
- return s.ExecuteSubscriptionFulfillment(ctx, oid)
- }
- return s.ExecuteBalanceFulfillment(ctx, oid)
- }
- func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int64) error {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- if o.Status == OrderStatusCompleted {
- return nil
- }
- if psIsRefundStatus(o.Status) {
- return infraerrors.BadRequest("INVALID_STATUS", "refund-related order cannot fulfill")
- }
- if o.Status != OrderStatusPaid && o.Status != OrderStatusFailed && o.Status != OrderStatusRecharging {
- return infraerrors.BadRequest("INVALID_STATUS", "order cannot fulfill in status "+o.Status)
- }
- lease, err := s.acquirePaymentFulfillmentLease(ctx, o)
- if err != nil {
- return err
- }
- if lease == nil {
- return nil
- }
- if err := s.doBalance(ctx, o, lease); err != nil {
- s.markFailed(ctx, oid, lease, err)
- return err
- }
- return nil
- }
- func (s *PaymentService) acquirePaymentFulfillmentLease(ctx context.Context, o *dbent.PaymentOrder) (*paymentFulfillmentLease, error) {
- if o == nil {
- return nil, infraerrors.BadRequest("INVALID_STATUS", "nil payment order")
- }
- now := time.Now().UTC().Truncate(time.Microsecond)
- staleBefore := now.Add(-paymentFulfillmentLeaseDuration)
- updated, err := s.entClient.PaymentOrder.Update().
- Where(
- paymentorder.IDEQ(o.ID),
- paymentorder.Or(
- paymentorder.StatusIn(OrderStatusPaid, OrderStatusFailed),
- paymentorder.And(
- paymentorder.StatusEQ(OrderStatusRecharging),
- paymentorder.UpdatedAtLTE(staleBefore),
- ),
- ),
- ).
- SetStatus(OrderStatusRecharging).
- SetUpdatedAt(now).
- ClearFailedAt().
- ClearFailedReason().
- Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("acquire fulfillment lease: %w", err)
- }
- if updated == 0 {
- current, getErr := s.entClient.PaymentOrder.Get(ctx, o.ID)
- if getErr != nil {
- return nil, fmt.Errorf("reload fulfillment lease: %w", getErr)
- }
- if current.Status == OrderStatusCompleted {
- return nil, nil
- }
- if current.Status == OrderStatusRecharging {
- return nil, infraerrors.Conflict("CONFLICT", "order is being processed")
- }
- return nil, infraerrors.Conflict("CONFLICT", "order status changed while acquiring fulfillment lease")
- }
- // Reload the persisted timestamp instead of trusting application clock precision.
- claimed, err := s.entClient.PaymentOrder.Get(ctx, o.ID)
- if err != nil {
- return nil, fmt.Errorf("reload acquired fulfillment lease: %w", err)
- }
- if claimed.Status != OrderStatusRecharging {
- return nil, infraerrors.Conflict("CONFLICT", "fulfillment lease was lost")
- }
- return &paymentFulfillmentLease{version: claimed.UpdatedAt}, nil
- }
- // redeemAction represents the idempotency decision for balance fulfillment.
- type redeemAction int
- const (
- // redeemActionCreate: code does not exist — create it, then redeem.
- redeemActionCreate redeemAction = iota
- // redeemActionRedeem: code exists but is unused — skip creation, redeem only.
- redeemActionRedeem
- // redeemActionSkipCompleted: code exists and is already used — skip to mark completed.
- redeemActionSkipCompleted
- )
- // resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
- // existing is the result of GetByCode; lookupErr is the error from that call.
- func resolveRedeemAction(existing *RedeemCode, lookupErr error) (redeemAction, error) {
- if lookupErr != nil {
- if errors.Is(lookupErr, ErrRedeemCodeNotFound) {
- return redeemActionCreate, nil
- }
- return redeemActionCreate, fmt.Errorf("lookup payment redeem code: %w", lookupErr)
- }
- if existing == nil {
- return redeemActionCreate, nil
- }
- if existing.IsUsed() {
- return redeemActionSkipCompleted, nil
- }
- return redeemActionRedeem, nil
- }
- func validatePaymentRedeemCode(o *dbent.PaymentOrder, code *RedeemCode) error {
- if o == nil || code == nil {
- return errors.New("payment redeem code validation requires an order and code")
- }
- if code.Code != o.RechargeCode {
- return fmt.Errorf("payment redeem code mismatch for order %d", o.ID)
- }
- if code.Type != RedeemTypeBalance {
- return fmt.Errorf("payment redeem code type mismatch for order %d: got %s", o.ID, code.Type)
- }
- if math.IsNaN(code.Value) || math.IsInf(code.Value, 0) || math.Abs(code.Value-o.Amount) > 1e-8 {
- return fmt.Errorf("payment redeem code amount mismatch for order %d: expected %.8f, got %.8f", o.ID, o.Amount, code.Value)
- }
- switch code.Status {
- case StatusUnused:
- if code.UsedBy != nil {
- return fmt.Errorf("unused payment redeem code has a user for order %d", o.ID)
- }
- case StatusUsed:
- if code.UsedBy == nil || *code.UsedBy != o.UserID {
- return fmt.Errorf("payment redeem code user mismatch for order %d", o.ID)
- }
- default:
- return fmt.Errorf("payment redeem code has invalid status for order %d: %s", o.ID, code.Status)
- }
- return nil
- }
- func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, lease *paymentFulfillmentLease) error {
- // Idempotency: check if redeem code already exists (from a previous partial run)
- existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode)
- action, err := resolveRedeemAction(existing, lookupErr)
- if err != nil {
- return err
- }
- if existing != nil {
- if err := validatePaymentRedeemCode(o, existing); err != nil {
- return err
- }
- }
- switch action {
- case redeemActionSkipCompleted:
- if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
- return err
- }
- // Code already created and redeemed — just mark completed
- return s.markCompleted(ctx, o, lease, "RECHARGE_SUCCESS")
- case redeemActionCreate:
- rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
- if err := s.redeemService.CreateCode(ctx, rc); err != nil {
- return fmt.Errorf("create redeem code: %w", err)
- }
- case redeemActionRedeem:
- // Code exists but unused — skip creation, proceed to redeem
- }
- if _, err := s.redeemService.redeemForPaymentFulfillment(ctx, o.UserID, o.RechargeCode); err != nil {
- return fmt.Errorf("redeem balance: %w", err)
- }
- if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
- return err
- }
- return s.markCompleted(ctx, o, lease, "RECHARGE_SUCCESS")
- }
- func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrder, lease *paymentFulfillmentLease, auditAction string) error {
- if lease == nil {
- return errors.New("missing payment fulfillment lease")
- }
- now := time.Now()
- updated, err := s.entClient.PaymentOrder.Update().Where(
- paymentorder.IDEQ(o.ID),
- paymentorder.StatusEQ(OrderStatusRecharging),
- paymentorder.UpdatedAtEQ(lease.version),
- ).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
- if err != nil {
- return fmt.Errorf("mark completed: %w", err)
- }
- if updated == 0 {
- current, getErr := s.entClient.PaymentOrder.Get(ctx, o.ID)
- if getErr == nil && current.Status == OrderStatusCompleted {
- return nil
- }
- return infraerrors.Conflict("CONFLICT", "fulfillment lease was lost before completion")
- }
- if !s.hasAuditLog(ctx, o.ID, auditAction) {
- s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{
- "rechargeCode": o.RechargeCode,
- "creditedAmount": o.Amount,
- "payAmount": o.PayAmount,
- })
- s.dispatchPaymentFulfillmentNotification(o, auditAction)
- }
- return nil
- }
- func (s *PaymentService) dispatchPaymentFulfillmentNotification(o *dbent.PaymentOrder, auditAction string) {
- if s == nil || s.notificationEmailService == nil || o == nil {
- return
- }
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
- defer cancel()
- var err error
- switch auditAction {
- case "RECHARGE_SUCCESS":
- err = s.sendBalanceRechargeSuccessNotification(ctx, o)
- case "SUBSCRIPTION_SUCCESS":
- err = s.sendSubscriptionPurchaseSuccessNotification(ctx, o)
- default:
- return
- }
- if err != nil {
- slog.Warn("payment fulfillment notification email failed", "order_id", o.ID, "action", auditAction, "err", err.Error())
- }
- }()
- }
- func (s *PaymentService) sendBalanceRechargeSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error {
- currentBalance := ""
- if s.userRepo != nil {
- if user, err := s.userRepo.GetByID(ctx, o.UserID); err == nil && user != nil {
- currentBalance = fmt.Sprintf("%.2f", user.Balance)
- }
- }
- return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
- Event: NotificationEmailEventBalanceRechargeSuccess,
- RecipientEmail: o.UserEmail,
- RecipientName: firstNonEmpty(o.UserName, o.UserEmail),
- UserID: o.UserID,
- SourceType: "payment_order",
- SourceID: strconv.FormatInt(o.ID, 10),
- Variables: map[string]string{
- "recharge_amount": fmt.Sprintf("%.2f", o.Amount),
- "current_balance": currentBalance,
- "order_id": strconv.FormatInt(o.ID, 10),
- },
- })
- }
- func (s *PaymentService) sendSubscriptionPurchaseSuccessNotification(ctx context.Context, o *dbent.PaymentOrder) error {
- variables := map[string]string{
- "subscription_group": "Subscription",
- "subscription_days": "",
- "expiry_time": "",
- "order_id": strconv.FormatInt(o.ID, 10),
- }
- if o.SubscriptionDays != nil {
- variables["subscription_days"] = strconv.Itoa(*o.SubscriptionDays)
- }
- if o.SubscriptionGroupID != nil {
- if s.groupRepo != nil {
- if group, err := s.groupRepo.GetByID(ctx, *o.SubscriptionGroupID); err == nil && group != nil && strings.TrimSpace(group.Name) != "" {
- variables["subscription_group"] = group.Name
- }
- }
- if s.subscriptionSvc != nil {
- if sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID); err == nil && sub != nil {
- variables["expiry_time"] = sub.ExpiresAt.Format("2006-01-02 15:04")
- }
- }
- }
- return s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
- Event: NotificationEmailEventSubscriptionPurchaseSuccess,
- RecipientEmail: o.UserEmail,
- RecipientName: firstNonEmpty(o.UserName, o.UserEmail),
- UserID: o.UserID,
- SourceType: "payment_order",
- SourceID: strconv.FormatInt(o.ID, 10),
- Variables: variables,
- })
- }
- func (s *PaymentService) ExecuteSubscriptionFulfillment(ctx context.Context, oid int64) error {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- if o.Status == OrderStatusCompleted {
- return nil
- }
- if psIsRefundStatus(o.Status) {
- return infraerrors.BadRequest("INVALID_STATUS", "refund-related order cannot fulfill")
- }
- if o.Status != OrderStatusPaid && o.Status != OrderStatusFailed && o.Status != OrderStatusRecharging {
- return infraerrors.BadRequest("INVALID_STATUS", "order cannot fulfill in status "+o.Status)
- }
- if o.SubscriptionGroupID == nil || o.SubscriptionDays == nil {
- return infraerrors.BadRequest("INVALID_STATUS", "missing subscription info")
- }
- lease, err := s.acquirePaymentFulfillmentLease(ctx, o)
- if err != nil {
- return err
- }
- if lease == nil {
- return nil
- }
- if err := s.doSub(ctx, o, lease); err != nil {
- s.markFailed(ctx, oid, lease, err)
- return err
- }
- return nil
- }
- func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder, lease *paymentFulfillmentLease) error {
- gid := *o.SubscriptionGroupID
- days := *o.SubscriptionDays
- g, err := s.groupRepo.GetByID(ctx, gid)
- if err != nil || g.Status != payment.EntityStatusActive {
- return fmt.Errorf("group %d no longer exists or inactive", gid)
- }
- if err := s.ensurePaymentSubscriptionAssigned(ctx, o, gid, days); err != nil {
- return err
- }
- if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
- return err
- }
- return s.markCompleted(ctx, o, lease, "SUBSCRIPTION_SUCCESS")
- }
- func (s *PaymentService) ensurePaymentSubscriptionAssigned(ctx context.Context, o *dbent.PaymentOrder, groupID int64, days int) error {
- if s.subscriptionSvc == nil {
- return errors.New("subscription service is unavailable")
- }
- tx, err := s.entClient.Tx(ctx)
- if err != nil {
- return fmt.Errorf("begin subscription fulfillment tx: %w", err)
- }
- defer func() { _ = tx.Rollback() }()
- txCtx := dbent.NewTxContext(ctx, tx)
- txClient := tx.Client()
- alreadyAssigned, err := hasPaymentSubscriptionAssignmentAudit(txCtx, txClient, o.ID)
- if err != nil {
- return fmt.Errorf("check subscription assignment audit: %w", err)
- }
- recoveredFromNote := false
- if !alreadyAssigned {
- orderNote := paymentSubscriptionOrderNote(o.ID)
- existing, lookupErr := s.subscriptionSvc.userSubRepo.GetByUserIDAndGroupID(txCtx, o.UserID, groupID)
- switch {
- case lookupErr == nil && existing != nil && hasPaymentSubscriptionOrderNote(existing.Notes, orderNote):
- recoveredFromNote = true
- case lookupErr != nil && !errors.Is(lookupErr, ErrSubscriptionNotFound):
- return fmt.Errorf("check existing subscription assignment: %w", lookupErr)
- default:
- if _, _, err := s.subscriptionSvc.assignOrExtendSubscription(txCtx, &AssignSubscriptionInput{
- UserID: o.UserID,
- GroupID: groupID,
- ValidityDays: days,
- AssignedBy: 0,
- Notes: orderNote,
- }, true); err != nil {
- return fmt.Errorf("assign subscription: %w", err)
- }
- }
- detail, _ := json.Marshal(map[string]any{
- "groupID": groupID,
- "validityDays": days,
- "recoveredFromNote": recoveredFromNote,
- })
- if _, err := txClient.PaymentAuditLog.Create().
- SetOrderID(strconv.FormatInt(o.ID, 10)).
- SetAction("SUBSCRIPTION_ASSIGNED").
- SetDetail(string(detail)).
- SetOperator("system").
- Save(txCtx); err != nil {
- if dbent.IsConstraintError(err) {
- _ = tx.Rollback()
- claimed, checkErr := hasPaymentSubscriptionAssignmentAudit(ctx, s.entClient, o.ID)
- if checkErr == nil && claimed {
- return s.subscriptionSvc.invalidateSubscriptionCaches(o.UserID, groupID)
- }
- }
- return fmt.Errorf("record subscription assignment audit: %w", err)
- }
- } else {
- slog.Info("subscription already assigned for order, skipping", "orderID", o.ID, "groupID", groupID)
- }
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("commit subscription fulfillment tx: %w", err)
- }
- // Assignment cache invalidation is deferred while this transaction is open,
- // then performed synchronously against the committed subscription.
- if err := s.subscriptionSvc.invalidateSubscriptionCaches(o.UserID, groupID); err != nil {
- return fmt.Errorf("invalidate subscription cache after fulfillment: %w", err)
- }
- return nil
- }
- func hasPaymentSubscriptionAssignmentAudit(ctx context.Context, client *dbent.Client, orderID int64) (bool, error) {
- count, err := client.PaymentAuditLog.Query().
- Where(
- paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10)),
- paymentauditlog.ActionIn("SUBSCRIPTION_ASSIGNED", "SUBSCRIPTION_SUCCESS"),
- ).
- Limit(1).
- Count(ctx)
- return count > 0, err
- }
- func paymentSubscriptionOrderNote(orderID int64) string {
- return fmt.Sprintf("payment order %d", orderID)
- }
- func hasPaymentSubscriptionOrderNote(notes string, orderNote string) bool {
- for _, line := range strings.Split(strings.ReplaceAll(notes, "\r\n", "\n"), "\n") {
- if strings.TrimSpace(line) == orderNote {
- return true
- }
- }
- return false
- }
- func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) bool {
- oid := strconv.FormatInt(orderID, 10)
- c, _ := s.entClient.PaymentAuditLog.Query().
- Where(paymentauditlog.OrderIDEQ(oid), paymentauditlog.ActionEQ(action)).
- Limit(1).Count(ctx)
- return c > 0
- }
- func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) error {
- baseAmount := affiliateRebateBaseAmount(o)
- if o == nil || baseAmount <= 0 {
- return nil
- }
- if s.affiliateService == nil {
- return nil
- }
- tx, err := s.entClient.Tx(ctx)
- if err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": fmt.Sprintf("begin affiliate rebate tx: %v", err),
- })
- return fmt.Errorf("begin affiliate rebate tx: %w", err)
- }
- defer func() { _ = tx.Rollback() }()
- txCtx := dbent.NewTxContext(ctx, tx)
- claimed, err := s.tryClaimAffiliateRebateAudit(txCtx, tx.Client(), o.ID, baseAmount)
- if err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("claim affiliate rebate audit: %w", err)
- }
- if !claimed {
- return nil
- }
- sourceOrderID := o.ID
- rebateAmount, err := s.affiliateService.AccrueInviteRebateForOrder(txCtx, o.UserID, baseAmount, &sourceOrderID)
- if err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("accrue affiliate rebate: %w", err)
- }
- if rebateAmount <= 0 {
- if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_SKIPPED", map[string]any{
- "baseAmount": baseAmount,
- "reason": "no inviter bound or rebate amount <= 0",
- }); err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("update affiliate rebate skipped audit: %w", err)
- }
- if err := tx.Commit(); err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": fmt.Sprintf("commit affiliate rebate tx: %v", err),
- })
- return fmt.Errorf("commit affiliate rebate tx: %w", err)
- }
- return nil
- }
- if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_APPLIED", map[string]any{
- "baseAmount": baseAmount,
- "rebateAmount": rebateAmount,
- }); err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("update affiliate rebate applied audit: %w", err)
- }
- if err := tx.Commit(); err != nil {
- s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
- "error": fmt.Sprintf("commit affiliate rebate tx: %v", err),
- })
- return fmt.Errorf("commit affiliate rebate tx: %w", err)
- }
- return nil
- }
- func affiliateRebateBaseAmount(o *dbent.PaymentOrder) float64 {
- if o == nil {
- return 0
- }
- switch o.OrderType {
- case payment.OrderTypeBalance, payment.OrderTypeSubscription:
- return o.Amount
- default:
- return 0
- }
- }
- func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, baseAmount float64) (bool, error) {
- if client == nil {
- return false, errors.New("nil payment client")
- }
- oid := strconv.FormatInt(orderID, 10)
- detail, _ := json.Marshal(map[string]any{
- "baseAmount": baseAmount,
- "status": "reserved",
- })
- query, args := buildAffiliateRebateAuditClaimQuery(client, oid, string(detail))
- rows, err := client.QueryContext(ctx, query, args...)
- if err != nil {
- return false, err
- }
- defer func() { _ = rows.Close() }()
- if !rows.Next() {
- if err := rows.Err(); err != nil {
- return false, err
- }
- return false, nil
- }
- var claimID int64
- if err := rows.Scan(&claimID); err != nil {
- return false, err
- }
- return true, nil
- }
- func buildAffiliateRebateAuditClaimQuery(client *dbent.Client, orderID, detail string) (string, []any) {
- nowExpr := paymentAuditCurrentTimestampExpr(client)
- if paymentAuditDialect(client) == dialect.Postgres {
- return fmt.Sprintf(`
- INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
- SELECT $1::text, 'AFFILIATE_REBATE_APPLIED', $2::text, 'system', %s
- WHERE NOT EXISTS (
- SELECT 1
- FROM payment_audit_logs
- WHERE order_id = $1::text
- AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
- )
- ON CONFLICT (order_id, action) DO NOTHING
- RETURNING id`, nowExpr), []any{orderID, detail}
- }
- return fmt.Sprintf(`
- INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
- SELECT ?, 'AFFILIATE_REBATE_APPLIED', ?, 'system', %s
- WHERE NOT EXISTS (
- SELECT 1
- FROM payment_audit_logs
- WHERE order_id = ?
- AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED')
- )
- ON CONFLICT (order_id, action) DO NOTHING
- RETURNING id`, nowExpr), []any{orderID, detail, orderID}
- }
- func paymentAuditCurrentTimestampExpr(client *dbent.Client) string {
- if paymentAuditDialect(client) == dialect.Postgres {
- return "NOW()"
- }
- return "CURRENT_TIMESTAMP"
- }
- func paymentAuditDialect(client *dbent.Client) string {
- if client == nil || client.Driver() == nil {
- return ""
- }
- return client.Driver().Dialect()
- }
- func (s *PaymentService) updateClaimedAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, action string, detail map[string]any) error {
- if client == nil {
- return errors.New("nil payment client")
- }
- oid := strconv.FormatInt(orderID, 10)
- detailJSON, _ := json.Marshal(detail)
- updated, err := client.PaymentAuditLog.Update().
- Where(
- paymentauditlog.OrderIDEQ(oid),
- paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED"),
- ).
- SetAction(action).
- SetDetail(string(detailJSON)).
- SetOperator("system").
- Save(ctx)
- if err != nil {
- return err
- }
- if updated == 0 {
- return errors.New("affiliate rebate claim log not found")
- }
- return nil
- }
- func (s *PaymentService) markFailed(ctx context.Context, oid int64, lease *paymentFulfillmentLease, cause error) {
- if lease == nil {
- slog.Error("mark FAILED without fulfillment lease", "orderID", oid)
- return
- }
- now := time.Now()
- r := psErrMsg(cause)
- // The lease version prevents a stale worker from overwriting a newer owner.
- c, e := s.entClient.PaymentOrder.Update().
- Where(
- paymentorder.IDEQ(oid),
- paymentorder.StatusEQ(OrderStatusRecharging),
- paymentorder.UpdatedAtEQ(lease.version),
- ).
- SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
- if e != nil {
- slog.Error("mark FAILED", "orderID", oid, "error", e)
- }
- if c > 0 {
- s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
- }
- }
- func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error {
- o, err := s.entClient.PaymentOrder.Get(ctx, oid)
- if err != nil {
- return infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- if o.PaidAt == nil {
- return infraerrors.BadRequest("INVALID_STATUS", "order is not paid")
- }
- if psIsRefundStatus(o.Status) {
- return infraerrors.BadRequest("INVALID_STATUS", "refund-related order cannot retry")
- }
- if o.Status == OrderStatusCompleted {
- return infraerrors.BadRequest("INVALID_STATUS", "order already completed")
- }
- if o.Status != OrderStatusFailed && o.Status != OrderStatusPaid && o.Status != OrderStatusRecharging {
- return infraerrors.BadRequest("INVALID_STATUS", "only paid, failed, and recoverable recharging orders can retry")
- }
- s.writeAuditLog(ctx, oid, "RECHARGE_RETRY", "admin", map[string]any{"detail": "admin manual retry"})
- return s.executeFulfillment(ctx, oid)
- }
|