807 lines
24 KiB
Go
807 lines
24 KiB
Go
//go:build ignore
|
|
// +build ignore
|
|
|
|
package payment
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"stream.api/internal/config"
|
|
"stream.api/internal/database/model"
|
|
"stream.api/internal/database/query"
|
|
"stream.api/pkg/logger"
|
|
"stream.api/pkg/response"
|
|
)
|
|
|
|
const (
|
|
walletTransactionTypeTopup = "topup"
|
|
walletTransactionTypeSubscriptionDebit = "subscription_debit"
|
|
paymentMethodWallet = "wallet"
|
|
paymentMethodTopup = "topup"
|
|
paymentKindSubscription = "subscription"
|
|
paymentKindWalletTopup = "wallet_topup"
|
|
)
|
|
|
|
var allowedTermMonths = map[int32]struct{}{
|
|
1: {},
|
|
3: {},
|
|
6: {},
|
|
12: {},
|
|
}
|
|
|
|
type Handler struct {
|
|
logger logger.Logger
|
|
cfg *config.Config
|
|
db *gorm.DB
|
|
}
|
|
|
|
type paymentRow struct {
|
|
ID string `gorm:"column:id"`
|
|
Amount float64 `gorm:"column:amount"`
|
|
Currency *string `gorm:"column:currency"`
|
|
Status *string `gorm:"column:status"`
|
|
PlanID *string `gorm:"column:plan_id"`
|
|
PlanName *string `gorm:"column:plan_name"`
|
|
TermMonths *int32 `gorm:"column:term_months"`
|
|
PaymentMethod *string `gorm:"column:payment_method"`
|
|
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
|
CreatedAt *time.Time `gorm:"column:created_at"`
|
|
}
|
|
|
|
type paymentInvoiceDetails struct {
|
|
PlanName string
|
|
TermMonths *int32
|
|
PaymentMethod string
|
|
ExpiresAt *time.Time
|
|
WalletAmount float64
|
|
TopupAmount float64
|
|
}
|
|
|
|
type paymentError struct {
|
|
Code int
|
|
Message string
|
|
Data interface{}
|
|
}
|
|
|
|
func (e *paymentError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB) PaymentHandler {
|
|
return &Handler{
|
|
logger: l,
|
|
cfg: cfg,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// @Summary Create Payment
|
|
// @Description Create a new payment for buying or renewing a plan
|
|
// @Tags payment
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body CreatePaymentRequest true "Payment Info"
|
|
// @Success 201 {object} response.Response
|
|
// @Failure 400 {object} response.Response
|
|
// @Failure 401 {object} response.Response
|
|
// @Failure 404 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /payments [post]
|
|
// @Security BearerAuth
|
|
func (h *Handler) CreatePayment(c *gin.Context) {
|
|
var req CreatePaymentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
planID := strings.TrimSpace(req.PlanID)
|
|
if planID == "" {
|
|
response.Error(c, http.StatusBadRequest, "Plan ID is required")
|
|
return
|
|
}
|
|
if !isAllowedTermMonths(req.TermMonths) {
|
|
response.Error(c, http.StatusBadRequest, "Term months must be one of 1, 3, 6, or 12")
|
|
return
|
|
}
|
|
|
|
paymentMethod := normalizePaymentMethod(req.PaymentMethod)
|
|
if paymentMethod == "" {
|
|
response.Error(c, http.StatusBadRequest, "Payment method must be wallet or topup")
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
var planRecord model.Plan
|
|
if err := h.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
response.Error(c, http.StatusNotFound, "Plan not found")
|
|
return
|
|
}
|
|
h.logger.Error("Failed to load plan", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to create payment")
|
|
return
|
|
}
|
|
|
|
if planRecord.IsActive == nil || !*planRecord.IsActive {
|
|
response.Error(c, http.StatusBadRequest, "Plan is not active")
|
|
return
|
|
}
|
|
|
|
totalAmount := planRecord.Price * float64(req.TermMonths)
|
|
if totalAmount < 0 {
|
|
response.Error(c, http.StatusBadRequest, "Amount must be greater than or equal to 0")
|
|
return
|
|
}
|
|
|
|
status := "SUCCESS"
|
|
provider := "INTERNAL"
|
|
currency := normalizeCurrency(nil)
|
|
transactionID := buildTransactionID("sub")
|
|
now := time.Now().UTC()
|
|
|
|
payment := &model.Payment{
|
|
ID: uuid.New().String(),
|
|
UserID: userID,
|
|
PlanID: &planRecord.ID,
|
|
Amount: totalAmount,
|
|
Currency: ¤cy,
|
|
Status: &status,
|
|
Provider: &provider,
|
|
TransactionID: &transactionID,
|
|
}
|
|
|
|
invoiceID := buildInvoiceID(payment.ID)
|
|
var subscription *model.PlanSubscription
|
|
var walletBalance float64
|
|
|
|
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if _, err := lockUserForUpdate(ctx, tx, userID); err != nil {
|
|
return err
|
|
}
|
|
|
|
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, 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.TermMonths), 0)
|
|
|
|
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
|
|
|
|
if paymentMethod == paymentMethodWallet && shortfall > 0 {
|
|
return &paymentError{
|
|
Code: http.StatusBadRequest,
|
|
Message: "Insufficient wallet balance",
|
|
Data: gin.H{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
},
|
|
}
|
|
}
|
|
|
|
topupAmount := 0.0
|
|
if paymentMethod == paymentMethodTopup {
|
|
if req.TopupAmount == nil {
|
|
return &paymentError{
|
|
Code: http.StatusBadRequest,
|
|
Message: "Top-up amount is required when payment method is topup",
|
|
Data: gin.H{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
},
|
|
}
|
|
}
|
|
|
|
topupAmount = maxFloat(*req.TopupAmount, 0)
|
|
if topupAmount <= 0 {
|
|
return &paymentError{
|
|
Code: http.StatusBadRequest,
|
|
Message: "Top-up amount must be greater than 0",
|
|
Data: gin.H{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
},
|
|
}
|
|
}
|
|
if topupAmount < shortfall {
|
|
return &paymentError{
|
|
Code: http.StatusBadRequest,
|
|
Message: "Top-up amount must be greater than or equal to the required shortfall",
|
|
Data: gin.H{
|
|
"payment_method": paymentMethod,
|
|
"wallet_balance": currentWalletBalance,
|
|
"total_amount": totalAmount,
|
|
"shortfall": shortfall,
|
|
"topup_amount": topupAmount,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Create(payment).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
walletUsedAmount := totalAmount
|
|
|
|
if paymentMethod == paymentMethodTopup {
|
|
topupTransaction := &model.WalletTransaction{
|
|
ID: uuid.New().String(),
|
|
UserID: userID,
|
|
Type: walletTransactionTypeTopup,
|
|
Amount: topupAmount,
|
|
Currency: model.StringPtr(currency),
|
|
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.TermMonths)),
|
|
PaymentID: &payment.ID,
|
|
PlanID: &planRecord.ID,
|
|
TermMonths: &req.TermMonths,
|
|
}
|
|
if err := tx.Create(topupTransaction).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
debitTransaction := &model.WalletTransaction{
|
|
ID: uuid.New().String(),
|
|
UserID: userID,
|
|
Type: walletTransactionTypeSubscriptionDebit,
|
|
Amount: -totalAmount,
|
|
Currency: model.StringPtr(currency),
|
|
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.TermMonths)),
|
|
PaymentID: &payment.ID,
|
|
PlanID: &planRecord.ID,
|
|
TermMonths: &req.TermMonths,
|
|
}
|
|
if err := tx.Create(debitTransaction).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
subscription = &model.PlanSubscription{
|
|
ID: uuid.New().String(),
|
|
UserID: userID,
|
|
PaymentID: payment.ID,
|
|
PlanID: planRecord.ID,
|
|
TermMonths: req.TermMonths,
|
|
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 = ?", userID).
|
|
Update("plan_id", planRecord.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
notification := buildSubscriptionNotification(userID, payment.ID, invoiceID, &planRecord, subscription)
|
|
if err := tx.Create(notification).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
walletBalance, err = model.GetWalletBalance(ctx, tx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
var paymentErr *paymentError
|
|
if errors.As(err, &paymentErr) {
|
|
c.AbortWithStatusJSON(paymentErr.Code, response.Response{
|
|
Code: paymentErr.Code,
|
|
Message: paymentErr.Message,
|
|
Data: paymentErr.Data,
|
|
})
|
|
return
|
|
}
|
|
|
|
h.logger.Error("Failed to create payment", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to create payment")
|
|
return
|
|
}
|
|
|
|
response.Created(c, gin.H{
|
|
"payment": payment,
|
|
"subscription": subscription,
|
|
"wallet_balance": walletBalance,
|
|
"invoice_id": invoiceID,
|
|
"message": "Payment completed successfully",
|
|
})
|
|
}
|
|
|
|
// @Summary List Payment History
|
|
// @Description Get payment history for the current user
|
|
// @Tags payment
|
|
// @Produce json
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 401 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /payments/history [get]
|
|
// @Security BearerAuth
|
|
func (h *Handler) ListPaymentHistory(c *gin.Context) {
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
var rows []paymentRow
|
|
if err := h.db.WithContext(c.Request.Context()).
|
|
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 = ?", userID).
|
|
Order("p.created_at DESC").
|
|
Scan(&rows).Error; err != nil {
|
|
h.logger.Error("Failed to fetch payment history", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to fetch payment history")
|
|
return
|
|
}
|
|
|
|
items := make([]PaymentHistoryItem, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, 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 := h.db.WithContext(c.Request.Context()).
|
|
Where("user_id = ? AND type = ? AND payment_id IS NULL", userID, walletTransactionTypeTopup).
|
|
Order("created_at DESC").
|
|
Find(&topups).Error; err != nil {
|
|
h.logger.Error("Failed to fetch wallet topups", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to fetch payment history")
|
|
return
|
|
}
|
|
|
|
for _, topup := range topups {
|
|
createdAt := topup.CreatedAt
|
|
items = append(items, PaymentHistoryItem{
|
|
ID: topup.ID,
|
|
Amount: topup.Amount,
|
|
Currency: normalizeCurrency(topup.Currency),
|
|
Status: "success",
|
|
InvoiceID: buildInvoiceID(topup.ID),
|
|
Kind: paymentKindWalletTopup,
|
|
CreatedAt: createdAt,
|
|
})
|
|
}
|
|
|
|
sortPaymentHistory(items)
|
|
response.Success(c, gin.H{"payments": items})
|
|
}
|
|
|
|
// @Summary Top Up Wallet
|
|
// @Description Add funds to wallet balance for the current user
|
|
// @Tags payment
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body TopupWalletRequest true "Topup Info"
|
|
// @Success 201 {object} response.Response
|
|
// @Failure 400 {object} response.Response
|
|
// @Failure 401 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /wallet/topups [post]
|
|
// @Security BearerAuth
|
|
func (h *Handler) TopupWallet(c *gin.Context) {
|
|
var req TopupWalletRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
amount := req.Amount
|
|
if amount < 1 {
|
|
response.Error(c, http.StatusBadRequest, "Amount must be at least 1")
|
|
return
|
|
}
|
|
|
|
transaction := &model.WalletTransaction{
|
|
ID: uuid.New().String(),
|
|
UserID: 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: userID,
|
|
Type: "billing.topup",
|
|
Title: "Wallet credited",
|
|
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
|
|
Metadata: model.StringPtr(mustMarshalJSON(gin.H{
|
|
"wallet_transaction_id": transaction.ID,
|
|
"invoice_id": buildInvoiceID(transaction.ID),
|
|
})),
|
|
}
|
|
|
|
if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
|
if _, err := lockUserForUpdate(c.Request.Context(), tx, 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 {
|
|
h.logger.Error("Failed to top up wallet", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to top up wallet")
|
|
return
|
|
}
|
|
|
|
balance, err := model.GetWalletBalance(c.Request.Context(), h.db, userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to calculate wallet balance", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to top up wallet")
|
|
return
|
|
}
|
|
|
|
response.Created(c, gin.H{
|
|
"wallet_transaction": transaction,
|
|
"wallet_balance": balance,
|
|
"invoice_id": buildInvoiceID(transaction.ID),
|
|
})
|
|
}
|
|
|
|
// @Summary Download Invoice
|
|
// @Description Download invoice text for a payment or wallet top-up
|
|
// @Tags payment
|
|
// @Produce plain
|
|
// @Param id path string true "Payment ID"
|
|
// @Success 200 {string} string
|
|
// @Failure 401 {object} response.Response
|
|
// @Failure 404 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /payments/{id}/invoice [get]
|
|
// @Security BearerAuth
|
|
func (h *Handler) DownloadInvoice(c *gin.Context) {
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
response.Error(c, http.StatusNotFound, "Invoice not found")
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
paymentRecord, err := query.Payment.WithContext(ctx).
|
|
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(userID)).
|
|
First()
|
|
if err == nil {
|
|
invoiceText, filename, buildErr := h.buildPaymentInvoice(ctx, paymentRecord)
|
|
if buildErr != nil {
|
|
h.logger.Error("Failed to build payment invoice", "error", buildErr)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to download invoice")
|
|
return
|
|
}
|
|
serveInvoiceText(c, filename, invoiceText)
|
|
return
|
|
}
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
h.logger.Error("Failed to load payment invoice", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to download invoice")
|
|
return
|
|
}
|
|
|
|
var topup model.WalletTransaction
|
|
if err := h.db.WithContext(ctx).
|
|
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, userID, walletTransactionTypeTopup).
|
|
First(&topup).Error; err == nil {
|
|
invoiceText := buildTopupInvoice(&topup)
|
|
serveInvoiceText(c, buildInvoiceFilename(topup.ID), invoiceText)
|
|
return
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
h.logger.Error("Failed to load topup invoice", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to download invoice")
|
|
return
|
|
}
|
|
|
|
response.Error(c, http.StatusNotFound, "Invoice not found")
|
|
}
|
|
|
|
func normalizePaymentStatus(status *string) string {
|
|
value := strings.ToLower(strings.TrimSpace(stringValue(status)))
|
|
switch value {
|
|
case "success", "succeeded", "paid":
|
|
return "success"
|
|
case "failed", "error", "canceled", "cancelled":
|
|
return "failed"
|
|
case "pending", "processing":
|
|
return "pending"
|
|
default:
|
|
if value == "" {
|
|
return "success"
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
func normalizeCurrency(currency *string) string {
|
|
value := strings.ToUpper(strings.TrimSpace(stringValue(currency)))
|
|
if value == "" {
|
|
return "USD"
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizePaymentMethod(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case paymentMethodWallet:
|
|
return paymentMethodWallet
|
|
case paymentMethodTopup:
|
|
return paymentMethodTopup
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeOptionalPaymentMethod(value *string) *string {
|
|
normalized := normalizePaymentMethod(stringValue(value))
|
|
if normalized == "" {
|
|
return nil
|
|
}
|
|
return &normalized
|
|
}
|
|
|
|
func buildInvoiceID(id string) string {
|
|
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
|
|
if len(trimmed) > 12 {
|
|
trimmed = trimmed[:12]
|
|
}
|
|
return "INV-" + strings.ToUpper(trimmed)
|
|
}
|
|
|
|
func buildTransactionID(prefix string) string {
|
|
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
|
}
|
|
|
|
func buildInvoiceFilename(id string) string {
|
|
return fmt.Sprintf("invoice-%s.txt", id)
|
|
}
|
|
|
|
func stringValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func sortPaymentHistory(items []PaymentHistoryItem) {
|
|
for i := 0; i < len(items); i++ {
|
|
for j := i + 1; j < len(items); j++ {
|
|
left := time.Time{}
|
|
right := time.Time{}
|
|
if items[i].CreatedAt != nil {
|
|
left = *items[i].CreatedAt
|
|
}
|
|
if items[j].CreatedAt != nil {
|
|
right = *items[j].CreatedAt
|
|
}
|
|
if right.After(left) {
|
|
items[i], items[j] = items[j], items[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func serveInvoiceText(c *gin.Context, filename string, content string) {
|
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
c.String(http.StatusOK, content)
|
|
}
|
|
|
|
func (h *Handler) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) {
|
|
details, err := h.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 (h *Handler) 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 := h.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 := h.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 buildSubscriptionNotification(userID string, paymentID string, 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(gin.H{
|
|
"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 isAllowedTermMonths(value int32) bool {
|
|
_, ok := allowedTermMonths[value]
|
|
return ok
|
|
}
|
|
|
|
func lockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) {
|
|
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 float64, 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 interface{}) string {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(encoded)
|
|
}
|