415 lines
13 KiB
Go
415 lines
13 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"gorm.io/gorm"
|
|
paymentapi "stream.api/internal/api/payment"
|
|
"stream.api/internal/database/model"
|
|
"stream.api/internal/database/query"
|
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
)
|
|
|
|
func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
planID := strings.TrimSpace(req.GetPlanId())
|
|
if planID == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "Plan ID is required")
|
|
}
|
|
if !isAllowedTermMonths(req.GetTermMonths()) {
|
|
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
|
|
}
|
|
|
|
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
|
|
if paymentMethod == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
totalAmount := planRecord.Price * float64(req.GetTermMonths())
|
|
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: result.UserID,
|
|
PlanID: &planRecord.ID,
|
|
Amount: totalAmount,
|
|
Currency: ¤cy,
|
|
Status: &statusValue,
|
|
Provider: &provider,
|
|
TransactionID: &transactionID,
|
|
}
|
|
|
|
invoiceID := buildInvoiceID(paymentRecord.ID)
|
|
var subscription *model.PlanSubscription
|
|
var walletBalance float64
|
|
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
|
|
return err
|
|
}
|
|
|
|
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, result.UserID)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
|
|
baseExpiry := now
|
|
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
|
|
baseExpiry = currentSubscription.ExpiresAt.UTC()
|
|
}
|
|
newExpiry := baseExpiry.AddDate(0, int(req.GetTermMonths()), 0)
|
|
|
|
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, result.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
|
|
|
|
if paymentMethod == paymentMethodWallet && shortfall > 0 {
|
|
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]interface{}{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
})
|
|
}
|
|
|
|
topupAmount := 0.0
|
|
if paymentMethod == paymentMethodTopup {
|
|
if req.TopupAmount == nil {
|
|
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount is required when payment method is topup", map[string]interface{}{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
})
|
|
}
|
|
|
|
topupAmount = maxFloat(req.GetTopupAmount(), 0)
|
|
if topupAmount <= 0 {
|
|
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than 0", map[string]interface{}{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
})
|
|
}
|
|
if topupAmount < shortfall {
|
|
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than or equal to the required shortfall", map[string]interface{}{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
"topup_amount": topupAmount,
|
|
})
|
|
}
|
|
}
|
|
|
|
if err := tx.Create(paymentRecord).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
walletUsedAmount := totalAmount
|
|
|
|
if paymentMethod == paymentMethodTopup {
|
|
topupTransaction := &model.WalletTransaction{
|
|
ID: uuid.New().String(),
|
|
UserID: result.UserID,
|
|
Type: walletTransactionTypeTopup,
|
|
Amount: topupAmount,
|
|
Currency: model.StringPtr(currency),
|
|
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
|
PaymentID: &paymentRecord.ID,
|
|
PlanID: &planRecord.ID,
|
|
TermMonths: int32Ptr(req.GetTermMonths()),
|
|
}
|
|
if err := tx.Create(topupTransaction).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
debitTransaction := &model.WalletTransaction{
|
|
ID: uuid.New().String(),
|
|
UserID: result.UserID,
|
|
Type: walletTransactionTypeSubscriptionDebit,
|
|
Amount: -totalAmount,
|
|
Currency: model.StringPtr(currency),
|
|
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
|
PaymentID: &paymentRecord.ID,
|
|
PlanID: &planRecord.ID,
|
|
TermMonths: int32Ptr(req.GetTermMonths()),
|
|
}
|
|
if err := tx.Create(debitTransaction).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
subscription = &model.PlanSubscription{
|
|
ID: uuid.New().String(),
|
|
UserID: result.UserID,
|
|
PaymentID: paymentRecord.ID,
|
|
PlanID: planRecord.ID,
|
|
TermMonths: req.GetTermMonths(),
|
|
PaymentMethod: paymentMethod,
|
|
WalletAmount: walletUsedAmount,
|
|
TopupAmount: topupAmount,
|
|
StartedAt: now,
|
|
ExpiresAt: newExpiry,
|
|
}
|
|
if err := tx.Create(subscription).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", result.UserID).
|
|
Update("plan_id", planRecord.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
notification := buildSubscriptionNotification(result.UserID, paymentRecord.ID, invoiceID, &planRecord, subscription)
|
|
if err := tx.Create(notification).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
walletBalance, err = model.GetWalletBalance(ctx, tx, result.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if _, ok := status.FromError(err); ok {
|
|
return nil, err
|
|
}
|
|
s.logger.Error("Failed to create payment", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to create payment")
|
|
}
|
|
|
|
return &appv1.CreatePaymentResponse{
|
|
Payment: toProtoPayment(paymentRecord),
|
|
Subscription: toProtoPlanSubscription(subscription),
|
|
WalletBalance: walletBalance,
|
|
InvoiceId: invoiceID,
|
|
Message: "Payment completed successfully",
|
|
}, nil
|
|
}
|
|
func (s *appServices) ListPaymentHistory(ctx context.Context, _ *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rows []paymentRow
|
|
if err := s.db.WithContext(ctx).
|
|
Table("payment AS p").
|
|
Select("p.id, p.amount, p.currency, p.status, p.plan_id, pl.name AS plan_name, ps.term_months, ps.payment_method, ps.expires_at, p.created_at").
|
|
Joins("LEFT JOIN plan AS pl ON pl.id = p.plan_id").
|
|
Joins("LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id").
|
|
Where("p.user_id = ?", result.UserID).
|
|
Order("p.created_at DESC").
|
|
Scan(&rows).Error; err != nil {
|
|
s.logger.Error("Failed to fetch payment history", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
|
|
}
|
|
|
|
items := make([]paymentapi.PaymentHistoryItem, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, paymentapi.PaymentHistoryItem{
|
|
ID: row.ID,
|
|
Amount: row.Amount,
|
|
Currency: normalizeCurrency(row.Currency),
|
|
Status: normalizePaymentStatus(row.Status),
|
|
PlanID: row.PlanID,
|
|
PlanName: row.PlanName,
|
|
InvoiceID: buildInvoiceID(row.ID),
|
|
Kind: paymentKindSubscription,
|
|
TermMonths: row.TermMonths,
|
|
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
|
|
ExpiresAt: row.ExpiresAt,
|
|
CreatedAt: row.CreatedAt,
|
|
})
|
|
}
|
|
|
|
var topups []model.WalletTransaction
|
|
if err := s.db.WithContext(ctx).
|
|
Where("user_id = ? AND type = ? AND payment_id IS NULL", result.UserID, walletTransactionTypeTopup).
|
|
Order("created_at DESC").
|
|
Find(&topups).Error; err != nil {
|
|
s.logger.Error("Failed to fetch wallet topups", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
|
|
}
|
|
|
|
for _, topup := range topups {
|
|
createdAt := topup.CreatedAt
|
|
items = append(items, paymentapi.PaymentHistoryItem{
|
|
ID: topup.ID,
|
|
Amount: topup.Amount,
|
|
Currency: normalizeCurrency(topup.Currency),
|
|
Status: "success",
|
|
InvoiceID: buildInvoiceID(topup.ID),
|
|
Kind: paymentKindWalletTopup,
|
|
CreatedAt: createdAt,
|
|
})
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
left := time.Time{}
|
|
right := time.Time{}
|
|
if items[i].CreatedAt != nil {
|
|
left = *items[i].CreatedAt
|
|
}
|
|
if items[j].CreatedAt != nil {
|
|
right = *items[j].CreatedAt
|
|
}
|
|
return right.After(left)
|
|
})
|
|
|
|
payload := make([]*appv1.PaymentHistoryItem, 0, len(items))
|
|
for _, item := range items {
|
|
copyItem := item
|
|
payload = append(payload, toProtoPaymentHistoryItem(©Item))
|
|
}
|
|
|
|
return &appv1.ListPaymentHistoryResponse{Payments: payload}, nil
|
|
}
|
|
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
amount := req.GetAmount()
|
|
if amount < 1 {
|
|
return nil, status.Error(codes.InvalidArgument, "Amount must be at least 1")
|
|
}
|
|
|
|
transaction := &model.WalletTransaction{
|
|
ID: uuid.New().String(),
|
|
UserID: result.UserID,
|
|
Type: walletTransactionTypeTopup,
|
|
Amount: amount,
|
|
Currency: model.StringPtr("USD"),
|
|
Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", amount)),
|
|
}
|
|
|
|
notification := &model.Notification{
|
|
ID: uuid.New().String(),
|
|
UserID: result.UserID,
|
|
Type: "billing.topup",
|
|
Title: "Wallet credited",
|
|
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
|
|
Metadata: model.StringPtr(mustMarshalJSON(map[string]interface{}{
|
|
"wallet_transaction_id": transaction.ID,
|
|
"invoice_id": buildInvoiceID(transaction.ID),
|
|
})),
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(transaction).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(notification).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
s.logger.Error("Failed to top up wallet", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to top up wallet")
|
|
}
|
|
|
|
balance, err := model.GetWalletBalance(ctx, s.db, result.UserID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to calculate wallet balance", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to top up wallet")
|
|
}
|
|
|
|
return &appv1.TopupWalletResponse{
|
|
WalletTransaction: toProtoWalletTransaction(transaction),
|
|
WalletBalance: balance,
|
|
InvoiceId: buildInvoiceID(transaction.ID),
|
|
}, nil
|
|
}
|
|
func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := strings.TrimSpace(req.GetId())
|
|
if id == "" {
|
|
return nil, status.Error(codes.NotFound, "Invoice not found")
|
|
}
|
|
|
|
paymentRecord, err := query.Payment.WithContext(ctx).
|
|
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(result.UserID)).
|
|
First()
|
|
if err == nil {
|
|
invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord)
|
|
if buildErr != nil {
|
|
s.logger.Error("Failed to build payment invoice", "error", buildErr)
|
|
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
|
}
|
|
return &appv1.DownloadInvoiceResponse{
|
|
Filename: filename,
|
|
ContentType: "text/plain; charset=utf-8",
|
|
Content: invoiceText,
|
|
}, nil
|
|
}
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Error("Failed to load payment invoice", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
|
}
|
|
|
|
var topup model.WalletTransaction
|
|
if err := s.db.WithContext(ctx).
|
|
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, result.UserID, walletTransactionTypeTopup).
|
|
First(&topup).Error; err == nil {
|
|
return &appv1.DownloadInvoiceResponse{
|
|
Filename: buildInvoiceFilename(topup.ID),
|
|
ContentType: "text/plain; charset=utf-8",
|
|
Content: buildTopupInvoice(&topup),
|
|
}, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Error("Failed to load topup invoice", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
|
}
|
|
|
|
return nil, status.Error(codes.NotFound, "Invoice not found")
|
|
}
|