package service import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "gorm.io/gorm" "gorm.io/gorm/clause" appv1 "stream.api/internal/api/proto/app/v1" "stream.api/internal/database/model" ) func statusErrorWithBody(ctx context.Context, grpcCode codes.Code, httpCode int, message string, data any) error { body := apiErrorBody{ Code: httpCode, Message: message, Data: data, } encoded, err := json.Marshal(body) if err == nil { _ = grpc.SetTrailer(ctx, metadata.Pairs("x-error-body", string(encoded))) } return status.Error(grpcCode, message) } func (s *appServices) loadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) { var planRecord model.Plan if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Plan not found") } s.logger.Error("Failed to load plan", "error", err) return nil, status.Error(codes.Internal, "Failed to create payment") } if planRecord.IsActive == nil || !*planRecord.IsActive { return nil, status.Error(codes.InvalidArgument, "Plan is not active") } return &planRecord, nil } func (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string) (*model.Plan, error) { var planRecord model.Plan if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "Plan not found") } return nil, status.Error(codes.Internal, "Failed to create payment") } if planRecord.IsActive == nil || !*planRecord.IsActive { return nil, status.Error(codes.InvalidArgument, "Plan is not active") } return &planRecord, nil } func (s *appServices) loadPaymentUserForAdmin(ctx context.Context, userID string) (*model.User, error) { var user model.User if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } return nil, status.Error(codes.Internal, "Failed to create payment") } return &user, nil } func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecutionInput) (*paymentExecutionResult, error) { totalAmount := input.Plan.Price * float64(input.TermMonths) if totalAmount < 0 { return nil, status.Error(codes.InvalidArgument, "Amount must be greater than or equal to 0") } statusValue := "SUCCESS" provider := "INTERNAL" currency := normalizeCurrency(nil) transactionID := buildTransactionID("sub") now := time.Now().UTC() paymentRecord := &model.Payment{ ID: uuid.New().String(), UserID: input.UserID, PlanID: &input.Plan.ID, Amount: totalAmount, Currency: ¤cy, Status: &statusValue, Provider: &provider, TransactionID: &transactionID, } invoiceID := buildInvoiceID(paymentRecord.ID) result := &paymentExecutionResult{ Payment: paymentRecord, InvoiceID: invoiceID, } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if _, err := lockUserForUpdate(ctx, tx, input.UserID); err != nil { return err } newExpiry, err := loadPaymentExpiry(ctx, tx, input.UserID, input.TermMonths, now) if err != nil { return err } currentWalletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID) if err != nil { return err } validatedTopupAmount, err := validatePaymentFunding(ctx, input, totalAmount, currentWalletBalance) if err != nil { return err } if err := tx.Create(paymentRecord).Error; err != nil { return err } if err := createPaymentWalletTransactions(tx, input, paymentRecord, totalAmount, validatedTopupAmount, currency); err != nil { return err } subscription := buildPaymentSubscription(input, paymentRecord, totalAmount, validatedTopupAmount, now, newExpiry) if err := tx.Create(subscription).Error; err != nil { return err } if err := tx.Model(&model.User{}).Where("id = ?", input.UserID).Update("plan_id", input.Plan.ID).Error; err != nil { return err } notification := buildSubscriptionNotification(input.UserID, paymentRecord.ID, invoiceID, input.Plan, subscription) if err := tx.Create(notification).Error; err != nil { return err } if _, err := s.maybeGrantReferralReward(ctx, tx, input, paymentRecord, subscription); err != nil { return err } walletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID) if err != nil { return err } result.Subscription = subscription result.WalletBalance = walletBalance return nil }) if err != nil { return nil, err } return result, nil } func loadPaymentExpiry(ctx context.Context, tx *gorm.DB, userID string, termMonths int32, now time.Time) (time.Time, error) { currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return time.Time{}, err } baseExpiry := now if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) { baseExpiry = currentSubscription.ExpiresAt.UTC() } return baseExpiry.AddDate(0, int(termMonths), 0), nil } func validatePaymentFunding(ctx context.Context, input paymentExecutionInput, totalAmount, currentWalletBalance float64) (float64, error) { shortfall := maxFloat(totalAmount-currentWalletBalance, 0) if input.PaymentMethod == paymentMethodWallet && shortfall > 0 { return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]any{ "payment_method": input.PaymentMethod, "wallet_balance": currentWalletBalance, "total_amount": totalAmount, "shortfall": shortfall, }) } if input.PaymentMethod != paymentMethodTopup { return 0, nil } if input.TopupAmount == nil { return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount is required when payment method is topup", map[string]any{ "payment_method": input.PaymentMethod, "wallet_balance": currentWalletBalance, "total_amount": totalAmount, "shortfall": shortfall, }) } topupAmount := maxFloat(*input.TopupAmount, 0) if topupAmount <= 0 { return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than 0", map[string]any{ "payment_method": input.PaymentMethod, "wallet_balance": currentWalletBalance, "total_amount": totalAmount, "shortfall": shortfall, }) } if topupAmount < shortfall { return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than or equal to the required shortfall", map[string]any{ "payment_method": input.PaymentMethod, "wallet_balance": currentWalletBalance, "total_amount": totalAmount, "shortfall": shortfall, "topup_amount": topupAmount, }) } return topupAmount, nil } func createPaymentWalletTransactions(tx *gorm.DB, input paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error { if input.PaymentMethod == paymentMethodTopup { topupTransaction := &model.WalletTransaction{ ID: uuid.New().String(), UserID: input.UserID, Type: walletTransactionTypeTopup, Amount: topupAmount, Currency: model.StringPtr(currency), Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", input.Plan.Name, input.TermMonths)), PaymentID: &paymentRecord.ID, PlanID: &input.Plan.ID, TermMonths: int32Ptr(input.TermMonths), } if err := tx.Create(topupTransaction).Error; err != nil { return err } } debitTransaction := &model.WalletTransaction{ ID: uuid.New().String(), UserID: input.UserID, Type: walletTransactionTypeSubscriptionDebit, Amount: -totalAmount, Currency: model.StringPtr(currency), Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", input.Plan.Name, input.TermMonths)), PaymentID: &paymentRecord.ID, PlanID: &input.Plan.ID, TermMonths: int32Ptr(input.TermMonths), } return tx.Create(debitTransaction).Error } func buildPaymentSubscription(input paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, now, newExpiry time.Time) *model.PlanSubscription { return &model.PlanSubscription{ ID: uuid.New().String(), UserID: input.UserID, PaymentID: paymentRecord.ID, PlanID: input.Plan.ID, TermMonths: input.TermMonths, PaymentMethod: input.PaymentMethod, WalletAmount: totalAmount, TopupAmount: topupAmount, StartedAt: now, ExpiresAt: newExpiry, } } func buildSubscriptionNotification(userID, paymentID, invoiceID string, planRecord *model.Plan, subscription *model.PlanSubscription) *model.Notification { return &model.Notification{ ID: uuid.New().String(), UserID: userID, Type: "billing.subscription", Title: "Subscription activated", Message: fmt.Sprintf("Your subscription to %s is active until %s.", planRecord.Name, subscription.ExpiresAt.UTC().Format("2006-01-02")), Metadata: model.StringPtr(mustMarshalJSON(map[string]any{ "payment_id": paymentID, "invoice_id": invoiceID, "plan_id": planRecord.ID, "term_months": subscription.TermMonths, "payment_method": subscription.PaymentMethod, "wallet_amount": subscription.WalletAmount, "topup_amount": subscription.TopupAmount, "plan_expires_at": subscription.ExpiresAt.UTC().Format(time.RFC3339), })), } } func (s *appServices) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) { details, err := s.loadPaymentInvoiceDetails(ctx, paymentRecord) if err != nil { return "", "", err } createdAt := formatOptionalTimestamp(paymentRecord.CreatedAt) lines := []string{ "Stream API Invoice", fmt.Sprintf("Invoice ID: %s", buildInvoiceID(paymentRecord.ID)), fmt.Sprintf("Payment ID: %s", paymentRecord.ID), fmt.Sprintf("User ID: %s", paymentRecord.UserID), fmt.Sprintf("Plan: %s", details.PlanName), fmt.Sprintf("Amount: %.2f %s", paymentRecord.Amount, normalizeCurrency(paymentRecord.Currency)), fmt.Sprintf("Status: %s", strings.ToUpper(normalizePaymentStatus(paymentRecord.Status))), fmt.Sprintf("Provider: %s", strings.ToUpper(stringValue(paymentRecord.Provider))), fmt.Sprintf("Payment Method: %s", strings.ToUpper(details.PaymentMethod)), fmt.Sprintf("Transaction ID: %s", stringValue(paymentRecord.TransactionID)), } if details.TermMonths != nil { lines = append(lines, fmt.Sprintf("Term: %d month(s)", *details.TermMonths)) } if details.ExpiresAt != nil { lines = append(lines, fmt.Sprintf("Valid Until: %s", details.ExpiresAt.UTC().Format(time.RFC3339))) } if details.WalletAmount > 0 { lines = append(lines, fmt.Sprintf("Wallet Applied: %.2f %s", details.WalletAmount, normalizeCurrency(paymentRecord.Currency))) } if details.TopupAmount > 0 { lines = append(lines, fmt.Sprintf("Top-up Added: %.2f %s", details.TopupAmount, normalizeCurrency(paymentRecord.Currency))) } lines = append(lines, fmt.Sprintf("Created At: %s", createdAt)) return strings.Join(lines, "\n"), buildInvoiceFilename(paymentRecord.ID), nil } func buildTopupInvoice(transaction *model.WalletTransaction) string { createdAt := formatOptionalTimestamp(transaction.CreatedAt) return strings.Join([]string{ "Stream API Wallet Top-up Invoice", fmt.Sprintf("Invoice ID: %s", buildInvoiceID(transaction.ID)), fmt.Sprintf("Wallet Transaction ID: %s", transaction.ID), fmt.Sprintf("User ID: %s", transaction.UserID), fmt.Sprintf("Amount: %.2f %s", transaction.Amount, normalizeCurrency(transaction.Currency)), "Status: SUCCESS", fmt.Sprintf("Type: %s", strings.ToUpper(transaction.Type)), fmt.Sprintf("Note: %s", model.StringValue(transaction.Note)), fmt.Sprintf("Created At: %s", createdAt), }, "\n") } func (s *appServices) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) { details := &paymentInvoiceDetails{ PlanName: "Unknown plan", PaymentMethod: paymentMethodWallet, } if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" { var planRecord model.Plan if err := s.db.WithContext(ctx).Where("id = ?", *paymentRecord.PlanID).First(&planRecord).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } else { details.PlanName = planRecord.Name } } var subscription model.PlanSubscription if err := s.db.WithContext(ctx). Where("payment_id = ?", paymentRecord.ID). Order("created_at DESC"). First(&subscription).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } return details, nil } details.TermMonths = &subscription.TermMonths details.PaymentMethod = normalizePaymentMethod(subscription.PaymentMethod) if details.PaymentMethod == "" { details.PaymentMethod = paymentMethodWallet } details.ExpiresAt = &subscription.ExpiresAt details.WalletAmount = subscription.WalletAmount details.TopupAmount = subscription.TopupAmount return details, nil } func isAllowedTermMonths(value int32) bool { _, ok := allowedTermMonths[value] return ok } func lockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) { if tx.Dialector.Name() == "sqlite" { res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", userID) if res.Error != nil { return nil, res.Error } if res.RowsAffected == 0 { return nil, gorm.ErrRecordNotFound } } var user model.User if err := tx.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", userID). First(&user).Error; err != nil { return nil, err } return &user, nil } func maxFloat(left, right float64) float64 { if left > right { return left } return right } func formatOptionalTimestamp(value *time.Time) string { if value == nil { return "" } return value.UTC().Format(time.RFC3339) } func mustMarshalJSON(value any) string { encoded, err := json.Marshal(value) if err != nil { return "{}" } return string(encoded) } func messageResponse(message string) *appv1.MessageResponse { return &appv1.MessageResponse{Message: message} }